Fix Sonarr TV request policy and bump gateway to 0.1.40
This commit is contained in:
@@ -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
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -377,6 +377,21 @@ export interface IntegrationsResponse {
|
||||
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 ---------- */
|
||||
|
||||
export interface LogEvent {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useAction, useQuery } from '../lib/hooks';
|
||||
import { useToast } from '../lib/toast';
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Tag,
|
||||
Toggle,
|
||||
} 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.
|
||||
*
|
||||
@@ -111,6 +111,8 @@ export function IntegrationsPage() {
|
||||
|
||||
<Banner message={error} />
|
||||
|
||||
<SonarrRequestCard />
|
||||
|
||||
{(data?.dropped ?? 0) > 0 ? (
|
||||
<Note tone="warn">
|
||||
{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 Memby’s existing all-episodes strategy, and {searchImmediately ? 'start an immediate search.' : 'not start an immediate search.'}</Note> : null}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationCard({
|
||||
integration,
|
||||
catalogue,
|
||||
|
||||
@@ -33,7 +33,7 @@ export function RequestsPage() {
|
||||
<>
|
||||
<Card
|
||||
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 Radarr’s policy. Series follow the dedicated Sonarr request policy: monitored as normal, with backlog searching only when an operator enables it under Integrations."
|
||||
icon="inbox"
|
||||
tone="info"
|
||||
actions={
|
||||
|
||||
@@ -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