From 19e9396f72fbd1c07d872e733d36925a36d8af77 Mon Sep 17 00:00:00 2001 From: drondeseries Date: Sun, 5 Apr 2026 15:36:45 -0400 Subject: [PATCH] feat(arrs): add support for Lidarr, Readarr, and Whisparr This adds first-class support for Lidarr, Readarr, and Whisparr as ARR instances. - Added configuration and management for the new instances. - Implemented automatic webhook and download client registration for Lidarr and Readarr. - Updated the ARR client manager to support all five applications. - Added database migrations to support new instance types in managed files. - Extended the health monitoring and scan triggers to include the new applications. --- config.sample.yaml | 5 +- frontend/src/types/config.ts | 6 + internal/api/arrs_handlers.go | 69 ++- internal/arrs/clients/manager.go | 100 ++++- internal/arrs/instances/manager.go | 129 +++++- internal/arrs/model/types.go | 2 +- internal/arrs/registrar/manager.go | 414 +++++++++++++++++- internal/arrs/scanner/manager.go | 121 ++++- internal/arrs/service.go | 39 +- internal/config/manager.go | 6 + .../migrations/postgres/022_add_arr_types.sql | 5 + .../migrations/sqlite/022_add_arr_types.sql | 23 + .../importer/postprocessor/arr_notifier.go | 6 + 13 files changed, 864 insertions(+), 61 deletions(-) create mode 100644 internal/database/migrations/postgres/022_add_arr_types.sql create mode 100644 internal/database/migrations/sqlite/022_add_arr_types.sql diff --git a/config.sample.yaml b/config.sample.yaml index 7c971b30..5345bd1a 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -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) diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index 88eb12c7..63086e7a 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -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; @@ -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; diff --git a/internal/api/arrs_handlers.go b/internal/api/arrs_handlers.go index 7c60fbd5..5f914372 100644 --- a/internal/api/arrs_handlers.go +++ b/internal/api/arrs_handlers.go @@ -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 @@ -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 @@ -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) @@ -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 != "" { @@ -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"` } @@ -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 @@ -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 @@ -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 @@ -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": @@ -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 } @@ -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 @@ -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 diff --git a/internal/arrs/clients/manager.go b/internal/arrs/clients/manager.go index eb861082..31e08da7 100644 --- a/internal/arrs/clients/manager.go +++ b/internal/arrs/clients/manager.go @@ -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), } } @@ -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 @@ -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) } diff --git a/internal/arrs/instances/manager.go b/internal/arrs/instances/manager.go index b5616598..90473155 100644 --- a/internal/arrs/instances/manager.go +++ b/internal/arrs/instances/manager.go @@ -10,9 +10,11 @@ import ( "github.com/javi11/altmount/internal/arrs/model" "github.com/javi11/altmount/internal/config" "golift.io/starr" + "golift.io/starr/lidarr" "golift.io/starr/radarr" + "golift.io/starr/readarr" "golift.io/starr/sonarr" -) + ) type Manager struct { configGetter config.ConfigGetter @@ -60,6 +62,50 @@ func (m *Manager) GetAllInstances() []*model.ConfigInstance { instances = append(instances, instance) } } + // Convert Lidarr instances + if len(cfg.Arrs.LidarrInstances) > 0 { + for _, lidarrConfig := range cfg.Arrs.LidarrInstances { + instance := &model.ConfigInstance{ + Name: lidarrConfig.Name, + Type: "lidarr", + URL: lidarrConfig.URL, + APIKey: lidarrConfig.APIKey, + Category: lidarrConfig.Category, + Enabled: lidarrConfig.Enabled != nil && *lidarrConfig.Enabled, + } + instances = append(instances, instance) + } + } + + // Convert Readarr instances + if len(cfg.Arrs.ReadarrInstances) > 0 { + for _, readarrConfig := range cfg.Arrs.ReadarrInstances { + instance := &model.ConfigInstance{ + Name: readarrConfig.Name, + Type: "readarr", + URL: readarrConfig.URL, + APIKey: readarrConfig.APIKey, + Category: readarrConfig.Category, + Enabled: readarrConfig.Enabled != nil && *readarrConfig.Enabled, + } + instances = append(instances, instance) + } + } + + // Convert Whisparr instances + if len(cfg.Arrs.WhisparrInstances) > 0 { + for _, whisparrConfig := range cfg.Arrs.WhisparrInstances { + instance := &model.ConfigInstance{ + Name: whisparrConfig.Name, + Type: "whisparr", + URL: whisparrConfig.URL, + APIKey: whisparrConfig.APIKey, + Category: whisparrConfig.Category, + Enabled: whisparrConfig.Enabled != nil && *whisparrConfig.Enabled, + } + instances = append(instances, instance) + } + } return instances } @@ -113,6 +159,12 @@ func (m *Manager) RegisterInstance(ctx context.Context, arrURL, apiKey string) ( category = "movies" case "sonarr": category = "tv" + case "lidarr": + category = "music" + case "readarr": + category = "books" + case "whisparr": + category = "movies" default: return false, fmt.Errorf("unsupported ARR type: %s", arrType) } @@ -154,6 +206,12 @@ func (m *Manager) RegisterInstance(ctx context.Context, arrURL, apiKey string) ( newConfig.Arrs.RadarrInstances = append(newConfig.Arrs.RadarrInstances, newInstance) case "sonarr": newConfig.Arrs.SonarrInstances = append(newConfig.Arrs.SonarrInstances, newInstance) + case "lidarr": + newConfig.Arrs.LidarrInstances = append(newConfig.Arrs.LidarrInstances, newInstance) + case "readarr": + newConfig.Arrs.ReadarrInstances = append(newConfig.Arrs.ReadarrInstances, newInstance) + case "whisparr": + newConfig.Arrs.WhisparrInstances = append(newConfig.Arrs.WhisparrInstances, newInstance) } // Create category for this ARR type @@ -192,6 +250,12 @@ func (m *Manager) detectARRType(ctx context.Context, arrURL, apiKey string) (str case "Sonarr": slog.DebugContext(ctx, "Detected Sonarr instance", "url", arrURL) return "sonarr", nil + case "Lidarr": + return "lidarr", nil + case "Readarr": + return "readarr", nil + case "Whisparr": + return "whisparr", nil default: slog.DebugContext(ctx, "Unknown AppName from Radarr client", "app_name", radarrStatus.AppName, "url", arrURL) } @@ -208,12 +272,56 @@ func (m *Manager) detectARRType(ctx context.Context, arrURL, apiKey string) (str case "Sonarr": slog.DebugContext(ctx, "Detected Sonarr instance", "url", arrURL) return "sonarr", nil + case "Lidarr": + return "lidarr", nil + case "Readarr": + return "readarr", nil + case "Whisparr": + return "whisparr", nil default: slog.DebugContext(ctx, "Unknown AppName from Sonarr client", "app_name", sonarrStatus.AppName, "url", arrURL) } } + // Try Lidarr + lidarrClient := lidarr.New(&starr.Config{URL: arrURL, APIKey: apiKey}) + lidarrStatus, err := lidarrClient.GetSystemStatusContext(ctx) + if err == nil { + switch lidarrStatus.AppName { + case "Lidarr": + slog.DebugContext(ctx, "Detected Lidarr instance", "url", arrURL) + return "lidarr", nil + default: + slog.DebugContext(ctx, "Unknown AppName from Lidarr client", "app_name", lidarrStatus.AppName, "url", arrURL) + } + } + + // Try Readarr + readarrClient := readarr.New(&starr.Config{URL: arrURL, APIKey: apiKey}) + readarrStatus, err := readarrClient.GetSystemStatusContext(ctx) + if err == nil { + switch readarrStatus.AppName { + case "Readarr": + slog.DebugContext(ctx, "Detected Readarr instance", "url", arrURL) + return "readarr", nil + default: + slog.DebugContext(ctx, "Unknown AppName from Readarr client", "app_name", readarrStatus.AppName, "url", arrURL) + } + } + + // Try Whisparr (using Radarr client) + whisparrClient := radarr.New(&starr.Config{URL: arrURL, APIKey: apiKey}) + whisparrStatus, err := whisparrClient.GetSystemStatusContext(ctx) + if err == nil { + switch whisparrStatus.AppName { + case "Whisparr": + slog.DebugContext(ctx, "Detected Whisparr instance", "url", arrURL) + return "whisparr", nil + default: + slog.DebugContext(ctx, "Unknown AppName from Whisparr client", "app_name", whisparrStatus.AppName, "url", arrURL) + } + } - return "", fmt.Errorf("unable to detect ARR type for URL %s - neither Radarr nor Sonarr responded successfully", arrURL) + return "", fmt.Errorf("unable to detect ARR type for URL %s - no ARR responded successfully", arrURL) } // generateInstanceName generates an instance name from a URL @@ -266,15 +374,28 @@ func (m *Manager) categoryUsedByOtherInstance(arrType, category string) bool { instances = cfg.Arrs.RadarrInstances case "sonarr": instances = cfg.Arrs.SonarrInstances + case "lidarr": + instances = cfg.Arrs.LidarrInstances + case "readarr": + instances = cfg.Arrs.ReadarrInstances + case "whisparr": + instances = cfg.Arrs.WhisparrInstances } for _, instance := range instances { instanceCat := instance.Category if instanceCat == "" { - if arrType == "radarr" { + switch arrType { + case "radarr": instanceCat = "movies" - } else { + case "sonarr": instanceCat = "tv" + case "lidarr": + instanceCat = "music" + case "readarr": + instanceCat = "books" + case "whisparr": + instanceCat = "movies" } } diff --git a/internal/arrs/model/types.go b/internal/arrs/model/types.go index f7ac9510..654ff4cb 100644 --- a/internal/arrs/model/types.go +++ b/internal/arrs/model/types.go @@ -15,7 +15,7 @@ var ( // ConfigInstance represents an arrs instance from configuration type ConfigInstance struct { Name string `json:"name"` - Type string `json:"type"` // "radarr" or "sonarr" + Type string `json:"type"` // "radarr", "sonarr", "lidarr", "readarr", or "whisparr" URL string `json:"url"` APIKey string `json:"api_key"` Category string `json:"category"` diff --git a/internal/arrs/registrar/manager.go b/internal/arrs/registrar/manager.go index 6957896c..fe712141 100644 --- a/internal/arrs/registrar/manager.go +++ b/internal/arrs/registrar/manager.go @@ -10,6 +10,8 @@ import ( "golift.io/starr" "golift.io/starr/radarr" "golift.io/starr/sonarr" + "golift.io/starr/lidarr" + "golift.io/starr/readarr" ) type Manager struct { @@ -40,7 +42,7 @@ func (m *Manager) EnsureWebhookRegistration(ctx context.Context, altmountURL str slog.DebugContext(ctx, "Checking webhook for instance", "instance", instance.Name, "type", instance.Type) switch instance.Type { - case "radarr": + case "radarr", "whisparr": client, err := m.clients.GetOrCreateRadarrClient(instance.Name, instance.URL, instance.APIKey) if err != nil { slog.ErrorContext(ctx, "Failed to create Radarr client for webhook check", "instance", instance.Name, "error", err) @@ -199,6 +201,162 @@ func (m *Manager) EnsureWebhookRegistration(ctx context.Context, altmountURL str slog.InfoContext(ctx, "Added AltMount webhook to Sonarr", "instance", instance.Name) } } + case "lidarr": + // ... + client, err := m.clients.GetOrCreateLidarrClient(instance.Name, instance.URL, instance.APIKey) + if err != nil { + slog.ErrorContext(ctx, "Failed to create Lidarr client for webhook check", "instance", instance.Name, "error", err) + continue + } + + notifications, err := client.GetNotificationsContext(ctx) + if err != nil { + slog.ErrorContext(ctx, "Failed to get Lidarr notifications", "instance", instance.Name, "error", err) + continue + } + + var existing *lidarr.NotificationOutput + for _, n := range notifications { + if n.Name == webhookName { + existing = n + break + } + } + + if existing != nil { + // Check if update is needed + currentURL := "" + for _, f := range existing.Fields { + if f.Name == "url" { + currentURL = f.Value.(string) + break + } + } + + if currentURL != webhookURL { + slog.InfoContext(ctx, "Updating Lidarr webhook API key/URL", "instance", instance.Name) + notif := &lidarr.NotificationInput{ + ID: existing.ID, + Name: webhookName, + Implementation: "Webhook", + ConfigContract: "WebhookSettings", + OnGrab: false, + OnReleaseImport: true, + OnUpgrade: true, + OnRename: true, + Fields: []*starr.FieldInput{ + {Name: "url", Value: webhookURL}, + {Name: "method", Value: "1"}, // 1 = POST + }, + } + _, err := client.UpdateNotificationContext(ctx, notif) + if err != nil { + slog.ErrorContext(ctx, "Failed to update Lidarr webhook", "instance", instance.Name, "error", err) + } + } + } else { + notif := &lidarr.NotificationInput{ + Name: webhookName, + Implementation: "Webhook", + ConfigContract: "WebhookSettings", + OnGrab: false, + OnReleaseImport: true, + OnUpgrade: true, + OnRename: true, + Fields: []*starr.FieldInput{ + {Name: "url", Value: webhookURL}, + {Name: "method", Value: "1"}, // 1 = POST + }, + } + _, err := client.AddNotificationContext(ctx, notif) + if err != nil { + slog.ErrorContext(ctx, "Failed to add Lidarr webhook", "instance", instance.Name, "error", err) + } else { + slog.InfoContext(ctx, "Added AltMount webhook to Lidarr", "instance", instance.Name) + } + } + + case "readarr": + client, err := m.clients.GetOrCreateReadarrClient(instance.Name, instance.URL, instance.APIKey) + if err != nil { + slog.ErrorContext(ctx, "Failed to create Readarr client for webhook check", "instance", instance.Name, "error", err) + continue + } + + notifications, err := client.GetNotificationsContext(ctx) + if err != nil { + slog.ErrorContext(ctx, "Failed to get Readarr notifications", "instance", instance.Name, "error", err) + continue + } + + var existing *readarr.NotificationOutput + for _, n := range notifications { + if n.Name == webhookName { + existing = n + break + } + } + + if existing != nil { + // Check if update is needed + currentURL := "" + for _, f := range existing.Fields { + if f.Name == "url" { + currentURL = f.Value.(string) + break + } + } + + if currentURL != webhookURL { + slog.InfoContext(ctx, "Updating Readarr webhook API key/URL", "instance", instance.Name) + notif := &readarr.NotificationInput{ + ID: existing.ID, + Name: webhookName, + Implementation: "Webhook", + ConfigContract: "WebhookSettings", + OnGrab: false, + OnReleaseImport: true, + OnUpgrade: true, + OnRename: true, + OnAuthorDelete: true, + OnBookDelete: true, + OnBookFileDelete: true, + OnBookFileDeleteForUpgrade: true, + Fields: []*starr.FieldInput{ + {Name: "url", Value: webhookURL}, + {Name: "method", Value: "1"}, // 1 = POST + }, + } + _, err := client.UpdateNotificationContext(ctx, notif) + if err != nil { + slog.ErrorContext(ctx, "Failed to update Readarr webhook", "instance", instance.Name, "error", err) + } + } + } else { + notif := &readarr.NotificationInput{ + Name: webhookName, + Implementation: "Webhook", + ConfigContract: "WebhookSettings", + OnGrab: false, + OnReleaseImport: true, + OnUpgrade: true, + OnRename: true, + OnAuthorDelete: true, + OnBookDelete: true, + OnBookFileDelete: true, + OnBookFileDeleteForUpgrade: true, + Fields: []*starr.FieldInput{ + {Name: "url", Value: webhookURL}, + {Name: "method", Value: "1"}, // 1 = POST + }, + } + _, err := client.AddNotificationContext(ctx, notif) + if err != nil { + slog.ErrorContext(ctx, "Failed to add Readarr webhook", "instance", instance.Name, "error", err) + } else { + slog.InfoContext(ctx, "Added AltMount webhook to Readarr", "instance", instance.Name) + } + } } } @@ -223,7 +381,7 @@ func (m *Manager) EnsureDownloadClientRegistration(ctx context.Context, altmount slog.DebugContext(ctx, "Checking download client for instance", "instance", instance.Name, "type", instance.Type) switch instance.Type { - case "radarr": + case "radarr", "whisparr": client, err := m.clients.GetOrCreateRadarrClient(instance.Name, instance.URL, instance.APIKey) if err != nil { slog.ErrorContext(ctx, "Failed to create Radarr client for download client check", "instance", instance.Name, "error", err) @@ -373,7 +531,7 @@ func (m *Manager) EnsureDownloadClientRegistration(ctx context.Context, altmount {Name: "port", Value: altmountPort}, {Name: "urlBase", Value: urlBase}, {Name: "apiKey", Value: apiKey}, - {Name: "tvCategory", Value: category}, + {Name: "bookCategory", Value: category}, {Name: "useSsl", Value: false}, }, } @@ -401,7 +559,7 @@ func (m *Manager) EnsureDownloadClientRegistration(ctx context.Context, altmount {Name: "port", Value: altmountPort}, {Name: "urlBase", Value: urlBase}, {Name: "apiKey", Value: apiKey}, - {Name: "tvCategory", Value: category}, + {Name: "bookCategory", Value: category}, {Name: "useSsl", Value: false}, }, } @@ -412,6 +570,191 @@ func (m *Manager) EnsureDownloadClientRegistration(ctx context.Context, altmount slog.InfoContext(ctx, "Added AltMount download client to Sonarr", "instance", instance.Name) } } + case "lidarr": + client, err := m.clients.GetOrCreateLidarrClient(instance.Name, instance.URL, instance.APIKey) + if err != nil { + slog.ErrorContext(ctx, "Failed to create Lidarr client for download client check", "instance", instance.Name, "error", err) + continue + } + + clients, err := client.GetDownloadClientsContext(ctx) + if err != nil { + slog.ErrorContext(ctx, "Failed to get Lidarr download clients", "instance", instance.Name, "error", err) + continue + } + + var existing *lidarr.DownloadClientOutput + for _, c := range clients { + if c.Name == clientName { + existing = c + break + } + } + + if existing != nil { + // Update if API key or Host changed + currentKey := "" + currentHost := "" + for _, f := range existing.Fields { + if f.Name == "apiKey" { + currentKey = f.Value.(string) + } + if f.Name == "host" { + currentHost = f.Value.(string) + } + } + + if currentKey != apiKey || currentHost != altmountHost { + slog.InfoContext(ctx, "Updating Lidarr download client API key/Host", "instance", instance.Name) + category := instance.Category + if category == "" { + category = "music" + } + dc := &lidarr.DownloadClientInput{ + ID: existing.ID, + Name: clientName, + Implementation: "SABnzbd", + ConfigContract: "SABnzbdSettings", + Enable: true, + RemoveCompletedDownloads: true, + RemoveFailedDownloads: true, + Priority: 1, + Protocol: "Usenet", + Fields: []*starr.FieldInput{ + {Name: "host", Value: altmountHost}, + {Name: "port", Value: altmountPort}, + {Name: "urlBase", Value: urlBase}, + {Name: "apiKey", Value: apiKey}, + {Name: "musicCategory", Value: category}, + {Name: "useSsl", Value: false}, + }, + } + _, err := client.UpdateDownloadClientContext(ctx, dc, true) + if err != nil { + slog.ErrorContext(ctx, "Failed to update Lidarr download client", "instance", instance.Name, "error", err) + } + } + } else { + category := instance.Category + if category == "" { + category = "music" + } + dc := &lidarr.DownloadClientInput{ + Name: clientName, + Implementation: "SABnzbd", + ConfigContract: "SABnzbdSettings", + Enable: true, + RemoveCompletedDownloads: true, + RemoveFailedDownloads: true, + Priority: 1, + Protocol: "Usenet", + Fields: []*starr.FieldInput{ + {Name: "host", Value: altmountHost}, + {Name: "port", Value: altmountPort}, + {Name: "urlBase", Value: urlBase}, + {Name: "apiKey", Value: apiKey}, + {Name: "musicCategory", Value: category}, + {Name: "useSsl", Value: false}, + }, + } + _, err := client.AddDownloadClientContext(ctx, dc) + if err != nil { + slog.ErrorContext(ctx, "Failed to add Lidarr download client", "instance", instance.Name, "error", err) + } else { + slog.InfoContext(ctx, "Added AltMount download client to Lidarr", "instance", instance.Name) + } + } + + case "readarr": + client, err := m.clients.GetOrCreateReadarrClient(instance.Name, instance.URL, instance.APIKey) + if err != nil { + slog.ErrorContext(ctx, "Failed to create Readarr client for download client check", "instance", instance.Name, "error", err) + continue + } + + clients, err := client.GetDownloadClientsContext(ctx) + if err != nil { + slog.ErrorContext(ctx, "Failed to get Readarr download clients", "instance", instance.Name, "error", err) + continue + } + + var existing *readarr.DownloadClientOutput + for _, c := range clients { + if c.Name == clientName { + existing = c + break + } + } + + if existing != nil { + // Update if API key or Host changed + currentKey := "" + currentHost := "" + for _, f := range existing.Fields { + if f.Name == "apiKey" { + currentKey = f.Value.(string) + } + if f.Name == "host" { + currentHost = f.Value.(string) + } + } + + if currentKey != apiKey || currentHost != altmountHost { + slog.InfoContext(ctx, "Updating Readarr download client API key/Host", "instance", instance.Name) + category := instance.Category + if category == "" { + category = "books" + } + dc := &readarr.DownloadClientInput{ + ID: existing.ID, + Name: clientName, + Implementation: "SABnzbd", + ConfigContract: "SABnzbdSettings", + Enable: true, + Priority: 1, + Protocol: "Usenet", + Fields: []*starr.FieldInput{ + {Name: "host", Value: altmountHost}, + {Name: "port", Value: altmountPort}, + {Name: "urlBase", Value: urlBase}, + {Name: "apiKey", Value: apiKey}, + {Name: "bookCategory", Value: category}, + {Name: "useSsl", Value: false}, + }, + } + _, err := client.UpdateDownloadClientContext(ctx, dc, true) + if err != nil { + slog.ErrorContext(ctx, "Failed to update Readarr download client", "instance", instance.Name, "error", err) + } + } + } else { + category := instance.Category + if category == "" { + category = "books" + } + dc := &readarr.DownloadClientInput{ + Name: clientName, + Implementation: "SABnzbd", + ConfigContract: "SABnzbdSettings", + Enable: true, + Priority: 1, + Protocol: "Usenet", + Fields: []*starr.FieldInput{ + {Name: "host", Value: altmountHost}, + {Name: "port", Value: altmountPort}, + {Name: "urlBase", Value: urlBase}, + {Name: "apiKey", Value: apiKey}, + {Name: "bookCategory", Value: category}, + {Name: "useSsl", Value: false}, + }, + } + _, err := client.AddDownloadClientContext(ctx, dc) + if err != nil { + slog.ErrorContext(ctx, "Failed to add Readarr download client", "instance", instance.Name, "error", err) + } else { + slog.InfoContext(ctx, "Added AltMount download client to Readarr", "instance", instance.Name) + } + } } } @@ -430,7 +773,7 @@ func (m *Manager) TestDownloadClientRegistration(ctx context.Context, altmountHo var testErr error switch instance.Type { - case "radarr": + case "radarr", "whisparr": client, err := m.clients.GetOrCreateRadarrClient(instance.Name, instance.URL, instance.APIKey) if err != nil { results[instance.Name] = fmt.Sprintf("Failed to create client: %v", err) @@ -484,7 +827,66 @@ func (m *Manager) TestDownloadClientRegistration(ctx context.Context, altmountHo {Name: "port", Value: altmountPort}, {Name: "urlBase", Value: urlBase}, {Name: "apiKey", Value: apiKey}, - {Name: "tvCategory", Value: category}, + {Name: "bookCategory", Value: category}, + {Name: "useSsl", Value: false}, + }, + } + testErr = client.TestDownloadClientContext(ctx, dc) + case "lidarr": + client, err := m.clients.GetOrCreateLidarrClient(instance.Name, instance.URL, instance.APIKey) + if err != nil { + results[instance.Name] = fmt.Sprintf("Failed to create client: %v", err) + continue + } + + category := instance.Category + if category == "" { + category = "music" + } + + dc := &lidarr.DownloadClientInput{ + Name: "AltMount Test", + Implementation: "SABnzbd", + ConfigContract: "SABnzbdSettings", + Enable: true, + Priority: 1, + Protocol: "Usenet", + Fields: []*starr.FieldInput{ + {Name: "host", Value: altmountHost}, + {Name: "port", Value: altmountPort}, + {Name: "urlBase", Value: urlBase}, + {Name: "apiKey", Value: apiKey}, + {Name: "musicCategory", Value: category}, + {Name: "useSsl", Value: false}, + }, + } + testErr = client.TestDownloadClientContext(ctx, dc) + + case "readarr": + client, err := m.clients.GetOrCreateReadarrClient(instance.Name, instance.URL, instance.APIKey) + if err != nil { + results[instance.Name] = fmt.Sprintf("Failed to create client: %v", err) + continue + } + + category := instance.Category + if category == "" { + category = "books" + } + + dc := &readarr.DownloadClientInput{ + Name: "AltMount Test", + Implementation: "SABnzbd", + ConfigContract: "SABnzbdSettings", + Enable: true, + Priority: 1, + Protocol: "Usenet", + Fields: []*starr.FieldInput{ + {Name: "host", Value: altmountHost}, + {Name: "port", Value: altmountPort}, + {Name: "urlBase", Value: urlBase}, + {Name: "apiKey", Value: apiKey}, + {Name: "bookCategory", Value: category}, {Name: "useSsl", Value: false}, }, } diff --git a/internal/arrs/scanner/manager.go b/internal/arrs/scanner/manager.go index adfa36bf..f7288dca 100644 --- a/internal/arrs/scanner/manager.go +++ b/internal/arrs/scanner/manager.go @@ -16,9 +16,11 @@ import ( "github.com/javi11/altmount/internal/arrs/model" "github.com/javi11/altmount/internal/config" "golift.io/starr" + "golift.io/starr/lidarr" "golift.io/starr/radarr" + "golift.io/starr/readarr" "golift.io/starr/sonarr" -) + ) type Manager struct { configGetter config.ConfigGetter @@ -109,33 +111,63 @@ func (m *Manager) findInstanceForFilePath(ctx context.Context, filePath string, } func (m *Manager) managesFile(ctx context.Context, instanceType string, client any, filePath string) bool { - if instanceType == "radarr" { + switch instanceType { + case "radarr": rc, ok := client.(*radarr.Radarr) if !ok { return false } return m.radarrManagesFile(ctx, rc, filePath) - } - sc, ok := client.(*sonarr.Sonarr) - if !ok { + case "sonarr": + sc, ok := client.(*sonarr.Sonarr) + if !ok { + return false + } + return m.sonarrManagesFile(ctx, sc, filePath) + case "lidarr": + lc, ok := client.(*lidarr.Lidarr) + if !ok { + return false + } + return m.lidarrManagesFile(ctx, lc, filePath) + case "readarr": + rc, ok := client.(*readarr.Readarr) + if !ok { + return false + } + return m.readarrManagesFile(ctx, rc, filePath) + case "whisparr": + wc, ok := client.(*radarr.Radarr) + if !ok { + return false + } + return m.radarrManagesFile(ctx, wc, filePath) + default: return false } - return m.sonarrManagesFile(ctx, sc, filePath) } func (m *Manager) hasFile(ctx context.Context, instanceType string, client any, instanceName, relativePath string) bool { - if instanceType == "radarr" { + switch instanceType { + case "radarr": rc, ok := client.(*radarr.Radarr) if !ok { return false } return m.radarrHasFile(ctx, rc, instanceName, relativePath) - } - sc, ok := client.(*sonarr.Sonarr) - if !ok { + case "sonarr": + sc, ok := client.(*sonarr.Sonarr) + if !ok { + return false + } + return m.sonarrHasFile(ctx, sc, instanceName, relativePath) + case "lidarr", "readarr", "whisparr": + // For now, these don't have a slow path search implementation + // They rely on the Root Folder (Strategy 1) or Category (Strategy 2) + return false + default: return false } - return m.sonarrHasFile(ctx, sc, instanceName, relativePath) } // radarrManagesFile checks if Radarr manages the given file path using root folders (checkrr approach) @@ -189,6 +221,38 @@ func (m *Manager) sonarrManagesFile(ctx context.Context, client *sonarr.Sonarr, return false } +// lidarrManagesFile checks if Lidarr manages the given file path using root folders +func (m *Manager) lidarrManagesFile(ctx context.Context, client *lidarr.Lidarr, filePath string) bool { + slog.DebugContext(ctx, "Checking Lidarr root folders for file ownership", "file_path", filePath) + rootFolders, err := client.GetRootFoldersContext(ctx) + if err != nil { + slog.DebugContext(ctx, "Failed to get root folders from Lidarr", "error", err) + return false + } + for _, folder := range rootFolders { + if strings.HasPrefix(filePath, folder.Path) { + return true + } + } + return false +} + +// readarrManagesFile checks if Readarr manages the given file path using root folders +func (m *Manager) readarrManagesFile(ctx context.Context, client *readarr.Readarr, filePath string) bool { + slog.DebugContext(ctx, "Checking Readarr root folders for file ownership", "file_path", filePath) + rootFolders, err := client.GetRootFoldersContext(ctx) + if err != nil { + slog.DebugContext(ctx, "Failed to get root folders from Readarr", "error", err) + return false + } + for _, folder := range rootFolders { + if strings.HasPrefix(filePath, folder.Path) { + return true + } + } + return false +} + // radarrHasFile checks if any movie in the instance contains the given relative path func (m *Manager) radarrHasFile(ctx context.Context, client *radarr.Radarr, instanceName, relativePath string) bool { movies, err := m.data.GetMovies(ctx, client, instanceName) @@ -270,6 +334,11 @@ func (m *Manager) TriggerFileRescan(ctx context.Context, pathForRescan string, r } return nil, m.triggerSonarrRescanByPath(ctx, client, pathForRescan, relativePath, instanceName) + case "lidarr", "readarr", "whisparr": + // For now, we only support RefreshMonitoredDownloads for these + m.TriggerScanForFile(ctx, pathForRescan) + return nil, nil + default: return nil, fmt.Errorf("unsupported instance type: %s", instanceType) } @@ -336,6 +405,21 @@ func (m *Manager) TriggerScanForFile(ctx context.Context, filePath string) error } else { slog.InfoContext(bgCtx, "Triggered RefreshMonitoredDownloads", "instance", instance.Name) } + case "lidarr": + client, err := m.clients.GetOrCreateLidarrClient(instance.Name, instance.URL, instance.APIKey) + if err == nil { + _, _ = client.SendCommandContext(bgCtx, &lidarr.CommandRequest{Name: "RefreshMonitoredDownloads"}) + } + case "readarr": + client, err := m.clients.GetOrCreateReadarrClient(instance.Name, instance.URL, instance.APIKey) + if err == nil { + _, _ = client.SendCommandContext(bgCtx, &readarr.CommandRequest{Name: "RefreshMonitoredDownloads"}) + } + case "whisparr": + client, err := m.clients.GetOrCreateWhisparrClient(instance.Name, instance.URL, instance.APIKey) + if err == nil { + _, _ = client.SendCommandContext(bgCtx, &radarr.CommandRequest{Name: "RefreshMonitoredDownloads"}) + } } }() @@ -385,6 +469,21 @@ func (m *Manager) TriggerDownloadScan(ctx context.Context, instanceType string) } else { slog.InfoContext(bgCtx, "Triggered RefreshMonitoredDownloads", "instance", inst.Name) } + case "lidarr": + client, err := m.clients.GetOrCreateLidarrClient(inst.Name, inst.URL, inst.APIKey) + if err == nil { + _, _ = client.SendCommandContext(bgCtx, &lidarr.CommandRequest{Name: "RefreshMonitoredDownloads"}) + } + case "readarr": + client, err := m.clients.GetOrCreateReadarrClient(inst.Name, inst.URL, inst.APIKey) + if err == nil { + _, _ = client.SendCommandContext(bgCtx, &readarr.CommandRequest{Name: "RefreshMonitoredDownloads"}) + } + case "whisparr": + client, err := m.clients.GetOrCreateWhisparrClient(inst.Name, inst.URL, inst.APIKey) + if err == nil { + _, _ = client.SendCommandContext(bgCtx, &radarr.CommandRequest{Name: "RefreshMonitoredDownloads"}) + } } return nil, nil }) diff --git a/internal/arrs/service.go b/internal/arrs/service.go index f2196f93..a02c38e1 100644 --- a/internal/arrs/service.go +++ b/internal/arrs/service.go @@ -28,7 +28,7 @@ var ( ErrInstanceNotFound = model.ErrInstanceNotFound ) -// Service manages Radarr and Sonarr instances for health monitoring and file repair +// Service manages Radarr, Sonarr, Lidarr, Readarr, and Whisparr instances for health monitoring and file repair type Service struct { configGetter config.ConfigGetter configManager model.ConfigManager @@ -222,28 +222,31 @@ func (s *Service) GetHealth(ctx context.Context) (map[string]any, error) { switch instance.Type { case "radarr": client, err := s.clients.GetOrCreateRadarrClient(instance.Name, instance.URL, instance.APIKey) - if err != nil { - slog.ErrorContext(ctx, "Failed to create Radarr client for health check", "instance", instance.Name, "error", err) - continue + if err == nil { + _ = client.GetInto(ctx, starr.Request{URI: "/health"}, &health) } - err = client.GetInto(ctx, starr.Request{URI: "/health"}, &health) - if err != nil { - slog.ErrorContext(ctx, "Failed to get Radarr health", "instance", instance.Name, "error", err) - continue - } - results[instance.Name] = health - case "sonarr": client, err := s.clients.GetOrCreateSonarrClient(instance.Name, instance.URL, instance.APIKey) - if err != nil { - slog.ErrorContext(ctx, "Failed to create Sonarr client for health check", "instance", instance.Name, "error", err) - continue + if err == nil { + _ = client.GetInto(ctx, starr.Request{URI: "/health"}, &health) } - err = client.GetInto(ctx, starr.Request{URI: "/health"}, &health) - if err != nil { - slog.ErrorContext(ctx, "Failed to get Sonarr health", "instance", instance.Name, "error", err) - continue + case "lidarr": + client, err := s.clients.GetOrCreateLidarrClient(instance.Name, instance.URL, instance.APIKey) + if err == nil { + _ = client.GetInto(ctx, starr.Request{URI: "/health"}, &health) } + case "readarr": + client, err := s.clients.GetOrCreateReadarrClient(instance.Name, instance.URL, instance.APIKey) + if err == nil { + _ = client.GetInto(ctx, starr.Request{URI: "/health"}, &health) + } + case "whisparr": + client, err := s.clients.GetOrCreateWhisparrClient(instance.Name, instance.URL, instance.APIKey) + if err == nil { + _ = client.GetInto(ctx, starr.Request{URI: "/health"}, &health) + } + } + if health != nil { results[instance.Name] = health } } diff --git a/internal/config/manager.go b/internal/config/manager.go index 5875a1dd..1bda0961 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -373,6 +373,9 @@ type ArrsConfig struct { WebhookBaseURL string `yaml:"webhook_base_url" mapstructure:"webhook_base_url" json:"webhook_base_url,omitempty"` RadarrInstances []ArrsInstanceConfig `yaml:"radarr_instances" mapstructure:"radarr_instances" json:"radarr_instances"` SonarrInstances []ArrsInstanceConfig `yaml:"sonarr_instances" mapstructure:"sonarr_instances" json:"sonarr_instances"` + LidarrInstances []ArrsInstanceConfig `yaml:"lidarr_instances" mapstructure:"lidarr_instances" json:"lidarr_instances"` + ReadarrInstances []ArrsInstanceConfig `yaml:"readarr_instances" mapstructure:"readarr_instances" json:"readarr_instances"` + WhisparrInstances []ArrsInstanceConfig `yaml:"whisparr_instances" mapstructure:"whisparr_instances" json:"whisparr_instances"` QueueCleanupEnabled *bool `yaml:"queue_cleanup_enabled" mapstructure:"queue_cleanup_enabled" json:"queue_cleanup_enabled,omitempty"` QueueCleanupIntervalSeconds int `yaml:"queue_cleanup_interval_seconds" mapstructure:"queue_cleanup_interval_seconds" json:"queue_cleanup_interval_seconds,omitempty"` CleanupAutomaticImportFailure *bool `yaml:"cleanup_automatic_import_failure" mapstructure:"cleanup_automatic_import_failure" json:"cleanup_automatic_import_failure,omitempty"` @@ -1381,6 +1384,9 @@ func DefaultConfig(configDir ...string) *Config { WebhookBaseURL: "", RadarrInstances: []ArrsInstanceConfig{}, SonarrInstances: []ArrsInstanceConfig{}, + LidarrInstances: []ArrsInstanceConfig{}, + ReadarrInstances: []ArrsInstanceConfig{}, + WhisparrInstances: []ArrsInstanceConfig{}, CleanupAutomaticImportFailure: &cleanupAutomaticImportFailure, QueueCleanupGracePeriodMinutes: 10, // Default to 10 minutes QueueCleanupAllowlist: []IgnoredMessage{ diff --git a/internal/database/migrations/postgres/022_add_arr_types.sql b/internal/database/migrations/postgres/022_add_arr_types.sql new file mode 100644 index 00000000..ab14673f --- /dev/null +++ b/internal/database/migrations/postgres/022_add_arr_types.sql @@ -0,0 +1,5 @@ +-- Migration 022: Update media_files instance_type check constraint for Postgres +-- Postgres allows dropping and adding constraints + +ALTER TABLE media_files DROP CONSTRAINT IF EXISTS media_files_instance_type_check; +ALTER TABLE media_files ADD CONSTRAINT media_files_instance_type_check CHECK (instance_type IN ('radarr', 'sonarr', 'lidarr', 'readarr', 'whisparr')); diff --git a/internal/database/migrations/sqlite/022_add_arr_types.sql b/internal/database/migrations/sqlite/022_add_arr_types.sql new file mode 100644 index 00000000..a485d927 --- /dev/null +++ b/internal/database/migrations/sqlite/022_add_arr_types.sql @@ -0,0 +1,23 @@ +-- Migration 022: Update media_files instance_type check constraint +-- SQLite does not support ALTER TABLE for constraints, so we have to recreate the table + +CREATE TABLE media_files_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_name TEXT NOT NULL, + instance_type TEXT NOT NULL CHECK(instance_type IN ('radarr', 'sonarr', 'lidarr', 'readarr', 'whisparr')), + external_id INTEGER NOT NULL, + file_path TEXT NOT NULL, + file_size INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO media_files_new SELECT * FROM media_files; + +DROP TABLE media_files; + +ALTER TABLE media_files_new RENAME TO media_files; + +CREATE INDEX idx_media_files_file_path ON media_files(file_path); +CREATE INDEX idx_media_files_instance ON media_files(instance_name, instance_type); +CREATE INDEX idx_media_files_external ON media_files(instance_name, instance_type, external_id); diff --git a/internal/importer/postprocessor/arr_notifier.go b/internal/importer/postprocessor/arr_notifier.go index ccedfdb3..173a3885 100644 --- a/internal/importer/postprocessor/arr_notifier.go +++ b/internal/importer/postprocessor/arr_notifier.go @@ -124,6 +124,12 @@ func (c *Coordinator) broadcastToARRType(ctx context.Context, arrsService *arrs. arrType = "sonarr" } else if category == "movies" || strings.Contains(category, "movie") || category == "radarr" { arrType = "radarr" + } else if category == "music" || strings.Contains(category, "music") || category == "lidarr" { + arrType = "lidarr" + } else if category == "books" || strings.Contains(category, "book") || category == "readarr" { + arrType = "readarr" + } else if category == "adult" || category == "whisparr" { + arrType = "whisparr" } }