Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion config.sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,15 @@ sabnzbd:
fallback_host: '' # External SABnzbd URL (e.g., "http://localhost:8080")
fallback_api_key: '' # External SABnzbd API key

# Radarr/Sonarr arrs configuration
# Radarr/Sonarr/Lidarr/Readarr/Whisparr arrs configuration
arrs:
enabled: false # Enable arrs service
max_workers: 5 # Number of concurrent workers (default: 5)
radarr_instances: [] # Radarr instances (configured via UI)
sonarr_instances: [] # Sonarr instances (configured via UI)
lidarr_instances: [] # Lidarr instances (configured via UI)
readarr_instances: [] # Readarr instances (configured via UI)
whisparr_instances: [] # Whisparr instances (configured via UI)
# Queue Cleanup Configuration
queue_cleanup_enabled: true # Enable automatic queue cleanup for failed imports (default: true)
queue_cleanup_interval_seconds: 10 # Interval in seconds to check for failed imports (default: 10)
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,9 @@ export interface ArrsConfig {
webhook_base_url?: string;
radarr_instances: ArrsInstanceConfig[];
sonarr_instances: ArrsInstanceConfig[];
lidarr_instances: ArrsInstanceConfig[];
readarr_instances: ArrsInstanceConfig[];
whisparr_instances: ArrsInstanceConfig[];
queue_cleanup_enabled?: boolean;
queue_cleanup_interval_seconds?: number;
queue_cleanup_grace_period_minutes?: number;
Expand Down Expand Up @@ -729,6 +732,9 @@ export interface ArrsFormData {
webhook_base_url?: string;
radarr_instances: ArrsInstanceConfig[];
sonarr_instances: ArrsInstanceConfig[];
lidarr_instances: ArrsInstanceConfig[];
readarr_instances: ArrsInstanceConfig[];
whisparr_instances: ArrsInstanceConfig[];
queue_cleanup_enabled?: boolean;
queue_cleanup_interval_seconds?: number;
queue_cleanup_grace_period_minutes?: number;
Expand Down
69 changes: 57 additions & 12 deletions internal/api/arrs_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ type ArrsInstanceRequest struct {

// ArrsWebhookRequest represents a webhook payload from Radarr/Sonarr
type ArrsWebhookRequest struct {
Artist struct {
Path string `json:"path"`
} `json:"artist"`
Album struct {
Title string `json:"title"`
} `json:"album"`
Author struct {
Path string `json:"path"`
} `json:"author"`
Book struct {
Title string `json:"title"`
} `json:"book"`

EventType string `json:"eventType"`
FilePath string `json:"filePath,omitempty"`
// For upgrades/renames, the file path might be in other fields or need to be inferred
Expand Down Expand Up @@ -69,10 +82,10 @@ func (df *ArrsDeletedFiles) UnmarshalJSON(data []byte) error {
return nil
}

// handleArrsWebhook handles webhooks from Radarr/Sonarr
// handleArrsWebhook handles webhooks from Radarr/Sonarr/Lidarr/Readarr/Whisparr
//
// @Summary ARR webhook receiver
// @Description Receives file-import webhook events from Sonarr/Radarr and triggers health checks. Authenticated via API key query parameter.
// @Description Receives file-import webhook events from ARR instances and triggers health checks. Authenticated via API key query parameter.
// @Tags ARRs
// @Accept json
// @Produce json
Expand Down Expand Up @@ -131,7 +144,7 @@ func (s *Server) handleArrsWebhook(c *fiber.Ctx) error {
case "Test":
slog.InfoContext(c.Context(), "Received ARR test webhook")
return c.Status(200).JSON(fiber.Map{"success": true, "message": "Test successful"})
case "Download": // OnImport
case "Download", "AlbumImport", "BookImport": // OnImport
isScanEvent = true
if req.EpisodeFile.Path != "" {
pathsToScan = append(pathsToScan, req.EpisodeFile.Path)
Expand Down Expand Up @@ -173,9 +186,13 @@ func (s *Server) handleArrsWebhook(c *fiber.Ctx) error {
filesToDelete = append(filesToDelete, deleted.Path)
}
}
case "MovieDelete":
case "MovieDelete", "ArtistDelete", "AuthorDelete":
if req.Movie.FolderPath != "" {
dirsToDelete = append(dirsToDelete, req.Movie.FolderPath)
} else if req.Artist.Path != "" {
dirsToDelete = append(dirsToDelete, req.Artist.Path)
} else if req.Author.Path != "" {
dirsToDelete = append(dirsToDelete, req.Author.Path)
}
case "SeriesDelete":
if req.Series.Path != "" {
Expand Down Expand Up @@ -464,6 +481,12 @@ type ArrsStatsResponse struct {
EnabledRadarr int `json:"enabled_radarr"`
TotalSonarr int `json:"total_sonarr"`
EnabledSonarr int `json:"enabled_sonarr"`
TotalLidarr int `json:"total_lidarr"`
EnabledLidarr int `json:"enabled_lidarr"`
TotalReadarr int `json:"total_readarr"`
EnabledReadarr int `json:"enabled_readarr"`
TotalWhisparr int `json:"total_whisparr"`
EnabledWhisparr int `json:"enabled_whisparr"`
DueForSync int `json:"due_for_sync"`
LastSync *string `json:"last_sync"`
}
Expand Down Expand Up @@ -512,7 +535,7 @@ type TestConnectionRequest struct {
// handleListArrsInstances returns all arrs instances
//
// @Summary List ARR instances
// @Description Returns all configured Sonarr/Radarr instances.
// @Description Returns all configured ARR instances.
// @Tags ARRs
// @Produce json
// @Success 200 {object} APIResponse
Expand Down Expand Up @@ -549,10 +572,10 @@ func (s *Server) handleListArrsInstances(c *fiber.Ctx) error {
// handleGetArrsInstance returns a single arrs instance by type and name
//
// @Summary Get ARR instance
// @Description Returns a specific Sonarr/Radarr instance by type and name.
// @Description Returns a specific ARR instance by type and name.
// @Tags ARRs
// @Produce json
// @Param type path string true "Instance type (sonarr or radarr)"
// @Param type path string true "Instance type (sonarr, radarr, lidarr, readarr, or whisparr)"
// @Param name path string true "Instance name"
// @Success 200 {object} APIResponse
// @Failure 404 {object} APIResponse
Expand Down Expand Up @@ -602,7 +625,7 @@ func (s *Server) handleGetArrsInstance(c *fiber.Ctx) error {
// handleTestArrsConnection tests connection to an arrs instance
//
// @Summary Test ARR connection
// @Description Tests connectivity to a Sonarr/Radarr instance with given credentials.
// @Description Tests connectivity to an ARR instance with given credentials.
// @Tags ARRs
// @Accept json
// @Produce json
Expand Down Expand Up @@ -666,6 +689,7 @@ func (s *Server) handleGetArrsStats(c *fiber.Ctx) error {

// Calculate stats from instances
var totalRadarr, enabledRadarr, totalSonarr, enabledSonarr int
var totalLidarr, enabledLidarr, totalReadarr, enabledReadarr, totalWhisparr, enabledWhisparr int
for _, instance := range instances {
switch instance.Type {
case "radarr":
Expand All @@ -678,16 +702,37 @@ func (s *Server) handleGetArrsStats(c *fiber.Ctx) error {
if instance.Enabled {
enabledSonarr++
}
case "lidarr":
totalLidarr++
if instance.Enabled {
enabledLidarr++
}
case "readarr":
totalReadarr++
if instance.Enabled {
enabledReadarr++
}
case "whisparr":
totalWhisparr++
if instance.Enabled {
enabledWhisparr++
}
}
}

response := &ArrsStatsResponse{
TotalInstances: totalRadarr + totalSonarr,
EnabledInstances: enabledRadarr + enabledSonarr,
TotalInstances: totalRadarr + totalSonarr + totalLidarr + totalReadarr + totalWhisparr,
EnabledInstances: enabledRadarr + enabledSonarr + enabledLidarr + enabledReadarr + enabledWhisparr,
TotalRadarr: totalRadarr,
EnabledRadarr: enabledRadarr,
TotalSonarr: totalSonarr,
EnabledSonarr: enabledSonarr,
TotalLidarr: totalLidarr,
EnabledLidarr: enabledLidarr,
TotalReadarr: totalReadarr,
EnabledReadarr: enabledReadarr,
TotalWhisparr: totalWhisparr,
EnabledWhisparr: enabledWhisparr,
DueForSync: 0, // Not applicable with config-first approach
}

Expand All @@ -700,7 +745,7 @@ func (s *Server) handleGetArrsStats(c *fiber.Ctx) error {
// handleGetArrsHealth returns health checks from all ARR instances
//
// @Summary Get ARR health
// @Description Returns health check results from all configured Sonarr/Radarr instances.
// @Description Returns health check results from all configured ARR instances.
// @Tags ARRs
// @Produce json
// @Success 200 {object} APIResponse
Expand Down Expand Up @@ -730,7 +775,7 @@ func (s *Server) handleGetArrsHealth(c *fiber.Ctx) error {
// handleRegisterArrsWebhooks triggers automatic registration of webhooks in ARR instances
//
// @Summary Register ARR webhooks
// @Description Automatically registers AltMount as a webhook connection in all configured Sonarr/Radarr instances.
// @Description Automatically registers AltMount as a webhook connection in all configured ARR instances.
// @Tags ARRs
// @Produce json
// @Success 200 {object} APIResponse
Expand Down
100 changes: 92 additions & 8 deletions internal/arrs/clients/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,28 @@ import (

"github.com/javi11/altmount/internal/arrs/model"
"golift.io/starr"
"golift.io/starr/lidarr"
"golift.io/starr/radarr"
"golift.io/starr/readarr"
"golift.io/starr/sonarr"
)
)

type Manager struct {
mu sync.RWMutex
radarrClients map[string]*radarr.Radarr // key: instance name
sonarrClients map[string]*sonarr.Sonarr // key: instance name
mu sync.RWMutex
radarrClients map[string]*radarr.Radarr // key: instance name
sonarrClients map[string]*sonarr.Sonarr // key: instance name
lidarrClients map[string]*lidarr.Lidarr // key: instance name
readarrClients map[string]*readarr.Readarr // key: instance name
whisparrClients map[string]*radarr.Radarr // key: instance name
}

func NewManager() *Manager {
return &Manager{
radarrClients: make(map[string]*radarr.Radarr),
sonarrClients: make(map[string]*sonarr.Sonarr),
radarrClients: make(map[string]*radarr.Radarr),
sonarrClients: make(map[string]*sonarr.Sonarr),
lidarrClients: make(map[string]*lidarr.Lidarr),
readarrClients: make(map[string]*readarr.Readarr),
whisparrClients: make(map[string]*radarr.Radarr),
}
}

Expand Down Expand Up @@ -52,12 +60,64 @@ func (m *Manager) GetOrCreateSonarrClient(instanceName, url, apiKey string) (*so
return client, nil
}

// GetOrCreateLidarrClient gets or creates a Lidarr client for an instance
func (m *Manager) GetOrCreateLidarrClient(instanceName, url, apiKey string) (*lidarr.Lidarr, error) {
m.mu.Lock()
defer m.mu.Unlock()

if client, exists := m.lidarrClients[instanceName]; exists {
return client, nil
}

client := lidarr.New(&starr.Config{URL: url, APIKey: apiKey})
m.lidarrClients[instanceName] = client
return client, nil
}

// GetOrCreateReadarrClient gets or creates a Readarr client for an instance
func (m *Manager) GetOrCreateReadarrClient(instanceName, url, apiKey string) (*readarr.Readarr, error) {
m.mu.Lock()
defer m.mu.Unlock()

if client, exists := m.readarrClients[instanceName]; exists {
return client, nil
}

client := readarr.New(&starr.Config{URL: url, APIKey: apiKey})
m.readarrClients[instanceName] = client
return client, nil
}

// GetOrCreateWhisparrClient gets or creates a Whisparr client for an instance (using Radarr client)
func (m *Manager) GetOrCreateWhisparrClient(instanceName, url, apiKey string) (*radarr.Radarr, error) {
m.mu.Lock()
defer m.mu.Unlock()

if client, exists := m.whisparrClients[instanceName]; exists {
return client, nil
}

client := radarr.New(&starr.Config{URL: url, APIKey: apiKey})
m.whisparrClients[instanceName] = client
return client, nil
}

// GetOrCreateClient is a helper to get or create the appropriate client
func (m *Manager) GetOrCreateClient(instance *model.ConfigInstance) (any, error) {
if instance.Type == "radarr" {
switch instance.Type {
case "radarr":
return m.GetOrCreateRadarrClient(instance.Name, instance.URL, instance.APIKey)
case "sonarr":
return m.GetOrCreateSonarrClient(instance.Name, instance.URL, instance.APIKey)
case "lidarr":
return m.GetOrCreateLidarrClient(instance.Name, instance.URL, instance.APIKey)
case "readarr":
return m.GetOrCreateReadarrClient(instance.Name, instance.URL, instance.APIKey)
case "whisparr":
return m.GetOrCreateWhisparrClient(instance.Name, instance.URL, instance.APIKey)
default:
return nil, fmt.Errorf("unsupported instance type: %s", instance.Type)
}
return m.GetOrCreateSonarrClient(instance.Name, instance.URL, instance.APIKey)
}

// TestConnection tests the connection to an arrs instance
Expand All @@ -79,6 +139,30 @@ func (m *Manager) TestConnection(ctx context.Context, instanceType, url, apiKey
}
return nil

case "lidarr":
client := lidarr.New(&starr.Config{URL: url, APIKey: apiKey})
_, err := client.GetSystemStatusContext(ctx)
if err != nil {
return fmt.Errorf("failed to connect to Lidarr: %w", err)
}
return nil

case "readarr":
client := readarr.New(&starr.Config{URL: url, APIKey: apiKey})
_, err := client.GetSystemStatusContext(ctx)
if err != nil {
return fmt.Errorf("failed to connect to Readarr: %w", err)
}
return nil

case "whisparr":
client := radarr.New(&starr.Config{URL: url, APIKey: apiKey})
_, err := client.GetSystemStatusContext(ctx)
if err != nil {
return fmt.Errorf("failed to connect to Whisparr: %w", err)
}
return nil

default:
return fmt.Errorf("unsupported instance type: %s", instanceType)
}
Expand Down
Loading
Loading