Change observability service ports and add Arrtrix content management
- Update ports for Alloy, Grafana, Loki, Prometheus, Promtail, Tempo, and Uptime Kuma to new ranges - Add Arrtrix content management commands and subscriptions - Implement Radarr and Sonarr client logic for movie and series management - Add matrix commands for download and subscription management - Add subscription repository with database schema and logic - Update Arrtrix config and example config for content section - Update help text and command processor to include new commands - Update vendor hash for Arrtrix package
This commit is contained in:
parent
9b93f017b6
commit
e26e25b566
24 changed files with 1340 additions and 82 deletions
211
packages/arrtrix/pkg/arrclient/client.go
Normal file
211
packages/arrtrix/pkg/arrclient/client.go
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
package arrclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
ContentType() arr.ContentType
|
||||
Search(context.Context, string) ([]SearchResult, error)
|
||||
List(context.Context, string) ([]ManagedItem, error)
|
||||
Add(context.Context, SearchResult) (*ManagedItem, error)
|
||||
SetMonitored(context.Context, int64, bool) (*ManagedItem, error)
|
||||
Delete(context.Context, int64) error
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
LookupID int64
|
||||
Title string
|
||||
Year int
|
||||
Overview string
|
||||
}
|
||||
|
||||
type ManagedItem struct {
|
||||
ID int64
|
||||
LookupID int64
|
||||
Title string
|
||||
Year int
|
||||
Monitored bool
|
||||
Path string
|
||||
}
|
||||
|
||||
type RadarrConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
APIKey string `yaml:"api_key"`
|
||||
RootFolderPath string `yaml:"root_folder_path"`
|
||||
QualityProfileID int64 `yaml:"quality_profile_id"`
|
||||
MinimumAvailability string `yaml:"minimum_availability"`
|
||||
SearchOnAdd *bool `yaml:"search_on_add"`
|
||||
}
|
||||
|
||||
type SonarrConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
APIKey string `yaml:"api_key"`
|
||||
RootFolderPath string `yaml:"root_folder_path"`
|
||||
QualityProfileID int64 `yaml:"quality_profile_id"`
|
||||
LanguageProfileID int64 `yaml:"language_profile_id"`
|
||||
SeasonFolder *bool `yaml:"season_folder"`
|
||||
SeriesType string `yaml:"series_type"`
|
||||
SearchOnAdd *bool `yaml:"search_on_add"`
|
||||
}
|
||||
|
||||
type httpClient struct {
|
||||
baseURL *url.URL
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func (c *RadarrConfig) ApplyDefaults() {
|
||||
if c.MinimumAvailability == "" {
|
||||
c.MinimumAvailability = "released"
|
||||
}
|
||||
}
|
||||
|
||||
func (c RadarrConfig) Enabled() bool {
|
||||
return strings.TrimSpace(c.URL) != "" || strings.TrimSpace(c.APIKey) != ""
|
||||
}
|
||||
|
||||
func (c RadarrConfig) Validate() error {
|
||||
if !c.Enabled() {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(c.URL) == "":
|
||||
return fmt.Errorf("network.content.movies.url must be set when movies content is configured")
|
||||
case strings.TrimSpace(c.APIKey) == "":
|
||||
return fmt.Errorf("network.content.movies.api_key must be set when movies content is configured")
|
||||
case strings.TrimSpace(c.RootFolderPath) == "":
|
||||
return fmt.Errorf("network.content.movies.root_folder_path must be set when movies content is configured")
|
||||
case c.QualityProfileID <= 0:
|
||||
return fmt.Errorf("network.content.movies.quality_profile_id must be set when movies content is configured")
|
||||
case strings.TrimSpace(c.MinimumAvailability) == "":
|
||||
return fmt.Errorf("network.content.movies.minimum_availability must not be empty")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c RadarrConfig) SearchOnAddValue() bool {
|
||||
return boolValue(c.SearchOnAdd, true)
|
||||
}
|
||||
|
||||
func (c *SonarrConfig) ApplyDefaults() {
|
||||
if c.SeriesType == "" {
|
||||
c.SeriesType = "standard"
|
||||
}
|
||||
}
|
||||
|
||||
func (c SonarrConfig) Enabled() bool {
|
||||
return strings.TrimSpace(c.URL) != "" || strings.TrimSpace(c.APIKey) != ""
|
||||
}
|
||||
|
||||
func (c SonarrConfig) Validate() error {
|
||||
if !c.Enabled() {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(c.URL) == "":
|
||||
return fmt.Errorf("network.content.series.url must be set when series content is configured")
|
||||
case strings.TrimSpace(c.APIKey) == "":
|
||||
return fmt.Errorf("network.content.series.api_key must be set when series content is configured")
|
||||
case strings.TrimSpace(c.RootFolderPath) == "":
|
||||
return fmt.Errorf("network.content.series.root_folder_path must be set when series content is configured")
|
||||
case c.QualityProfileID <= 0:
|
||||
return fmt.Errorf("network.content.series.quality_profile_id must be set when series content is configured")
|
||||
case c.LanguageProfileID <= 0:
|
||||
return fmt.Errorf("network.content.series.language_profile_id must be set when series content is configured")
|
||||
case strings.TrimSpace(c.SeriesType) == "":
|
||||
return fmt.Errorf("network.content.series.series_type must not be empty")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c SonarrConfig) SeasonFolderValue() bool {
|
||||
return boolValue(c.SeasonFolder, true)
|
||||
}
|
||||
|
||||
func (c SonarrConfig) SearchOnAddValue() bool {
|
||||
return boolValue(c.SearchOnAdd, true)
|
||||
}
|
||||
|
||||
func newHTTPClient(rawURL, apiKey string) (*httpClient, error) {
|
||||
parsedURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(rawURL), "/"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpClient{
|
||||
baseURL: parsedURL,
|
||||
apiKey: apiKey,
|
||||
httpClient: http.DefaultClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *httpClient) do(ctx context.Context, method, requestPath string, query url.Values, body any, dest any) error {
|
||||
endpoint := *c.baseURL
|
||||
endpoint.Path = path.Join(endpoint.Path, requestPath)
|
||||
if len(query) > 0 {
|
||||
endpoint.RawQuery = query.Encode()
|
||||
}
|
||||
|
||||
var payload io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Api-Key", c.apiKey)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("%s %s returned %d: %s", method, endpoint.String(), resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
if dest == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(dest)
|
||||
}
|
||||
|
||||
func boolValue(value *bool, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func containsFold(haystack, needle string) bool {
|
||||
return strings.Contains(strings.ToLower(haystack), strings.ToLower(strings.TrimSpace(needle)))
|
||||
}
|
||||
|
||||
func FormatSearchResult(result SearchResult) string {
|
||||
if result.Year != 0 {
|
||||
return fmt.Sprintf("%s (%d)", result.Title, result.Year)
|
||||
}
|
||||
return result.Title
|
||||
}
|
||||
164
packages/arrtrix/pkg/arrclient/radarr.go
Normal file
164
packages/arrtrix/pkg/arrclient/radarr.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package arrclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
||||
)
|
||||
|
||||
type RadarrClient struct {
|
||||
http *httpClient
|
||||
config RadarrConfig
|
||||
}
|
||||
|
||||
type radarrMovie struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
TMDBID int64 `json:"tmdbId"`
|
||||
Overview string `json:"overview"`
|
||||
Monitored bool `json:"monitored"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func NewRadarrClient(config RadarrConfig) (*RadarrClient, error) {
|
||||
config.ApplyDefaults()
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient, err := newHTTPClient(config.URL, config.APIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RadarrClient{http: httpClient, config: config}, nil
|
||||
}
|
||||
|
||||
func (c *RadarrClient) ContentType() arr.ContentType {
|
||||
return arr.ContentTypeMovies
|
||||
}
|
||||
|
||||
func (c *RadarrClient) Search(ctx context.Context, query string) ([]SearchResult, error) {
|
||||
var response []radarrMovie
|
||||
if err := c.http.do(ctx, http.MethodGet, "/api/v3/movie/lookup", url.Values{"term": {strings.TrimSpace(query)}}, nil, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]SearchResult, 0, len(response))
|
||||
for _, movie := range response {
|
||||
if movie.TMDBID == 0 {
|
||||
continue
|
||||
}
|
||||
results = append(results, SearchResult{
|
||||
LookupID: movie.TMDBID,
|
||||
Title: movie.Title,
|
||||
Year: movie.Year,
|
||||
Overview: movie.Overview,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (c *RadarrClient) List(ctx context.Context, query string) ([]ManagedItem, error) {
|
||||
var response []radarrMovie
|
||||
if err := c.http.do(ctx, http.MethodGet, "/api/v3/movie", nil, nil, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]ManagedItem, 0, len(response))
|
||||
for _, movie := range response {
|
||||
if query != "" && !containsFold(movie.Title, query) && !containsFold(strconv.Itoa(movie.Year), query) {
|
||||
continue
|
||||
}
|
||||
items = append(items, ManagedItem{
|
||||
ID: movie.ID,
|
||||
LookupID: movie.TMDBID,
|
||||
Title: movie.Title,
|
||||
Year: movie.Year,
|
||||
Monitored: movie.Monitored,
|
||||
Path: movie.Path,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (c *RadarrClient) Add(ctx context.Context, result SearchResult) (*ManagedItem, error) {
|
||||
payload := map[string]any{
|
||||
"title": result.Title,
|
||||
"tmdbId": result.LookupID,
|
||||
"year": result.Year,
|
||||
"qualityProfileId": c.config.QualityProfileID,
|
||||
"rootFolderPath": c.config.RootFolderPath,
|
||||
"minimumAvailability": c.config.MinimumAvailability,
|
||||
"monitored": true,
|
||||
"addOptions": map[string]any{
|
||||
"searchForMovie": c.config.SearchOnAddValue(),
|
||||
},
|
||||
}
|
||||
|
||||
var response radarrMovie
|
||||
if err := c.http.do(ctx, http.MethodPost, "/api/v3/movie", nil, payload, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := ManagedItem{
|
||||
ID: response.ID,
|
||||
LookupID: response.TMDBID,
|
||||
Title: response.Title,
|
||||
Year: response.Year,
|
||||
Monitored: response.Monitored,
|
||||
Path: response.Path,
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (c *RadarrClient) SetMonitored(ctx context.Context, id int64, monitored bool) (*ManagedItem, error) {
|
||||
var movie map[string]any
|
||||
endpoint := "/api/v3/movie/" + strconv.FormatInt(id, 10)
|
||||
if err := c.http.do(ctx, http.MethodGet, endpoint, nil, nil, &movie); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
movie["monitored"] = monitored
|
||||
|
||||
var response radarrMovie
|
||||
if err := c.http.do(ctx, http.MethodPut, endpoint, nil, movie, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := ManagedItem{
|
||||
ID: response.ID,
|
||||
LookupID: response.TMDBID,
|
||||
Title: response.Title,
|
||||
Year: response.Year,
|
||||
Monitored: response.Monitored,
|
||||
Path: response.Path,
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (c *RadarrClient) Delete(ctx context.Context, id int64) error {
|
||||
return c.http.do(ctx, http.MethodDelete, "/api/v3/movie/"+strconv.FormatInt(id, 10), url.Values{
|
||||
"deleteFiles": {"false"},
|
||||
"addImportExclusion": {"false"},
|
||||
}, nil, nil)
|
||||
}
|
||||
|
||||
func PickSingleResult(results []SearchResult, query string) (SearchResult, error) {
|
||||
switch len(results) {
|
||||
case 0:
|
||||
return SearchResult{}, fmt.Errorf("no matching result found for %q", query)
|
||||
case 1:
|
||||
return results[0], nil
|
||||
default:
|
||||
normalized := strings.TrimSpace(strings.ToLower(query))
|
||||
for _, result := range results {
|
||||
title := strings.ToLower(FormatSearchResult(result))
|
||||
if title == normalized {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
return SearchResult{}, fmt.Errorf("multiple results matched %q", query)
|
||||
}
|
||||
}
|
||||
149
packages/arrtrix/pkg/arrclient/sonarr.go
Normal file
149
packages/arrtrix/pkg/arrclient/sonarr.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package arrclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
||||
)
|
||||
|
||||
type SonarrClient struct {
|
||||
http *httpClient
|
||||
config SonarrConfig
|
||||
}
|
||||
|
||||
type sonarrSeries struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
TVDBID int64 `json:"tvdbId"`
|
||||
Overview string `json:"overview"`
|
||||
Monitored bool `json:"monitored"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func NewSonarrClient(config SonarrConfig) (*SonarrClient, error) {
|
||||
config.ApplyDefaults()
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient, err := newHTTPClient(config.URL, config.APIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SonarrClient{http: httpClient, config: config}, nil
|
||||
}
|
||||
|
||||
func (c *SonarrClient) ContentType() arr.ContentType {
|
||||
return arr.ContentTypeSeries
|
||||
}
|
||||
|
||||
func (c *SonarrClient) Search(ctx context.Context, query string) ([]SearchResult, error) {
|
||||
var response []sonarrSeries
|
||||
if err := c.http.do(ctx, http.MethodGet, "/api/v3/series/lookup", url.Values{"term": {strings.TrimSpace(query)}}, nil, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]SearchResult, 0, len(response))
|
||||
for _, series := range response {
|
||||
if series.TVDBID == 0 {
|
||||
continue
|
||||
}
|
||||
results = append(results, SearchResult{
|
||||
LookupID: series.TVDBID,
|
||||
Title: series.Title,
|
||||
Year: series.Year,
|
||||
Overview: series.Overview,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (c *SonarrClient) List(ctx context.Context, query string) ([]ManagedItem, error) {
|
||||
var response []sonarrSeries
|
||||
if err := c.http.do(ctx, http.MethodGet, "/api/v3/series", nil, nil, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]ManagedItem, 0, len(response))
|
||||
for _, series := range response {
|
||||
if query != "" && !containsFold(series.Title, query) && !containsFold(strconv.Itoa(series.Year), query) {
|
||||
continue
|
||||
}
|
||||
items = append(items, ManagedItem{
|
||||
ID: series.ID,
|
||||
LookupID: series.TVDBID,
|
||||
Title: series.Title,
|
||||
Year: series.Year,
|
||||
Monitored: series.Monitored,
|
||||
Path: series.Path,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (c *SonarrClient) Add(ctx context.Context, result SearchResult) (*ManagedItem, error) {
|
||||
payload := map[string]any{
|
||||
"title": result.Title,
|
||||
"tvdbId": result.LookupID,
|
||||
"qualityProfileId": c.config.QualityProfileID,
|
||||
"languageProfileId": c.config.LanguageProfileID,
|
||||
"rootFolderPath": c.config.RootFolderPath,
|
||||
"seasonFolder": c.config.SeasonFolderValue(),
|
||||
"monitored": true,
|
||||
"seriesType": c.config.SeriesType,
|
||||
"addOptions": map[string]any{
|
||||
"searchForMissingEpisodes": c.config.SearchOnAddValue(),
|
||||
},
|
||||
}
|
||||
if result.Year != 0 {
|
||||
payload["year"] = result.Year
|
||||
}
|
||||
|
||||
var response sonarrSeries
|
||||
if err := c.http.do(ctx, http.MethodPost, "/api/v3/series", nil, payload, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := ManagedItem{
|
||||
ID: response.ID,
|
||||
LookupID: response.TVDBID,
|
||||
Title: response.Title,
|
||||
Year: response.Year,
|
||||
Monitored: response.Monitored,
|
||||
Path: response.Path,
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (c *SonarrClient) SetMonitored(ctx context.Context, id int64, monitored bool) (*ManagedItem, error) {
|
||||
var series map[string]any
|
||||
endpoint := "/api/v3/series/" + strconv.FormatInt(id, 10)
|
||||
if err := c.http.do(ctx, http.MethodGet, endpoint, nil, nil, &series); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
series["monitored"] = monitored
|
||||
|
||||
var response sonarrSeries
|
||||
if err := c.http.do(ctx, http.MethodPut, endpoint, nil, series, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := ManagedItem{
|
||||
ID: response.ID,
|
||||
LookupID: response.TVDBID,
|
||||
Title: response.Title,
|
||||
Year: response.Year,
|
||||
Monitored: response.Monitored,
|
||||
Path: response.Path,
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (c *SonarrClient) Delete(ctx context.Context, id int64) error {
|
||||
return c.http.do(ctx, http.MethodDelete, "/api/v3/series/"+strconv.FormatInt(id, 10), url.Values{
|
||||
"deleteFiles": {"false"},
|
||||
"addImportListExclusion": {"false"},
|
||||
}, nil, nil)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue