Skip to content

Commit a92fca0

Browse files
committed
Add radarr support
1 parent f62fbcd commit a92fca0

7 files changed

Lines changed: 466 additions & 6 deletions

File tree

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,10 @@ sorted by like count and shown with per-participant stats.
5757

5858
**After the match.** With `MATCHARR__SEERR__URL` configured, the host gets
5959
a Request button and a Radarr quality profile picker for any movie in the
60-
deck. Leave it empty and the button disappears, leaving matcharr as a
61-
picker.
60+
deck. No Seerr? Point `MATCHARR__RADARR__URL` at Radarr directly and the
61+
same button adds the movie there, monitored and searching. When both are
62+
set, Seerr wins. Leave both empty and the button disappears, leaving
63+
matcharr as a picker.
6264

6365
**How it's built.** A single image, no database, since sessions
6466
are ephemeral by nature and live in memory.
@@ -160,6 +162,9 @@ which fetches metadata and posters on their behalf.
160162
| `MATCHARR__IMAGE__MAXFILEMB` | ceiling on one poster download, MB | `4` |
161163
| `MATCHARR__SEERR__URL` | Seerr address, empty hides the Request button | empty |
162164
| `MATCHARR__SEERR__APIKEY` | Seerr API key | none |
165+
| `MATCHARR__RADARR__URL` | Radarr address, used when Seerr is not set | empty |
166+
| `MATCHARR__RADARR__APIKEY` | Radarr API key | none |
167+
| `MATCHARR__RADARR__ROOTFOLDER` | root folder for added movies | Radarr's first root folder |
163168
| `MATCHARR__SESSION__TTL` | outer ceiling on any session | `24h` |
164169
| `MATCHARR__SESSION__EMPTYGRACE` | lifetime with nobody connected or calling | `15m` |
165170
| `MATCHARR__SESSION__FINISHEDTTL` | how long results stay fetchable | `1h` |
@@ -270,7 +275,7 @@ in `internal/app`.
270275

271276
| Method and path | What it does |
272277
|-------------------------------------------------|--------------------------------------------------------|
273-
| `GET /api/config` | public flags, such as whether Seerr is enabled |
278+
| `GET /api/config` | public flags, such as whether requests are enabled |
274279
| `GET /api/genres` | genre options for the filter builder |
275280
| `GET /api/presets` | preset list |
276281
| `GET /api/image/{size}/{file}` | poster proxy cache (`w185` and `w500`) |
@@ -289,7 +294,7 @@ in `internal/app`.
289294
| `POST /api/sessions/{code}/leave` | leave for good (guest) |
290295
| `DELETE /api/sessions/{code}/participants/{id}` | kick (host) |
291296
| `GET /api/sessions/{code}/request/profiles` | Radarr quality profiles (host) |
292-
| `POST /api/sessions/{code}/request` | `{movieId, profileId}` sends a request to Seerr (host) |
297+
| `POST /api/sessions/{code}/request` | `{movieId, profileId}` to Seerr or Radarr (host) |
293298
| `GET /api/sessions/{code}/ws?token=` | session event WebSocket |
294299
| `GET /healthz` | health, always at the root, outside `URLBASE` |
295300

internal/app/app.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"log/slog"
66
"path/filepath"
77

