Fix Sonarr TV request policy and bump gateway to 0.1.40
This commit is contained in:
@@ -59,6 +59,8 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
||||
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
||||
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("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))
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
type sonarrProfileOption struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Recommended bool `json:"recommended"`
|
||||
}
|
||||
|
||||
type sonarrRequestAdminPolicy struct {
|
||||
QualityProfileID int `json:"qualityProfileId"`
|
||||
SearchImmediately bool `json:"searchImmediately"`
|
||||
Profiles []sonarrProfileOption `json:"profiles"`
|
||||
RecommendedID int `json:"recommendedId"`
|
||||
Configured bool `json:"configured"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) sonarrRequestAdminPolicy(ctx context.Context) sonarrRequestAdminPolicy {
|
||||
policy, err := s.store.SonarrRequestPolicy(ctx)
|
||||
if err != nil {
|
||||
return sonarrRequestAdminPolicy{Profiles: []sonarrProfileOption{}, Error: "Could not read the Sonarr request policy."}
|
||||
}
|
||||
result := sonarrRequestAdminPolicy{QualityProfileID: policy.QualityProfileID, SearchImmediately: policy.SearchImmediately, Profiles: []sonarrProfileOption{}}
|
||||
if s.sonarr == nil {
|
||||
result.Error = "Sonarr is not configured."
|
||||
return result
|
||||
}
|
||||
profiles, err := s.sonarr.QualityProfiles(ctx)
|
||||
if err != nil {
|
||||
result.Error = "Could not read Sonarr 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, sonarrProfileOption{ID: profile.ID, Name: profile.Name, Recommended: recommended})
|
||||
}
|
||||
if policy.QualityProfileID == 0 && result.RecommendedID != 0 {
|
||||
result.QualityProfileID = result.RecommendedID
|
||||
result.Configured = true
|
||||
}
|
||||
if !result.Configured && result.Error == "" {
|
||||
result.Error = "Choose an existing Sonarr quality profile before accepting TV requests."
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type sonarrRequestPolicyRequest struct {
|
||||
QualityProfileID int `json:"qualityProfileId"`
|
||||
SearchImmediately bool `json:"searchImmediately"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSonarrRequestPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
writeJSON(w, http.StatusOK, s.sonarrRequestAdminPolicy(r.Context()))
|
||||
return
|
||||
}
|
||||
if s.sonarr == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "Sonarr is not configured")
|
||||
return
|
||||
}
|
||||
var req sonarrRequestPolicyRequest
|
||||
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 Sonarr quality profile")
|
||||
return
|
||||
}
|
||||
profiles, err := s.sonarr.QualityProfiles(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "could not validate Sonarr 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 Sonarr quality profile no longer exists")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetSonarrRequestPolicy(r.Context(), store.SonarrRequestPolicy{QualityProfileID: req.QualityProfileID, SearchImmediately: req.SearchImmediately}); err != nil {
|
||||
s.loggerFor(r.Context()).Error("Sonarr request policy write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save Sonarr request policy")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("Sonarr request policy changed", "quality_profile_id", req.QualityProfileID, "search_immediately", req.SearchImmediately)
|
||||
writeJSON(w, http.StatusOK, s.sonarrRequestAdminPolicy(r.Context()))
|
||||
}
|
||||
|
||||
func is720pProfile(name string) bool { return strings.EqualFold(strings.TrimSpace(name), "720p") }
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
@@ -302,13 +303,22 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
return
|
||||
}
|
||||
req.Title = show.Title
|
||||
added, err := s.sonarr.AddRequested(r.Context(), show)
|
||||
requestOptions, rootFolder, profileName, err := s.sonarrRequestOptions(r.Context())
|
||||
if err != nil {
|
||||
s.logSonarrRequest(r.Context(), sess, req, show, 0, "failed", "", 0, "", false, err)
|
||||
s.publishSonarrRequestConfigurationProblem(r.Context(), err)
|
||||
writeError(w, http.StatusServiceUnavailable, "TV requests are unavailable: "+err.Error())
|
||||
return
|
||||
}
|
||||
added, err := s.sonarr.AddRequested(r.Context(), show, rootFolder, requestOptions)
|
||||
if err != nil {
|
||||
s.logSonarrRequest(r.Context(), sess, req, show, 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 series")
|
||||
return
|
||||
}
|
||||
req.Title = added.Title
|
||||
s.logSonarrRequest(r.Context(), sess, req, added, added.ID, "successful", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, nil)
|
||||
s.recordMediaRequest(r.Context(), sess, req, added.Year,
|
||||
sonarrCoverURL(added.Images, "poster"))
|
||||
s.logMediaRequest(r.Context(), req, "successful", nil)
|
||||
@@ -378,3 +388,80 @@ func (s *Server) writeRequestUpstreamError(
|
||||
s.loggerFor(ctx).Error(message, "error", err)
|
||||
writeError(w, http.StatusBadGateway, message)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return sonarr.RequestOptions{}, "", "", errors.New("Sonarr integration is unavailable")
|
||||
}
|
||||
policy, err := s.store.SonarrRequestPolicy(ctx)
|
||||
if err != nil {
|
||||
return sonarr.RequestOptions{}, "", "", errors.New("could not read the Sonarr request policy")
|
||||
}
|
||||
roots, err := s.sonarr.RootFolders(ctx)
|
||||
if err != nil {
|
||||
return sonarr.RequestOptions{}, "", "", errors.New("could not validate Sonarr root folders")
|
||||
}
|
||||
if len(roots) == 0 || strings.TrimSpace(roots[0].Path) == "" {
|
||||
return sonarr.RequestOptions{}, "", "", errors.New("Sonarr has no valid root folder")
|
||||
}
|
||||
profiles, err := s.sonarr.QualityProfiles(ctx)
|
||||
if err != nil {
|
||||
return sonarr.RequestOptions{}, "", "", errors.New("could not validate Sonarr quality profiles")
|
||||
}
|
||||
profileID := policy.QualityProfileID
|
||||
if profileID == 0 {
|
||||
for _, profile := range profiles {
|
||||
if is720pProfile(profile.Name) {
|
||||
profileID = profile.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if profileID == 0 {
|
||||
return sonarr.RequestOptions{}, "", "", errors.New("no request quality profile is configured and Sonarr has no 720p profile")
|
||||
}
|
||||
// First-run policy is the safe 720p recommendation. Persist its id immediately so
|
||||
// later profile renames or deletion are caught as configuration errors instead of
|
||||
// becoming a fresh name-based selection.
|
||||
policy.QualityProfileID = profileID
|
||||
if err := s.store.SetSonarrRequestPolicy(ctx, policy); err != nil {
|
||||
return sonarr.RequestOptions{}, "", "", errors.New("could not save the default Sonarr request quality profile")
|
||||
}
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
if profile.ID == profileID {
|
||||
return sonarr.RequestOptions{QualityProfileID: profile.ID, SearchImmediately: policy.SearchImmediately}, roots[0].Path, profile.Name, nil
|
||||
}
|
||||
}
|
||||
return sonarr.RequestOptions{}, "", "", errors.New("the configured Sonarr request quality profile no longer exists")
|
||||
}
|
||||
|
||||
func (s *Server) logSonarrRequest(
|
||||
ctx context.Context, sess store.Session, req requestPayload, series sonarr.Series, seriesID int,
|
||||
outcome, profileName string, profileID int, rootFolder string, searchImmediately bool, err error,
|
||||
) {
|
||||
fields := []any{
|
||||
"user", clientLogValue(sess.Username), "user_id", sess.EmbyUserID,
|
||||
"title", clientLogValue(series.Title), "tvdb_id", series.TVDBID,
|
||||
"sonarr_series_id", seriesID, "quality_profile", profileName,
|
||||
"quality_profile_id", profileID, "monitoring_strategy", "all",
|
||||
"root_folder", rootFolder, "search_immediately", searchImmediately,
|
||||
"outcome", outcome,
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, "sonarr_result", err.Error())
|
||||
s.loggerFor(ctx).Warn("Sonarr TV request", fields...)
|
||||
return
|
||||
}
|
||||
fields = append(fields, "sonarr_result", "created")
|
||||
s.loggerFor(ctx).Info("Sonarr TV request", fields...)
|
||||
}
|
||||
|
||||
func (s *Server) publishSonarrRequestConfigurationProblem(ctx context.Context, err error) {
|
||||
s.publishAdmin(ctx, adminevents.Event{
|
||||
Type: "sonarr.request_configuration", Severity: adminevents.SeverityError,
|
||||
Title: "Sonarr TV requests need attention", Summary: err.Error(), Actor: "memby-server",
|
||||
Link: "/admin/integrations", Metadata: adminevents.Meta(map[string]any{"error": err.Error()}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.38
|
||||
0.1.40
|
||||
|
||||
@@ -84,7 +84,31 @@ type RootFolder struct {
|
||||
}
|
||||
|
||||
type QualityProfile struct {
|
||||
ID int `json:"id"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// RequestOptions are Memby's deliberate request policy. They must be supplied by the
|
||||
// gateway: letting Sonarr choose a profile or search flag reintroduces unsafe defaults.
|
||||
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 EpisodeFile struct {
|
||||
@@ -168,23 +192,18 @@ func (c *Client) Lookup(ctx context.Context, term string) ([]Series, error) {
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// AddRequested adds a series, monitors its seasons and asks Sonarr to search for missing
|
||||
// episodes immediately. An unmonitored catalogue row does not fulfil a media request.
|
||||
func (c *Client) AddRequested(ctx context.Context, series Series) (Series, error) {
|
||||
var roots []RootFolder
|
||||
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
|
||||
return Series{}, err
|
||||
// AddRequested adds a monitored series using the supplied request policy. It never reads
|
||||
// Sonarr's first quality profile, because that is commonly "Any".
|
||||
func (c *Client) AddRequested(ctx context.Context, series Series, rootFolder string, options RequestOptions) (Series, error) {
|
||||
if strings.TrimSpace(rootFolder) == "" {
|
||||
return Series{}, fmt.Errorf("sonarr: request root folder is required")
|
||||
}
|
||||
var profiles []QualityProfile
|
||||
if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil {
|
||||
return Series{}, err
|
||||
}
|
||||
if len(roots) == 0 || len(profiles) == 0 {
|
||||
return Series{}, fmt.Errorf("sonarr: no root folder or quality profile configured")
|
||||
if options.QualityProfileID <= 0 {
|
||||
return Series{}, fmt.Errorf("sonarr: request quality profile is required")
|
||||
}
|
||||
series.ID = 0
|
||||
series.RootFolderPath = roots[0].Path
|
||||
series.QualityProfileID = profiles[0].ID
|
||||
series.RootFolderPath = rootFolder
|
||||
series.QualityProfileID = options.QualityProfileID
|
||||
series.Monitored = true
|
||||
series.SeasonFolder = true
|
||||
for i := range series.Seasons {
|
||||
@@ -193,7 +212,7 @@ func (c *Client) AddRequested(ctx context.Context, series Series) (Series, error
|
||||
body := struct {
|
||||
Series
|
||||
AddOptions map[string]bool `json:"addOptions"`
|
||||
}{Series: series, AddOptions: map[string]bool{"searchForMissingEpisodes": true}}
|
||||
}{Series: series, AddOptions: map[string]bool{"searchForMissingEpisodes": options.SearchImmediately}}
|
||||
var added Series
|
||||
if err := c.post(ctx, "/api/v3/series", body, &added); err != nil {
|
||||
return Series{}, err
|
||||
|
||||
@@ -46,13 +46,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":"/tv"}]`))
|
||||
case "/api/v3/qualityprofile":
|
||||
_, _ = w.Write([]byte(`[{"id":3}]`))
|
||||
case "/api/v3/series":
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
@@ -67,8 +63,8 @@ func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) {
|
||||
t.Errorf("season was not monitored: %#v", body)
|
||||
}
|
||||
options := body["addOptions"].(map[string]any)
|
||||
if options["searchForMissingEpisodes"] != true {
|
||||
t.Errorf("episode search was not enabled: %#v", body)
|
||||
if options["searchForMissingEpisodes"] != false {
|
||||
t.Errorf("episode search was unexpectedly enabled: %#v", body)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":8,"tvdbId":44,"title":"Severance"}`))
|
||||
default:
|
||||
@@ -81,7 +77,7 @@ func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) {
|
||||
context.Background(), Series{
|
||||
TVDBID: 44, Title: "Severance",
|
||||
Seasons: []Season{{SeasonNumber: 1}},
|
||||
},
|
||||
}, "/tv", RequestOptions{QualityProfileID: 3, SearchImmediately: false},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -18,6 +18,10 @@ const MaintenanceKey = "maintenance"
|
||||
// RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr.
|
||||
const RequestPolicyKey = "request_policy"
|
||||
|
||||
// SonarrRequestPolicyKey is deliberately separate from request access. Access answers who
|
||||
// may ask; this policy answers the safe, household-wide way a TV request is created.
|
||||
const SonarrRequestPolicyKey = "sonarr_request_policy"
|
||||
|
||||
// PlaybackPolicyKey controls presentation behaviour that should be adjustable without
|
||||
// shipping a new TV build.
|
||||
const PlaybackPolicyKey = "playback_policy"
|
||||
@@ -379,6 +383,56 @@ type RequestPolicy struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// SonarrRequestPolicy stores Sonarr's stable quality-profile id, never its mutable name.
|
||||
// A zero id means the operator has not selected one yet; the gateway may use only a profile
|
||||
// named 720p as its safe first-run recommendation, never Sonarr's arbitrary default.
|
||||
type SonarrRequestPolicy struct {
|
||||
QualityProfileID int `json:"qualityProfileId"`
|
||||
SearchImmediately bool `json:"searchImmediately"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func DefaultSonarrRequestPolicy() SonarrRequestPolicy { return SonarrRequestPolicy{} }
|
||||
|
||||
func (s *Store) SonarrRequestPolicy(ctx context.Context) (SonarrRequestPolicy, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, SonarrRequestPolicyKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DefaultSonarrRequestPolicy(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return DefaultSonarrRequestPolicy(), fmt.Errorf("store: read Sonarr request policy: %w", err)
|
||||
}
|
||||
var policy SonarrRequestPolicy
|
||||
if err := json.Unmarshal(raw, &policy); err != nil {
|
||||
return DefaultSonarrRequestPolicy(), fmt.Errorf("store: decode Sonarr request policy: %w", err)
|
||||
}
|
||||
if policy.QualityProfileID < 0 {
|
||||
policy.QualityProfileID = 0
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetSonarrRequestPolicy(ctx context.Context, policy SonarrRequestPolicy) error {
|
||||
if policy.QualityProfileID <= 0 {
|
||||
return fmt.Errorf("store: Sonarr 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()`,
|
||||
SonarrRequestPolicyKey, string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write Sonarr 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