0.2.63 update

This commit is contained in:
ponzischeme89
2026-08-14 11:47:32 +12:00
parent 9a8ecbdceb
commit 06b6490ac9
41 changed files with 921 additions and 179 deletions
+12 -8
View File
@@ -61,8 +61,8 @@ The server writes one aligned line per event, designed for
`docker compose logs -f server` and for the admin page's live log:
```
2026-08-03 11:33:40 INFO playback requested component=playback user=matt device="Living room" client=0.1.60 title="Severance Good News About Hell" item=184223 type=Episode play_method=DirectStream resume=12m0s runtime=57m0s version=0.1.0
2026-08-03 11:33:40 INFO request component=home user=matt device="Living room" client=0.1.60 protocol=1 method=GET path=/v1/home status=200 duration=412ms cache=miss version=0.1.0
2026-08-03 11:33:40 INFO playback requested component=playback user=matt device="Living room" client=0.1.60 correlation=req-8F2A1C play_session_id=8F2A1C title="Severance Good News About Hell" item=184223 type=Episode play_method=DirectStream resume=12m0s runtime=57m0s version=0.1.0
2026-08-03 11:33:40 INFO request component=home user=matt device="Living room" client=0.1.60 correlation=req-4D91B7 protocol=1 method=GET path=/v1/home status=200 duration=412ms cache=miss version=0.1.0
```
The timestamp is a column rather than a `time=` field, and the fields are always in the
@@ -75,9 +75,11 @@ into the admin log when the gateway starts. Compose mounts that path from the pe
`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.
Every line from a request carries the viewer, the television, the app build, a correlation
identifier 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. The same
identifier is returned as `X-Memby-Correlation`; the TV's network diagnostics record it
beside the request outcome. Playback events also carry the Emby play-session identifier.
Beyond the per-request line, these are logged as events in their own right: sign-in,
sign-out and rejected sign-ins; a device removed or renamed from another TV; what
@@ -88,8 +90,10 @@ library imports (start, progress per 500-item page, final counts and duration).
At the default `MEMBY_LOG_LEVEL=INFO`, successful health checks, live maintenance polls,
artwork requests, search terms and ten-second playback progress reports are hidden;
warnings and failures from those routes are still shown. Set `MEMBY_LOG_LEVEL=DEBUG`
temporarily and recreate the server container when they are useful during diagnosis.
warnings and failures from those routes are still shown. Set `MEMBY_LOG_LEVEL=DEBUG` for
diagnostic request and playback detail, or `TRACE` for the deepest negotiation and request
trace, then recreate the server container. URLs and sensitive structured attributes are
redacted before they reach either the console or admin log history.
The build that wrote a line is `internal/buildinfo/VERSION`, embedded at compile time —
bump it with a meaningful server change. It is also on `/healthz` and in the admin rail.
@@ -614,7 +618,7 @@ can review and revoke signed-in TVs from the app's Settings screen.
| `MEMBY_DATABASE_URL` | *required* | Postgres DSN |
| `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_LEVEL` | `INFO` | `DEBUG` adds diagnostic request/playback detail; `TRACE` adds deep negotiation tracing |
| `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` |
+15 -1
View File
@@ -61,6 +61,10 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
mux.Handle("GET /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
mux.Handle("POST /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
mux.Handle("GET /admin/api/radarr-request-policy", s.adminAuth(s.handleAdminRadarrRequestPolicy))
mux.Handle("POST /admin/api/radarr-request-policy", s.adminAuth(s.handleAdminRadarrRequestPolicy))
mux.Handle("GET /admin/api/arr-integrations", s.adminAuth(s.handleAdminArrIntegrations))
mux.Handle("POST /admin/api/arr-integrations", s.adminAuth(s.handleAdminArrIntegrations))
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
mux.Handle("GET /admin/api/hero/search", s.adminAuth(s.handleAdminHeroSearch))
mux.Handle("POST /admin/api/hero-policy", s.adminAuth(s.handleAdminHeroPolicy))
@@ -459,8 +463,10 @@ func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request
return
}
valid := make(map[string]bool, len(known))
usernames := make(map[string]string, len(known))
for _, user := range known {
valid[user.ID] = true
usernames[user.ID] = user.Username
}
seen := map[string]bool{}
allowed := make([]string, 0, len(req.AllowedUserIDs))
@@ -482,7 +488,15 @@ func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusInternalServerError, "could not save request access")
return
}
s.loggerFor(r.Context()).Info("media request access changed", "users", len(allowed))
allowedNames := make([]string, 0, len(allowed))
for _, id := range allowed {
allowedNames = append(allowedNames, usernames[id])
}
s.loggerFor(r.Context()).Info("Media Request access level updated",
"gateway_version", buildinfo.Version(),
"allowed_usernames", strings.Join(allowedNames, ", "),
"allowed_user_ids", strings.Join(allowed, ", "),
"allowed_users", len(allowed))
writeJSON(w, http.StatusOK, policy)
}
@@ -0,0 +1,44 @@
package api
import (
"encoding/json"
"net/http"
"github.com/ponzischeme89/memby/server/internal/store"
)
type arrIntegrationStatus struct {
SonarrConfigured bool `json:"sonarrConfigured"`
RadarrConfigured bool `json:"radarrConfigured"`
SonarrEnabled bool `json:"sonarrEnabled"`
RadarrEnabled bool `json:"radarrEnabled"`
}
func (s *Server) arrIntegrationStatus(r *http.Request) arrIntegrationStatus {
policy, err := s.store.ArrIntegrationPolicy(r.Context())
if err != nil {
policy = store.DefaultArrIntegrationPolicy()
}
return arrIntegrationStatus{SonarrConfigured: s.sonarr != nil, RadarrConfigured: s.radarr != nil, SonarrEnabled: s.sonarr != nil && policy.SonarrEnabled, RadarrEnabled: s.radarr != nil && policy.RadarrEnabled}
}
func (s *Server) handleAdminArrIntegrations(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
writeJSON(w, http.StatusOK, s.arrIntegrationStatus(r))
return
}
var req struct {
SonarrEnabled bool `json:"sonarrEnabled"`
RadarrEnabled bool `json:"radarrEnabled"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if err := s.store.SetArrIntegrationPolicy(r.Context(), store.ArrIntegrationPolicy{SonarrEnabled: req.SonarrEnabled, RadarrEnabled: req.RadarrEnabled}); err != nil {
writeError(w, http.StatusInternalServerError, "could not save integration settings")
return
}
s.loggerFor(r.Context()).Info("arr integrations changed", "sonarr_enabled", req.SonarrEnabled, "radarr_enabled", req.RadarrEnabled)
writeJSON(w, http.StatusOK, s.arrIntegrationStatus(r))
}
@@ -0,0 +1,106 @@
package api
import (
"context"
"encoding/json"
"net/http"
"github.com/ponzischeme89/memby/server/internal/store"
)
type radarrProfileOption struct {
ID int `json:"id"`
Name string `json:"name"`
Recommended bool `json:"recommended"`
}
type radarrRequestAdminPolicy struct {
QualityProfileID int `json:"qualityProfileId"`
SearchImmediately bool `json:"searchImmediately"`
Profiles []radarrProfileOption `json:"profiles"`
RecommendedID int `json:"recommendedId"`
Configured bool `json:"configured"`
Error string `json:"error,omitempty"`
}
func (s *Server) radarrRequestAdminPolicy(ctx context.Context) radarrRequestAdminPolicy {
policy, err := s.store.RadarrRequestPolicy(ctx)
if err != nil {
return radarrRequestAdminPolicy{Profiles: []radarrProfileOption{}, Error: "Could not read the Radarr request policy."}
}
result := radarrRequestAdminPolicy{QualityProfileID: policy.QualityProfileID, SearchImmediately: policy.SearchImmediately, Profiles: []radarrProfileOption{}}
if s.radarr == nil {
result.Error = "Radarr is not configured."
return result
}
profiles, err := s.radarr.QualityProfiles(ctx)
if err != nil {
result.Error = "Could not read Radarr quality profiles."
return result
}
for _, profile := range profiles {
recommended := is720pProfile(profile.Name)
if recommended && result.RecommendedID == 0 {
result.RecommendedID = profile.ID
}
if profile.ID == policy.QualityProfileID {
result.Configured = true
}
result.Profiles = append(result.Profiles, radarrProfileOption{ID: profile.ID, Name: profile.Name, Recommended: recommended})
}
if policy.QualityProfileID == 0 && result.RecommendedID != 0 {
result.QualityProfileID, result.Configured = result.RecommendedID, true
}
if !result.Configured && result.Error == "" {
result.Error = "Choose an existing Radarr quality profile before accepting movie requests."
}
return result
}
type radarrRequestPolicyRequest struct {
QualityProfileID int `json:"qualityProfileId"`
SearchImmediately bool `json:"searchImmediately"`
}
func (s *Server) handleAdminRadarrRequestPolicy(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
writeJSON(w, http.StatusOK, s.radarrRequestAdminPolicy(r.Context()))
return
}
if s.radarr == nil {
writeError(w, http.StatusServiceUnavailable, "Radarr is not configured")
return
}
var req radarrRequestPolicyRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if req.QualityProfileID <= 0 {
writeError(w, http.StatusBadRequest, "choose a Radarr quality profile")
return
}
profiles, err := s.radarr.QualityProfiles(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "could not validate Radarr quality profiles")
return
}
found := false
for _, profile := range profiles {
if profile.ID == req.QualityProfileID {
found = true
break
}
}
if !found {
writeError(w, http.StatusBadRequest, "the selected Radarr quality profile no longer exists")
return
}
if err := s.store.SetRadarrRequestPolicy(r.Context(), store.RadarrRequestPolicy{QualityProfileID: req.QualityProfileID, SearchImmediately: req.SearchImmediately}); err != nil {
s.loggerFor(r.Context()).Error("Radarr request policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save Radarr request policy")
return
}
s.loggerFor(r.Context()).Info("Radarr request policy changed", "quality_profile_id", req.QualityProfileID, "search_immediately", req.SearchImmediately)
writeJSON(w, http.StatusOK, s.radarrRequestAdminPolicy(r.Context()))
}
+1 -1
View File
@@ -180,7 +180,7 @@ func liveAlerts(stored []storedAlert, now time.Time) []clientAlert {
// sonarrAiredAlerts reads the calendar through the same cache the five-day schedule row
// uses, so polling clients never cost a Sonarr request of their own.
func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
if s.sonarr == nil || s.cfg.SonarrAlertWindow <= 0 {
if !s.sonarrEnabled(ctx) || s.cfg.SonarrAlertWindow <= 0 {
return nil
}
row, err := s.sonarrAiringTodayRow(ctx)
+39 -1
View File
@@ -153,6 +153,30 @@ func (s *Server) publishAdmin(ctx context.Context, event adminevents.Event) {
s.adminEvents.Publish(ctx, event)
}
func (s *Server) sonarrEnabled(ctx context.Context) bool {
if s.sonarr == nil || s.store == nil {
return false
}
policy, err := s.store.ArrIntegrationPolicy(ctx)
if err != nil {
s.loggerFor(ctx).Warn("arr integration policy read failed", "error", err)
return false
}
return policy.SonarrEnabled
}
func (s *Server) radarrEnabled(ctx context.Context) bool {
if s.radarr == nil || s.store == nil {
return false
}
policy, err := s.store.ArrIntegrationPolicy(ctx)
if err != nil {
s.loggerFor(ctx).Warn("arr integration policy read failed", "error", err)
return false
}
return policy.RadarrEnabled
}
func (s *Server) Routes() http.Handler {
// The client API lives on its own mux so maintenance mode can gate all of it at
// once, without the gate ever touching health checks or the admin page.
@@ -404,6 +428,9 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
r, identity := withRequestIdentity(r)
w.Header().Set("X-Memby-Correlation", identity.correlation)
s.loggerFor(r.Context()).Log(r.Context(), serverlogging.LevelTrace, "request started",
"method", r.Method, "path", r.URL.Path, "query_keys", queryKeys(r))
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
// Polling the live-log endpoint must not create another live-log record and
@@ -417,7 +444,7 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
//
// Path only: query strings can carry image tokens.
level := requestLogLevel(r.URL.Path, rec.status)
fields := []any{"component", identity.component}
fields := []any{"component", identity.component, "correlation", identity.correlation}
fields = append(fields, identity.viewerAttrs()...)
// The app build keeps its placeholder where the viewer does not, because "which
// build made this call" always has an answer worth seeing, including "it did
@@ -439,6 +466,17 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
})
}
// queryKeys is diagnostic context without values: query values can contain title searches,
// tokens or other private values, while their keys are enough to explain the route shape.
func queryKeys(r *http.Request) string {
keys := make([]string, 0, len(r.URL.Query()))
for key := range r.URL.Query() {
keys = append(keys, key)
}
slices.Sort(keys)
return strings.Join(keys, ",")
}
func clientLogValue(value string) string {
if value == "" {
return "unknown"
+1 -1
View File
@@ -62,7 +62,7 @@ type calendarDay struct {
func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request, _ store.Session) {
ctx := r.Context()
if s.sonarr == nil || !s.featureEnabled(ctx, featureTVCalendar) {
if !s.sonarrEnabled(ctx) || !s.featureEnabled(ctx, featureTVCalendar) {
writeJSON(w, http.StatusOK, emptyCalendar())
return
}
+2 -2
View File
@@ -65,8 +65,8 @@ type homeResponse struct {
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
limit := queryInt(r, "limit", 24, 100)
sonarrSchedule := s.sonarr != nil && supportsSonarrSchedule(r)
radarrSchedule := s.radarr != nil && supportsRadarrSchedule(r)
sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r)
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
hero := supportsHomeHero(r)
key := cache.UserKey(
sess.EmbyUserID,
+2 -2
View File
@@ -84,7 +84,7 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
}
func (s *Server) handleRadarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
if s.radarr == nil {
if !s.radarrEnabled(r.Context()) {
writeError(w, http.StatusNotFound, "unknown image")
return
}
@@ -132,7 +132,7 @@ func (s *Server) handleRadarrImage(w http.ResponseWriter, r *http.Request, itemI
}
func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
if s.sonarr == nil {
if !s.sonarrEnabled(r.Context()) {
writeError(w, http.StatusNotFound, "unknown image")
return
}
+38 -8
View File
@@ -2,6 +2,8 @@ package api
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net"
"net/http"
@@ -19,11 +21,12 @@ import (
// still read what an inner layer filled in. A request is served on one goroutine and the
// handler has returned by the time the middleware reads this, so no lock is needed.
type requestIdentity struct {
component string
user string
device string
client string
protocol string
component string
user string
device string
client string
protocol string
correlation string
}
type identityKey struct{}
@@ -31,9 +34,10 @@ type identityKey struct{}
// withRequestIdentity installs an empty identity for this request and returns it.
func withRequestIdentity(r *http.Request) (*http.Request, *requestIdentity) {
identity := &requestIdentity{
component: componentFor(r.URL.Path),
client: clientVersion(r),
protocol: clientProtocol(r),
component: componentFor(r.URL.Path),
client: clientVersion(r),
protocol: clientProtocol(r),
correlation: requestCorrelation(r),
}
return r.WithContext(context.WithValue(r.Context(), identityKey{}, identity)), identity
}
@@ -79,9 +83,35 @@ func (i *requestIdentity) attrs() []any {
if i.client != "" {
attrs = append(attrs, "client", i.client)
}
if i.correlation != "" {
attrs = append(attrs, "correlation", i.correlation)
}
return attrs
}
// requestCorrelation accepts a client-supplied safe identifier or creates one at the
// gateway boundary. The same value is attached to every log line within the exchange and
// returned to the client, making a playback launch traceable through gateway and Emby work.
func requestCorrelation(r *http.Request) string {
if value := strings.TrimSpace(r.Header.Get("X-Memby-Correlation")); len(value) >= 6 && len(value) <= 64 {
for _, rune := range value {
if !(rune >= 'a' && rune <= 'z' || rune >= 'A' && rune <= 'Z' || rune >= '0' && rune <= '9' || rune == '-' || rune == '_') {
return newCorrelation()
}
}
return value
}
return newCorrelation()
}
func newCorrelation() string {
var raw [4]byte
if _, err := rand.Read(raw[:]); err != nil {
return "request-unknown"
}
return "req-" + strings.ToUpper(hex.EncodeToString(raw[:]))
}
// viewerAttrs names the person and the television, and only when they are known: an
// unauthenticated probe has neither, and "user=unknown" on every health check is noise.
func (i *requestIdentity) viewerAttrs() []any {
+2 -2
View File
@@ -77,7 +77,7 @@ func (s *Server) listMyShows(w http.ResponseWriter, r *http.Request, sess store.
return
}
sonarrSeries := []sonarr.Series{}
if s.sonarr != nil {
if s.sonarrEnabled(r.Context()) {
if value, seriesErr := s.sonarrSeriesCatalogue(r.Context()); seriesErr == nil {
sonarrSeries = value
} else {
@@ -186,7 +186,7 @@ func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, ses
func (s *Server) syncReturnNotifications(
r *http.Request, sess store.Session, prefs store.NotificationPreferences,
) {
if s.sonarr == nil {
if !s.sonarrEnabled(r.Context()) {
return
}
shows, err := s.store.UserShows(r.Context(), sess.EmbyUserID)
+44 -2
View File
@@ -8,8 +8,10 @@ import (
"net/url"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -161,9 +163,11 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
subtitleIndex = &parsed
}
}
negotiationStarted := time.Now()
forceTranscode := queryBool(r, "forceTranscode")
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "",
queryBool(r, "forceTranscode"), s.effectivePlaybackCapabilities(ctx, sess),
forceTranscode, s.effectivePlaybackCapabilities(ctx, sess),
)
streamURL := s.emby.StreamURL(cred, target.ID)
if negotiatedURL != "" {
@@ -198,6 +202,10 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
"subtitles", len(subtitles),
"subtitle_track", clientLogValue(selectedSubtitleID),
"subtitle_language", clientLogValue(subtitleLanguage),
"media_source_id", mediaSourceID,
"play_session_id", clientLogValue(playSessionID),
"force_transcode", forceTranscode,
"negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond),
)
writeJSON(w, http.StatusOK, playbackResponse{
@@ -392,15 +400,20 @@ func (s *Server) playbackSubtitles(
subtitleIndex *int, currentPlaySessionID string, forceTranscode bool,
capabilities emby.PlaybackCapabilities,
) ([]playableSubtitle, string, string, string, string) {
started := time.Now()
log := s.loggerFor(ctx).With("item", itemID, "force_transcode", forceTranscode,
"subtitle_index", subtitleIndex != nil, "resume", millisecondDuration(startTicks/ticksPerMillisecond))
log.Log(ctx, serverlogging.LevelTrace, "Emby playback negotiation started")
info, err := s.emby.PlaybackInfo(
ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, forceTranscode,
capabilities,
)
if err != nil {
s.loggerFor(ctx).Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
log.Warn("Emby playback negotiation failed", "duration", time.Since(started).Round(time.Millisecond), "error", err)
return []playableSubtitle{}, itemID, "", "", "DirectPlay"
}
if len(info.MediaSources) == 0 {
log.Warn("Emby playback negotiation returned no media sources", "duration", time.Since(started).Round(time.Millisecond), "play_session_id", info.PlaySessionID)
return []playableSubtitle{}, itemID, info.PlaySessionID, "", "DirectPlay"
}
out := make([]playableSubtitle, 0)
@@ -457,12 +470,37 @@ func (s *Server) playbackSubtitles(
// download that produced it.
out = mergeSubtitleTracks(out, s.storedSubtitlesFor(ctx, itemID))
delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil)
log.Debug("Emby playback source selected",
"duration", time.Since(started).Round(time.Millisecond), "play_session_id", info.PlaySessionID,
"media_source_id", source.ID, "media_sources", len(info.MediaSources), "subtitles", len(out),
"play_method", playMethod, "selection_reason", playbackSelectionReason(source, forceTranscode || subtitleIndex != nil),
"supports_direct_play", source.SupportsDirectPlay, "supports_direct_stream", source.SupportsDirectStream,
"supports_transcoding", source.SupportsTranscoding)
if delivery != "" {
delivery = s.emby.DeliveryURL(cred, delivery)
}
return out, source.ID, info.PlaySessionID, delivery, playMethod
}
func playbackSelectionReason(source emby.MediaSourceInfo, forceTranscode bool) string {
switch {
case forceTranscode && source.TranscodingURL != "":
return "forced by viewer or burned-in subtitle"
case source.SupportsDirectPlay:
return "source supports direct play"
case source.SupportsDirectStream && source.DirectStreamURL != "":
return "direct play unavailable; source supports direct stream"
case source.SupportsTranscoding && source.TranscodingURL != "":
return "source requires transcoding"
case source.DirectStreamURL != "":
return "fallback direct-stream URL available"
case source.TranscodingURL != "":
return "fallback transcode URL available"
default:
return "Emby supplied no alternate delivery URL"
}
}
func sessionPlaybackCapabilities(sess store.Session) emby.PlaybackCapabilities {
capabilities := emby.PlaybackCapabilities{}
for _, value := range sess.ClientCapabilities {
@@ -729,6 +767,8 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
log := s.loggerFor(r.Context()).With(
"title", s.playbackTitles.name(report.ItemID),
"item", report.ItemID,
"media_source_id", clientLogValue(report.MediaSourceID),
"play_session_id", clientLogValue(report.PlaySessionID),
)
err := s.emby.ReportPlayback(
@@ -756,6 +796,7 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
log.Info("playback started",
"position", millisecondDuration(report.PositionMs),
"play_method", clientLogValue(report.PlayMethod),
"event_name", clientLogValue(report.EventName),
)
case "stopped":
log.Info("playback stopped",
@@ -767,6 +808,7 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
log.Debug("playback progress",
"position", millisecondDuration(report.PositionMs),
"paused", report.IsPaused,
"event_name", clientLogValue(report.EventName),
)
}
+2 -2
View File
@@ -45,7 +45,7 @@ type radarrScheduleItem struct {
}
func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, error) {
if s.radarr == nil {
if !s.radarrEnabled(ctx) {
return nil, nil
}
location := s.cfg.RadarrLocation
@@ -243,7 +243,7 @@ const radarrMovieCacheKey = "radarr:movies:v1"
// Failure degrades to asking Radarr directly — a cache that is down costs latency, never the
// answer.
func (s *Server) radarrMovieCatalogue(ctx context.Context) ([]radarr.Movie, error) {
if s.radarr == nil {
if !s.radarrEnabled(ctx) {
return nil, fmt.Errorf("radarr: not configured")
}
if movies := s.cachedRadarrMovies(ctx); movies != nil {
+87 -7
View File
@@ -48,7 +48,7 @@ type requestLookupResponse struct {
}
func (s *Server) requestAllowed(r *http.Request, sess store.Session) bool {
if s.store == nil || (s.sonarr == nil && s.radarr == nil) {
if s.store == nil || (!s.sonarrEnabled(r.Context()) && !s.radarrEnabled(r.Context())) {
return false
}
policy, err := s.store.RequestPolicy(r.Context())
@@ -73,7 +73,7 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
var movieCandidates []requestCandidate
var seriesCandidates []requestCandidate
var wg sync.WaitGroup
if s.radarr != nil {
if s.radarrEnabled(r.Context()) {
wg.Add(1)
go func() {
defer wg.Done()
@@ -98,7 +98,7 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
}
}()
}
if s.sonarr != nil {
if s.sonarrEnabled(r.Context()) {
wg.Add(1)
go func() {
defer wg.Done()
@@ -235,7 +235,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
switch req.MediaType {
case "movie":
if s.radarr == nil {
if !s.radarrEnabled(r.Context()) {
s.logMediaRequest(r.Context(), req, "failed", errors.New("movie requests are not configured"))
writeError(w, http.StatusServiceUnavailable, "movie requests are not configured")
return
@@ -265,13 +265,22 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
return
}
req.Title = movie.Title
added, err := s.radarr.AddRequested(r.Context(), movie)
requestOptions, rootFolder, profileName, err := s.radarrRequestOptions(r.Context())
if err != nil {
s.logRadarrRequest(r.Context(), sess, req, movie, 0, "failed", "", 0, "", false, err)
s.publishRadarrRequestConfigurationProblem(r.Context(), err)
writeError(w, http.StatusServiceUnavailable, "movie requests are unavailable: "+err.Error())
return
}
added, err := s.radarr.AddRequested(r.Context(), movie, rootFolder, requestOptions)
if err != nil {
s.logRadarrRequest(r.Context(), sess, req, movie, 0, "failed", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, err)
s.logMediaRequest(r.Context(), req, "failed", err)
s.writeRequestUpstreamError(r.Context(), w, err, "could not request that movie")
return
}
req.Title = added.Title
s.logRadarrRequest(r.Context(), sess, req, added, added.ID, "successful", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, nil)
s.recordMediaRequest(r.Context(), sess, req, added.Year,
radarrCoverURL(added.Images, "poster"))
s.logMediaRequest(r.Context(), req, "successful", nil)
@@ -279,7 +288,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
return
}
case "series":
if s.sonarr == nil {
if !s.sonarrEnabled(r.Context()) {
s.logMediaRequest(r.Context(), req, "failed", errors.New("series requests are not configured"))
writeError(w, http.StatusServiceUnavailable, "series requests are not configured")
return
@@ -392,7 +401,7 @@ func (s *Server) writeRequestUpstreamError(
// sonarrRequestOptions validates every component before a POST can reach Sonarr. A missing
// configured profile is an error, not permission to fall back to Sonarr's "Any" profile.
func (s *Server) sonarrRequestOptions(ctx context.Context) (sonarr.RequestOptions, string, string, error) {
if s.sonarr == nil {
if !s.sonarrEnabled(ctx) {
return sonarr.RequestOptions{}, "", "", errors.New("Sonarr integration is unavailable")
}
policy, err := s.store.SonarrRequestPolicy(ctx)
@@ -465,3 +474,74 @@ func (s *Server) publishSonarrRequestConfigurationProblem(ctx context.Context, e
Link: "/admin/integrations", Metadata: adminevents.Meta(map[string]any{"error": err.Error()}),
})
}
func (s *Server) radarrRequestOptions(ctx context.Context) (radarr.RequestOptions, string, string, error) {
if !s.radarrEnabled(ctx) {
return radarr.RequestOptions{}, "", "", errors.New("Radarr integration is unavailable")
}
policy, err := s.store.RadarrRequestPolicy(ctx)
if err != nil {
return radarr.RequestOptions{}, "", "", errors.New("could not read the Radarr request policy")
}
roots, err := s.radarr.RootFolders(ctx)
if err != nil {
return radarr.RequestOptions{}, "", "", errors.New("could not validate Radarr root folders")
}
if len(roots) == 0 || strings.TrimSpace(roots[0].Path) == "" {
return radarr.RequestOptions{}, "", "", errors.New("Radarr has no valid root folder")
}
profiles, err := s.radarr.QualityProfiles(ctx)
if err != nil {
return radarr.RequestOptions{}, "", "", errors.New("could not validate Radarr quality profiles")
}
profileID := policy.QualityProfileID
if profileID == 0 {
for _, profile := range profiles {
if is720pProfile(profile.Name) {
profileID = profile.ID
break
}
}
if profileID == 0 {
return radarr.RequestOptions{}, "", "", errors.New("no request quality profile is configured and Radarr has no 720p profile")
}
policy.QualityProfileID = profileID
if err := s.store.SetRadarrRequestPolicy(ctx, policy); err != nil {
return radarr.RequestOptions{}, "", "", errors.New("could not save the default Radarr request quality profile")
}
}
for _, profile := range profiles {
if profile.ID == profileID {
return radarr.RequestOptions{QualityProfileID: profile.ID, SearchImmediately: policy.SearchImmediately}, roots[0].Path, profile.Name, nil
}
}
return radarr.RequestOptions{}, "", "", errors.New("the configured Radarr request quality profile no longer exists")
}
func (s *Server) logRadarrRequest(
ctx context.Context, sess store.Session, req requestPayload, movie radarr.Movie, movieID int,
outcome, profileName string, profileID int, rootFolder string, searchImmediately bool, err error,
) {
fields := []any{
"user", clientLogValue(sess.Username), "user_id", sess.EmbyUserID,
"title", clientLogValue(movie.Title), "tmdb_id", movie.TMDBID,
"radarr_movie_id", movieID, "quality_profile", profileName,
"quality_profile_id", profileID, "monitoring_strategy", "movie",
"root_folder", rootFolder, "search_immediately", searchImmediately, "outcome", outcome,
}
if err != nil {
fields = append(fields, "radarr_result", err.Error())
s.loggerFor(ctx).Warn("Radarr movie request", fields...)
return
}
fields = append(fields, "radarr_result", "created")
s.loggerFor(ctx).Info("Radarr movie request", fields...)
}
func (s *Server) publishRadarrRequestConfigurationProblem(ctx context.Context, err error) {
s.publishAdmin(ctx, adminevents.Event{
Type: "radarr.request_configuration", Severity: adminevents.SeverityError,
Title: "Radarr movie requests need attention", Summary: err.Error(), Actor: "memby-server",
Link: "/admin/integrations", Metadata: adminevents.Meta(map[string]any{"error": err.Error()}),
})
}
+2 -2
View File
@@ -88,7 +88,7 @@ func (s *Server) decorateRequests(
)
now := time.Now()
if len(movieIDs) > 0 && s.radarr != nil {
if len(movieIDs) > 0 && s.radarrEnabled(ctx) {
wg.Add(1)
go func() {
defer wg.Done()
@@ -111,7 +111,7 @@ func (s *Server) decorateRequests(
}
}()
}
if len(seriesIDs) > 0 && s.sonarr != nil {
if len(seriesIDs) > 0 && s.sonarrEnabled(ctx) {
wg.Add(1)
go func() {
defer wg.Done()
+3 -3
View File
@@ -38,7 +38,7 @@ const sonarrScheduleDays = 5
// Every failure degrades to asking Sonarr directly: a cache that is down must cost latency,
// never the answer.
func (s *Server) sonarrSeriesCatalogue(ctx context.Context) ([]sonarr.Series, error) {
if s.sonarr == nil {
if !s.sonarrEnabled(ctx) {
return nil, fmt.Errorf("sonarr: not configured")
}
if series := s.cachedSonarrSeries(ctx); series != nil {
@@ -95,7 +95,7 @@ type prerollScheduleEntry struct {
}
func (s *Server) handlePreroll(w http.ResponseWriter, r *http.Request, _ store.Session) {
if s.sonarr == nil {
if !s.sonarrEnabled(r.Context()) {
writeJSON(w, http.StatusOK, emptyPrerollSchedule())
return
}
@@ -340,7 +340,7 @@ func (index seriesIndex) logo(title string, year int) (string, string) {
}
func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) {
if s.sonarr == nil {
if !s.sonarrEnabled(ctx) {
return nil, nil
}
location := s.cfg.SonarrLocation
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// daily. History stores changes rather than identical daily snapshots: it still records
// the complete lifecycle while making an active-to-cancelled transition unambiguous.
func (s *Server) WatchSonarrLifecycle(ctx context.Context, interval time.Duration) {
if s.sonarr == nil || interval <= 0 {
if !s.sonarrEnabled(ctx) || interval <= 0 {
return
}
scan := func() {
+13 -11
View File
@@ -59,7 +59,7 @@ func (h *consoleHandler) Handle(_ context.Context, record slog.Record) error {
var line strings.Builder
line.WriteString(record.Time.UTC().Format(time.DateTime))
line.WriteByte(' ')
line.WriteString(pad(record.Level.String(), levelWidth))
line.WriteString(pad(LevelName(record.Level), levelWidth))
line.WriteByte(' ')
line.WriteString(pad(record.Message, messageWidth))
for _, f := range fields {
@@ -103,7 +103,7 @@ func appendField(target []field, groups []string, attr slog.Attr) []field {
return target
}
key := strings.Join(append(append([]string{}, groups...), attr.Key), ".")
return append(target, field{key: key, value: attributeValue(attr.Value)})
return append(target, field{key: key, value: safeAttribute(key, attributeValue(attr.Value))})
}
// fieldRank puts the fields that identify *who and where* first, in the same order on
@@ -111,15 +111,17 @@ func appendField(target []field, groups []string, attr slog.Attr) []field {
// Anything unranked keeps the order the caller wrote it in, which is usually the order
// that reads best for that particular event.
var fieldRank = map[string]int{
"component": 1,
"user": 2,
"device": 3,
"client": 4,
"protocol": 5,
"method": 6,
"path": 7,
"status": 8,
"duration": 9,
"component": 1,
"user": 2,
"device": 3,
"client": 4,
"correlation": 5,
"play_session_id": 6,
"protocol": 7,
"method": 8,
"path": 9,
"status": 10,
"duration": 11,
// Constant per process, so it belongs at the end of the line rather than in front
// of the fields that differ between events.
"version": 900,
+50 -2
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"io"
"log/slog"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -16,11 +17,25 @@ import (
"time"
)
// LevelTrace is deliberately below DEBUG. It is disabled unless MEMBY_LOG_LEVEL=TRACE,
// so tight diagnostic loops can be instrumented without making ordinary DEBUG unusable.
const LevelTrace slog.Level = slog.LevelDebug - 4
// LevelName presents custom levels consistently in every configured output format.
func LevelName(level slog.Level) string {
if level == LevelTrace {
return "TRACE"
}
return level.String()
}
// ParseLevel returns a supported slog level, defaulting to INFO for empty or invalid
// values. Keeping this forgiving prevents a typo in Docker configuration from stopping
// the gateway.
func ParseLevel(value string) slog.Level {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "TRACE":
return LevelTrace
case "DEBUG":
return slog.LevelDebug
case "WARN", "WARNING":
@@ -128,6 +143,11 @@ func NewBuffered(
if attr.Key == slog.TimeKey {
return slog.String(slog.TimeKey, attr.Value.Time().UTC().Format(time.RFC3339))
}
if attr.Key == slog.LevelKey {
if level, ok := attr.Value.Any().(slog.Level); ok {
return slog.String(slog.LevelKey, LevelName(level))
}
}
return attr
},
}
@@ -233,7 +253,7 @@ func (h *captureHandler) Handle(ctx context.Context, record slog.Record) error {
})
h.buffer.append(Event{
OccurredAt: record.Time.UTC(),
Level: record.Level.String(),
Level: LevelName(record.Level),
Message: record.Message,
Attributes: attributes,
})
@@ -266,7 +286,35 @@ func addAttribute(target map[string]string, groups []string, attr slog.Attr) {
}
return
}
target[key] = attributeValue(attr.Value)
target[key] = safeAttribute(key, attributeValue(attr.Value))
}
// safeAttribute is the final guard before a structured record reaches the browser-visible
// buffer and optional on-disk history. Call sites should never log credentials, but this
// makes one accidental token-bearing URL or header non-disclosive by construction.
func safeAttribute(key, value string) string {
lower := strings.ToLower(key)
if strings.Contains(lower, "password") || strings.Contains(lower, "token") ||
strings.Contains(lower, "secret") || strings.Contains(lower, "cookie") ||
strings.Contains(lower, "authorization") || strings.Contains(lower, "api_key") || strings.Contains(lower, "apikey") {
return "[redacted]"
}
if parsed, err := url.Parse(value); err == nil && parsed.RawQuery != "" {
query := parsed.Query()
changed := false
for key := range query {
lowerKey := strings.ToLower(key)
if strings.Contains(lowerKey, "token") || strings.Contains(lowerKey, "key") || strings.Contains(lowerKey, "auth") || lowerKey == "t" {
query.Set(key, "[redacted]")
changed = true
}
}
if changed {
parsed.RawQuery = query.Encode()
return parsed.String()
}
}
return value
}
func (b *Buffer) append(event Event) {
+24
View File
@@ -63,6 +63,29 @@ func TestConsoleQuotesOnlyAmbiguousValues(t *testing.T) {
}
}
func TestLoggingRedactsSensitiveAttributesAndURLs(t *testing.T) {
var output bytes.Buffer
logger, buffer := NewBuffered(&output, LevelTrace, 10, FormatConsole)
logger.Log(nil, LevelTrace, "diagnostic request",
"authorization", "Bearer private-token",
"url", "https://emby.example/stream?api_key=private-key&quality=720p",
)
line := output.String()
if strings.Contains(line, "private-token") || strings.Contains(line, "private-key") {
t.Fatalf("console leaked a secret: %s", line)
}
if !strings.Contains(line, "TRACE") {
t.Fatalf("console did not render TRACE: %s", line)
}
page := buffer.Events(0, 1)
if len(page.Events) != 1 || page.Events[0].Level != "TRACE" ||
page.Events[0].Attributes["authorization"] != "[redacted]" ||
strings.Contains(page.Events[0].Attributes["url"], "private-key") {
t.Fatalf("buffer leaked or mislabelled diagnostic event: %+v", page.Events)
}
}
func TestParseFormat(t *testing.T) {
tests := map[string]Format{
"": FormatConsole,
@@ -144,6 +167,7 @@ func TestPersistentBufferRestoresTheRetainedTail(t *testing.T) {
func TestParseLevel(t *testing.T) {
tests := map[string]slog.Level{
"": slog.LevelInfo,
"trace": LevelTrace,
"debug": slog.LevelDebug,
"WARNING": slog.LevelWarn,
"error": slog.LevelError,
+34 -17
View File
@@ -58,7 +58,31 @@ type RootFolder struct {
}
type QualityProfile struct {
ID int `json:"id"`
ID int `json:"id"`
Name string `json:"name"`
}
// RequestOptions are Memby's deliberate movie-request policy. Radarr defaults are never
// allowed to choose a profile or initiate a search on Memby's behalf.
type RequestOptions struct {
QualityProfileID int
SearchImmediately bool
}
func (c *Client) RootFolders(ctx context.Context) ([]RootFolder, error) {
var roots []RootFolder
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
return nil, err
}
return roots, nil
}
func (c *Client) QualityProfiles(ctx context.Context) ([]QualityProfile, error) {
var profiles []QualityProfile
if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil {
return nil, err
}
return profiles, nil
}
type APIError struct {
@@ -116,29 +140,22 @@ func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) {
return movies, nil
}
// 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
// AddRequested adds a monitored movie using the supplied request policy.
func (c *Client) AddRequested(ctx context.Context, movie Movie, rootFolder string, options RequestOptions) (Movie, error) {
if strings.TrimSpace(rootFolder) == "" {
return Movie{}, fmt.Errorf("radarr: request root folder is required")
}
var profiles []QualityProfile
if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil {
return Movie{}, err
}
if len(roots) == 0 || len(profiles) == 0 {
return Movie{}, fmt.Errorf("radarr: no root folder or quality profile configured")
if options.QualityProfileID <= 0 {
return Movie{}, fmt.Errorf("radarr: request quality profile is required")
}
movie.ID = 0
movie.RootFolderPath = roots[0].Path
movie.QualityProfileID = profiles[0].ID
movie.RootFolderPath = rootFolder
movie.QualityProfileID = options.QualityProfileID
movie.Monitored = true
body := struct {
Movie
AddOptions map[string]bool `json:"addOptions"`
}{Movie: movie, AddOptions: map[string]bool{"searchForMovie": true}}
}{Movie: movie, AddOptions: map[string]bool{"searchForMovie": options.SearchImmediately}}
var added Movie
if err := c.post(ctx, "/api/v3/movie", body, &added); err != nil {
return Movie{}, err
+4 -8
View File
@@ -48,13 +48,9 @@ func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
}
}
func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) {
func TestAddRequestedUsesExplicitPolicyWithoutSearching(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/rootfolder":
_, _ = w.Write([]byte(`[{"path":"/movies"}]`))
case "/api/v3/qualityprofile":
_, _ = w.Write([]byte(`[{"id":4}]`))
case "/api/v3/movie":
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@@ -65,8 +61,8 @@ func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) {
t.Errorf("unexpected add body: %#v", body)
}
options := body["addOptions"].(map[string]any)
if options["searchForMovie"] != true {
t.Errorf("movie search was not enabled: %#v", body)
if options["searchForMovie"] != false {
t.Errorf("movie search was unexpectedly enabled: %#v", body)
}
_, _ = w.Write([]byte(`{"id":9,"tmdbId":22,"title":"Arrival"}`))
default:
@@ -76,7 +72,7 @@ func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) {
defer upstream.Close()
added, err := New(upstream.URL, "secret", time.Second).AddRequested(
context.Background(), Movie{TMDBID: 22, Title: "Arrival"},
context.Background(), Movie{TMDBID: 22, Title: "Arrival"}, "/movies", RequestOptions{QualityProfileID: 4},
)
if err != nil {
t.Fatal(err)
+98
View File
@@ -22,6 +22,55 @@ const RequestPolicyKey = "request_policy"
// may ask; this policy answers the safe, household-wide way a TV request is created.
const SonarrRequestPolicyKey = "sonarr_request_policy"
const RadarrRequestPolicyKey = "radarr_request_policy"
const ArrIntegrationPolicyKey = "arr_integration_policy"
// ArrIntegrationPolicy lets the operator stop either *arr integration without removing
// credentials or request policy. Both start enabled for existing households.
type ArrIntegrationPolicy struct {
SonarrEnabled bool `json:"sonarrEnabled"`
RadarrEnabled bool `json:"radarrEnabled"`
UpdatedAt time.Time `json:"updatedAt"`
}
func DefaultArrIntegrationPolicy() ArrIntegrationPolicy {
return ArrIntegrationPolicy{SonarrEnabled: true, RadarrEnabled: true}
}
func (s *Store) ArrIntegrationPolicy(ctx context.Context) (ArrIntegrationPolicy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, ArrIntegrationPolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return DefaultArrIntegrationPolicy(), nil
}
if err != nil {
return DefaultArrIntegrationPolicy(), fmt.Errorf("store: read arr integration policy: %w", err)
}
var policy ArrIntegrationPolicy
if err := json.Unmarshal(raw, &policy); err != nil {
return DefaultArrIntegrationPolicy(), fmt.Errorf("store: decode arr integration policy: %w", err)
}
return policy, nil
}
func (s *Store) SetArrIntegrationPolicy(ctx context.Context, policy ArrIntegrationPolicy) error {
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
ArrIntegrationPolicyKey, string(raw))
if err != nil {
return fmt.Errorf("store: write arr integration policy: %w", err)
}
return nil
}
// PlaybackPolicyKey controls presentation behaviour that should be adjustable without
// shipping a new TV build.
const PlaybackPolicyKey = "playback_policy"
@@ -433,6 +482,55 @@ func (s *Store) SetSonarrRequestPolicy(ctx context.Context, policy SonarrRequest
return nil
}
// RadarrRequestPolicy is the movie equivalent of SonarrRequestPolicy. It persists the
// stable profile id so a renamed or removed profile becomes a safe configuration error.
type RadarrRequestPolicy struct {
QualityProfileID int `json:"qualityProfileId"`
SearchImmediately bool `json:"searchImmediately"`
UpdatedAt time.Time `json:"updatedAt"`
}
func DefaultRadarrRequestPolicy() RadarrRequestPolicy { return RadarrRequestPolicy{} }
func (s *Store) RadarrRequestPolicy(ctx context.Context) (RadarrRequestPolicy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, RadarrRequestPolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return DefaultRadarrRequestPolicy(), nil
}
if err != nil {
return DefaultRadarrRequestPolicy(), fmt.Errorf("store: read Radarr request policy: %w", err)
}
var policy RadarrRequestPolicy
if err := json.Unmarshal(raw, &policy); err != nil {
return DefaultRadarrRequestPolicy(), fmt.Errorf("store: decode Radarr request policy: %w", err)
}
if policy.QualityProfileID < 0 {
policy.QualityProfileID = 0
}
return policy, nil
}
func (s *Store) SetRadarrRequestPolicy(ctx context.Context, policy RadarrRequestPolicy) error {
if policy.QualityProfileID <= 0 {
return fmt.Errorf("store: Radarr request quality profile is required")
}
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
RadarrRequestPolicyKey, string(raw))
if err != nil {
return fmt.Errorf("store: write Radarr request policy: %w", err)
}
return nil
}
func (p RequestPolicy) Allows(userID string) bool {
for _, allowed := range p.AllowedUserIDs {
if allowed == userID {