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
+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() {