8+
"github.com/Jagerente/matcharr/internal/client/radarr"
89
"github.com/Jagerente/matcharr/internal/client/seerr"
910
"github.com/Jagerente/matcharr/internal/client/tmdb"
1011
"github.com/Jagerente/matcharr/internal/config"
@@ -69,9 +70,16 @@ func New(cfg *config.Config, logger *slog.Logger) (*App, error) {
6970
catalogSvc := catalog.New(presets, tmdbSource, sessions)
7071

7172
var requester request.Requester
72-
if cfg.Seerr.URL != "" {
73+
switch {
74+
case cfg.Seerr.URL != "":
7375
requester = seerr.New(cfg.Seerr.URL, cfg.Seerr.APIKey)
7476
logger.Info("Seerr integration enabled", "url", cfg.Seerr.URL)
77+
if cfg.Radarr.URL != "" {
78+
logger.Warn("both Seerr and Radarr are configured, requests go through Seerr")
79+
}
80+
case cfg.Radarr.URL != "":
81+
requester = radarr.New(cfg.Radarr.URL, cfg.Radarr.APIKey, cfg.Radarr.RootFolder)
82+
logger.Info("Radarr integration enabled", "url", cfg.Radarr.URL)
7583
}
7684
requests := request.New(sessions, requester, logger)
7785

internal/client/radarr/radarr.go

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package radarr
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"slices"
12+
"strconv"
13+
"strings"
14+
"time"
15+
16+
"github.com/Jagerente/matcharr/internal/cache"
17+
"github.com/Jagerente/matcharr/internal/domain"
18+
)
19+
20+
const (
21+
httpTimeout = 15 * time.Second
22+
targetCacheKey = "target"
23+
targetCacheTTL = 10 * time.Minute
24+
// minimumAvailability mirrors Radarr's own default for added movies.
25+
minimumAvailability = "announced"
26+
)
27+
28+
type Client struct {
29+
baseURL string
30+
apiKey string
31+
rootFolder string
32+
http *http.Client
33+
target *cache.TTL[target]
34+
}
35+
36+
func New(baseURL, apiKey, rootFolder string) *Client {
37+
return &Client{
38+
baseURL: strings.TrimRight(baseURL, "/"),
39+
apiKey: apiKey,
40+
rootFolder: rootFolder,
41+
http: &http.Client{Timeout: httpTimeout},
42+
target: cache.NewTTL[target](targetCacheTTL),
43+
}
44+
}
45+
46+
func (c *Client) Profiles(ctx context.Context) (domain.QualityProfiles, error) {
47+
t, err := c.load(ctx)
48+
if err != nil {
49+
return domain.QualityProfiles{}, err
50+
}
51+
return domain.QualityProfiles{Profiles: t.profiles, DefaultID: t.defaultID}, nil
52+
}
53+
54+
func (c *Client) Request(ctx context.Context, movieID int64, profileID int) error {
55+
t, err := c.load(ctx)
56+
if err != nil {
57+
return err
58+
}
59+
// Radarr requires a profile on every add and has no server-side default
60+
// the way Seerr does, so a zero id resolves to the first profile here.
61+
if profileID == 0 {
62+
profileID = t.defaultID
63+
} else if !t.hasProfile(profileID) {
64+
return fmt.Errorf("radarr: unknown quality profile %d", profileID)
65+
}
66+
67+
movie, err := c.lookup(ctx, movieID)
68+
if err != nil {
69+
return err
70+
}
71+
movie["qualityProfileId"] = profileID
72+
movie["rootFolderPath"] = t.rootFolder
73+
movie["monitored"] = true
74+
movie["minimumAvailability"] = minimumAvailability
75+
movie["addOptions"] = map[string]any{"searchForMovie": true}
76+
77+
return c.add(ctx, movie)
78+
}
79+
80+
// lookup fetches the full movie resource: Radarr's add endpoint expects the
81+
// looked-up object posted back, not a bare TMDB id.
82+
func (c *Client) lookup(ctx context.Context, movieID int64) (map[string]any, error) {
83+
var movie map[string]any
84+
path := "/api/v3/movie/lookup/tmdb?tmdbId=" + strconv.FormatInt(movieID, 10)
85+
if err := c.get(ctx, path, &movie); err != nil {
86+
return nil, err
87+
}
88+
if movie["title"] == nil {
89+
return nil, fmt.Errorf("radarr: movie %d has no TMDB record", movieID)
90+
}
91+
return movie, nil
92+
}
93+
94+
func (c *Client) add(ctx context.Context, movie map[string]any) error {
95+
body, err := json.Marshal(movie)
96+
if err != nil {
97+
return err
98+
}
99+
req, err := c.newRequest(ctx, http.MethodPost, "/api/v3/movie", bytes.NewReader(body))
100+
if err != nil {
101+
return err
102+
}
103+
req.Header.Set("Content-Type", "application/json")
104+
105+
resp, err := c.http.Do(req)
106+
if err != nil {
107+
return fmt.Errorf("radarr: %w", err)
108+
}
109+
defer resp.Body.Close()
110+
111+
if resp.StatusCode == http.StatusCreated {
112+
return nil
113+
}
114+
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
115+
// A movie already in the library fails validation, which counts as
116+
// success for the user, like Seerr's 409.
117+
if resp.StatusCode == http.StatusBadRequest && alreadyAdded(snippet) {
118+
return nil
119+
}
120+
return fmt.Errorf("radarr: status %d: %s", resp.StatusCode, bytes.TrimSpace(snippet))
121+
}
122+
123+
func alreadyAdded(body []byte) bool {
124+
var failures []struct {
125+
ErrorCode string `json:"errorCode"`
126+
}
127+
if json.Unmarshal(body, &failures) != nil {
128+
return false
129+
}
130+
for _, f := range failures {
131+
if f.ErrorCode == "MovieExistsValidator" {
132+
return true
133+
}
134+
}
135+
return false
136+
}
137+
138+
type target struct {
139+
defaultID int
140+
rootFolder string
141+
profiles []domain.QualityProfile
142+
}
143+
144+
func (t target) hasProfile(id int) bool {
145+
return slices.ContainsFunc(t.profiles, func(p domain.QualityProfile) bool { return p.ID == id })
146+
}
147+
148+
func (c *Client) load(ctx context.Context) (target, error) {
149+
if t, ok := c.target.Get(targetCacheKey); ok {
150+
return t, nil
151+
}
152+
153+
var dtos []profileDTO
154+
if err := c.get(ctx, "/api/v3/qualityprofile", &dtos); err != nil {
155+
return target{}, err
156+
}
157+
if len(dtos) == 0 {
158+
return target{}, errors.New("radarr: no quality profiles are configured")
159+
}
160+
profiles := make([]domain.QualityProfile, 0, len(dtos))
161+
for _, p := range dtos {
162+
profiles = append(profiles, domain.QualityProfile{ID: p.ID, Name: p.Name})
163+
}
164+
165+
var folders []rootFolderDTO
166+
if err := c.get(ctx, "/api/v3/rootfolder", &folders); err != nil {
167+
return target{}, err
168+
}
169+
folder, err := pickRootFolder(folders, c.rootFolder)
170+
if err != nil {
171+
return target{}, err
172+
}
173+
174+
t := target{defaultID: profiles[0].ID, rootFolder: folder, profiles: profiles}
175+
c.target.Set(targetCacheKey, t)
176+
return t, nil
177+
}
178+
179+
type profileDTO struct {
180+
ID int `json:"id"`
181+
Name string `json:"name"`
182+
}
183+
184+
type rootFolderDTO struct {
185+
Path string `json:"path"`
186+
}
187+
188+
// pickRootFolder compares trimmed paths: Radarr reports folders without a
189+
// trailing separator, while the configured value may carry one.
190+
func pickRootFolder(folders []rootFolderDTO, configured string) (string, error) {
191+
if len(folders) == 0 {
192+
return "", errors.New("radarr: no root folder is configured")
193+
}
194+
if configured == "" {
195+
return folders[0].Path, nil
196+
}
197+
want := trimSep(configured)
198+
for _, f := range folders {
199+
if trimSep(f.Path) == want {
200+
return f.Path, nil
201+
}
202+
}
203+
return "", fmt.Errorf("radarr: root folder %q is not configured in Radarr", configured)
204+
}
205+
206+
func trimSep(path string) string { return strings.TrimRight(path, "/\\") }
207+
208+
func (c *Client) get(ctx context.Context, path string, out any) error {
209+
req, err := c.newRequest(ctx, http.MethodGet, path, nil)
210+
if err != nil {
211+
return err
212+
}
213+
resp, err := c.http.Do(req)
214+
if err != nil {
215+
return fmt.Errorf("radarr: %w", err)
216+
}
217+
defer resp.Body.Close()
218+
219+
if resp.StatusCode != http.StatusOK {
220+
return statusError(resp)
221+
}
222+
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
223+
return fmt.Errorf("radarr: decoding %s: %w", path, err)
224+
}
225+
return nil
226+
}
227+
228+
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
229+
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
230+
if err != nil {
231+
return nil, err
232+
}
233+
req.Header.Set("X-Api-Key", c.apiKey)
234+
return req, nil
235+
}
236+
237+
func statusError(resp *http.Response) error {
238+
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
239+
return fmt.Errorf("radarr: status %d: %s", resp.StatusCode, bytes.TrimSpace(snippet))
240+
}

0 commit comments

Comments
 (0)