0.2.63 update
This commit is contained in:
+12
-8
@@ -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` |
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user