Fix Sonarr TV request policy and bump gateway to 0.1.40

This commit is contained in:
ponzischeme89
2026-08-14 10:30:25 +12:00
parent 5e2ed3d12e
commit 19f08e6293
11 changed files with 366 additions and 29 deletions
+3
View File
@@ -1,3 +1,6 @@
## 0.1.40 — 2026-08-14
- Fixed: TV requests use Memby's safe Sonarr request policy: a selected 720p profile, normal monitoring and no immediate backlog search unless an operator enables it.
## 0.2.62 — 2026-08-13 ## 0.2.62 — 2026-08-13
- Fixed: Playback is back to the way it worked in 0.2.60. The groundwork for Memby playing video itself caused problems, so it has been withdrawn and will return once it is ready. - Fixed: Playback is back to the way it worked in 0.2.60. The groundwork for Memby playing video itself caused problems, so it has been withdrawn and will return once it is ready.
+15
View File
@@ -377,6 +377,21 @@ export interface IntegrationsResponse {
dropped: number; dropped: number;
} }
export interface SonarrProfileOption {
id: number;
name: string;
recommended: boolean;
}
export interface SonarrRequestPolicy {
qualityProfileId: number;
searchImmediately: boolean;
profiles: SonarrProfileOption[];
recommendedId: number;
configured: boolean;
error?: string;
}
/* ---------- logs ---------- */ /* ---------- logs ---------- */
export interface LogEvent { export interface LogEvent {
+53 -2
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks'; import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast'; import { useToast } from '../lib/toast';
@@ -18,7 +18,7 @@ import {
Tag, Tag,
Toggle, Toggle,
} from '../components/ui'; } from '../components/ui';
import type { Integration, IntegrationEventOption, IntegrationsResponse } from '../api/types'; import type { Integration, IntegrationEventOption, IntegrationsResponse, SonarrRequestPolicy } from '../api/types';
/* Integrations: administrative events going out to somewhere else. /* Integrations: administrative events going out to somewhere else.
* *
@@ -111,6 +111,8 @@ export function IntegrationsPage() {
<Banner message={error} /> <Banner message={error} />
<SonarrRequestCard />
{(data?.dropped ?? 0) > 0 ? ( {(data?.dropped ?? 0) > 0 ? (
<Note tone="warn"> <Note tone="warn">
{num(data?.dropped ?? 0)} events could not be queued for delivery. The queue is deliberately {num(data?.dropped ?? 0)} events could not be queued for delivery. The queue is deliberately
@@ -168,6 +170,55 @@ export function IntegrationsPage() {
); );
} }
function SonarrRequestCard() {
const { wrap } = useToast();
const { busy, run } = useAction();
const { data, error, loading, reload } = useQuery<SonarrRequestPolicy>('/admin/api/sonarr-request-policy');
const [profileId, setProfileId] = useState(0);
const [searchImmediately, setSearchImmediately] = useState(false);
useEffect(() => {
if (data) {
setProfileId(data.qualityProfileId);
setSearchImmediately(data.searchImmediately);
}
}, [data]);
const save = () => run('sonarr-request-policy', async () => {
await wrap(
() => api.post('/admin/api/sonarr-request-policy', { qualityProfileId: profileId, searchImmediately }),
'Sonarr TV request policy saved.',
);
await reload();
});
const selected = data?.profiles.find((profile) => profile.id === profileId);
return (
<Card
title="Sonarr TV requests"
intro="The policy Memby uses when a viewer requests a television series. The series remains monitored; searching its existing episodes is an explicit choice."
icon="tv"
tone={data?.configured ? 'ok' : 'warn'}
actions={data?.configured ? <Tag tone="ok">configured</Tag> : <Tag tone="warn">needs attention</Tag>}
footer={<Button variant="primary" busy={busy === 'sonarr-request-policy'} disabled={loading || profileId <= 0} onClick={() => void save()}>Save Sonarr policy</Button>}
>
<Banner message={error ?? data?.error ?? ''} />
{loading ? <Loading rows={2} /> : (
<>
<div className="fields">
<Field label="Request quality profile" hint="Memby stores this Sonarr profile ID. 720p is the recommended safe default; Memby will never fall back to Any.">
<select value={profileId} onChange={(event) => setProfileId(Number(event.target.value))} disabled={!data?.profiles.length}>
<option value={0}>Choose a quality profile…</option>
{data?.profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}{profile.recommended ? ' recommended (720p)' : ''}</option>)}
</select>
</Field>
</div>
<Toggle label="Search for episodes immediately after request" hint="Off adds and monitors the series without searching its backlog. Enable only when requests should start an immediate episode search." checked={searchImmediately} onChange={setSearchImmediately} />
{selected ? <Note tone="info">Requested series will use <b>{selected.name}</b> (profile ID {selected.id}), be monitored using Membys existing all-episodes strategy, and {searchImmediately ? 'start an immediate search.' : 'not start an immediate search.'}</Note> : null}
</>
)}
</Card>
);
}
function IntegrationCard({ function IntegrationCard({
integration, integration,
catalogue, catalogue,
+1 -1
View File
@@ -33,7 +33,7 @@ export function RequestsPage() {
<> <>
<Card <Card
title="Where a request goes" title="Where a request goes"
intro="A movie or series is monitored and searched for immediately. The configured download service handles it from there." intro="Movies follow Radarrs policy. Series follow the dedicated Sonarr request policy: monitored as normal, with backlog searching only when an operator enables it under Integrations."
icon="inbox" icon="inbox"
tone="info" tone="info"
actions={ actions={
+2
View File
@@ -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/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy)) mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy)) 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("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
mux.Handle("GET /admin/api/hero/search", s.adminAuth(s.handleAdminHeroSearch)) mux.Handle("GET /admin/api/hero/search", s.adminAuth(s.handleAdminHeroSearch))
mux.Handle("POST /admin/api/hero-policy", s.adminAuth(s.handleAdminHeroPolicy)) 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") }
+88 -1
View File
@@ -11,6 +11,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/radarr" "github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/store"
@@ -302,13 +303,22 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
return return
} }
req.Title = show.Title req.Title = show.Title
added, err := s.sonarr.AddRequested(r.Context(), show) requestOptions, rootFolder, profileName, err := s.sonarrRequestOptions(r.Context())
if err != nil { 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.logMediaRequest(r.Context(), req, "failed", err)
s.writeRequestUpstreamError(r.Context(), w, err, "could not request that series") s.writeRequestUpstreamError(r.Context(), w, err, "could not request that series")
return return
} }
req.Title = added.Title 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, s.recordMediaRequest(r.Context(), sess, req, added.Year,
sonarrCoverURL(added.Images, "poster")) sonarrCoverURL(added.Images, "poster"))
s.logMediaRequest(r.Context(), req, "successful", nil) s.logMediaRequest(r.Context(), req, "successful", nil)
@@ -378,3 +388,80 @@ func (s *Server) writeRequestUpstreamError(
s.loggerFor(ctx).Error(message, "error", err) s.loggerFor(ctx).Error(message, "error", err)
writeError(w, http.StatusBadGateway, message) 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
View File
@@ -1 +1 @@
0.1.38 0.1.40
+35 -16
View File
@@ -84,7 +84,31 @@ type RootFolder struct {
} }
type QualityProfile 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 { type EpisodeFile struct {
@@ -168,23 +192,18 @@ func (c *Client) Lookup(ctx context.Context, term string) ([]Series, error) {
return series, nil return series, nil
} }
// AddRequested adds a series, monitors its seasons and asks Sonarr to search for missing // AddRequested adds a monitored series using the supplied request policy. It never reads
// episodes immediately. An unmonitored catalogue row does not fulfil a media request. // Sonarr's first quality profile, because that is commonly "Any".
func (c *Client) AddRequested(ctx context.Context, series Series) (Series, error) { func (c *Client) AddRequested(ctx context.Context, series Series, rootFolder string, options RequestOptions) (Series, error) {
var roots []RootFolder if strings.TrimSpace(rootFolder) == "" {
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil { return Series{}, fmt.Errorf("sonarr: request root folder is required")
return Series{}, err
} }
var profiles []QualityProfile if options.QualityProfileID <= 0 {
if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil { return Series{}, fmt.Errorf("sonarr: request quality profile is required")
return Series{}, err
}
if len(roots) == 0 || len(profiles) == 0 {
return Series{}, fmt.Errorf("sonarr: no root folder or quality profile configured")
} }
series.ID = 0 series.ID = 0
series.RootFolderPath = roots[0].Path series.RootFolderPath = rootFolder
series.QualityProfileID = profiles[0].ID series.QualityProfileID = options.QualityProfileID
series.Monitored = true series.Monitored = true
series.SeasonFolder = true series.SeasonFolder = true
for i := range series.Seasons { for i := range series.Seasons {
@@ -193,7 +212,7 @@ func (c *Client) AddRequested(ctx context.Context, series Series) (Series, error
body := struct { body := struct {
Series Series
AddOptions map[string]bool `json:"addOptions"` 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 var added Series
if err := c.post(ctx, "/api/v3/series", body, &added); err != nil { if err := c.post(ctx, "/api/v3/series", body, &added); err != nil {
return Series{}, err return Series{}, err
+4 -8
View File
@@ -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) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { 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": case "/api/v3/series":
var body map[string]any var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 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) t.Errorf("season was not monitored: %#v", body)
} }
options := body["addOptions"].(map[string]any) options := body["addOptions"].(map[string]any)
if options["searchForMissingEpisodes"] != true { if options["searchForMissingEpisodes"] != false {
t.Errorf("episode search was not enabled: %#v", body) t.Errorf("episode search was unexpectedly enabled: %#v", body)
} }
_, _ = w.Write([]byte(`{"id":8,"tvdbId":44,"title":"Severance"}`)) _, _ = w.Write([]byte(`{"id":8,"tvdbId":44,"title":"Severance"}`))
default: default:
@@ -81,7 +77,7 @@ func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) {
context.Background(), Series{ context.Background(), Series{
TVDBID: 44, Title: "Severance", TVDBID: 44, Title: "Severance",
Seasons: []Season{{SeasonNumber: 1}}, Seasons: []Season{{SeasonNumber: 1}},
}, }, "/tv", RequestOptions{QualityProfileID: 3, SearchImmediately: false},
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+54
View File
@@ -18,6 +18,10 @@ const MaintenanceKey = "maintenance"
// RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr. // RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr.
const RequestPolicyKey = "request_policy" 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 // PlaybackPolicyKey controls presentation behaviour that should be adjustable without
// shipping a new TV build. // shipping a new TV build.
const PlaybackPolicyKey = "playback_policy" const PlaybackPolicyKey = "playback_policy"
@@ -379,6 +383,56 @@ type RequestPolicy struct {
UpdatedAt time.Time `json:"updatedAt"` 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 { func (p RequestPolicy) Allows(userID string) bool {
for _, allowed := range p.AllowedUserIDs { for _, allowed := range p.AllowedUserIDs {
if allowed == userID { if allowed == userID {