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:
Chris Kruining 2026-04-16 10:41:16 +02:00
parent 9b93f017b6
commit e26e25b566
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
24 changed files with 1340 additions and 82 deletions

View 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
}