package service import ( "context" "database/sql" "encoding/json" "fmt" "log/slog" "strings" "time" "github.com/TopherMayor/unified-media-manager/internal/db" "github.com/TopherMayor/unified-media-manager/internal/download" ) type DownloadClientConfig struct { ID int64 `json:"id"` Name string `json:"name"` Implementation string `json:"implementation"` URL string `json:"url"` APIKey *string `json:"-"` Category string `json:"category"` Priority int `json:"priority"` Protocol string `json:"protocol"` Settings json.RawMessage `json:"settings"` Enabled bool `json:"enabled"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type DownloadClientConfigResponse struct { ID int64 `json:"id"` Name string `json:"name"` Implementation string `json:"implementation"` URL string `json:"url"` Category string `json:"category"` Priority int `json:"priority"` Protocol string `json:"protocol"` Settings json.RawMessage `json:"settings"` Enabled bool `json:"enabled"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type DownloadClientWithInfo struct { Client download.DownloadClient Config DownloadClientConfig } type CreateDownloadClientRequest struct { Name string `json:"name"` Implementation string `json:"implementation"` URL string `json:"url"` APIKey *string `json:"api_key,omitempty"` Category string `json:"category,omitempty"` Priority *int `json:"priority,omitempty"` Protocol string `json:"protocol,omitempty"` Settings json.RawMessage `json:"settings,omitempty"` Enabled *bool `json:"enabled,omitempty"` } type UpdateDownloadClientRequest struct { Name *string `json:"name,omitempty"` Implementation *string `json:"implementation,omitempty"` URL *string `json:"url,omitempty"` APIKey *string `json:"api_key,omitempty"` Category *string `json:"category,omitempty"` Priority *int `json:"priority,omitempty"` Protocol *string `json:"protocol,omitempty"` Settings json.RawMessage `json:"settings,omitempty"` Enabled *bool `json:"enabled,omitempty"` } type DownloadClientTestResult struct { Success bool `json:"success"` Error string `json:"error,omitempty"` } const downloadClientColumns = `id, name, implementation, url, api_key, category, priority, protocol, settings, enabled, created_at, updated_at` type DownloadClientService struct { db *db.DB } func NewDownloadClientService(database *db.DB) *DownloadClientService { return &DownloadClientService{db: database} } func scanDownloadClientConfig(scanner interface{ Scan(...interface{}) error }) (*DownloadClientConfig, error) { var cfg DownloadClientConfig var apiKey sql.NullString var settings []byte err := scanner.Scan(&cfg.ID, &cfg.Name, &cfg.Implementation, &cfg.URL, &apiKey, &cfg.Category, &cfg.Priority, &cfg.Protocol, &settings, &cfg.Enabled, &cfg.CreatedAt, &cfg.UpdatedAt) if err != nil { return nil, err } if apiKey.Valid { cfg.APIKey = &apiKey.String } cfg.Settings = json.RawMessage(settings) return &cfg, nil } func clientConfigToResponse(cfg *DownloadClientConfig) DownloadClientConfigResponse { return DownloadClientConfigResponse{ ID: cfg.ID, Name: cfg.Name, Implementation: cfg.Implementation, URL: cfg.URL, Category: cfg.Category, Priority: cfg.Priority, Protocol: cfg.Protocol, Settings: cfg.Settings, Enabled: cfg.Enabled, CreatedAt: cfg.CreatedAt, UpdatedAt: cfg.UpdatedAt, } } func (s *DownloadClientService) List(ctx context.Context) ([]DownloadClientConfigResponse, error) { rows, err := s.db.Pool.Query(ctx, fmt.Sprintf("SELECT %s FROM download_clients ORDER BY priority, name", downloadClientColumns)) if err != nil { return nil, fmt.Errorf("list download clients: %w", err) } defer rows.Close() var items []DownloadClientConfigResponse for rows.Next() { cfg, err := scanDownloadClientConfig(rows) if err != nil { slog.Error("failed to scan download client", "error", err) continue } items = append(items, clientConfigToResponse(cfg)) } return items, nil } func (s *DownloadClientService) GetByID(ctx context.Context, id int64) (*DownloadClientConfig, error) { row := s.db.Pool.QueryRow(ctx, fmt.Sprintf("SELECT %s FROM download_clients WHERE id = $1", downloadClientColumns), id) cfg, err := scanDownloadClientConfig(row) if err != nil { return nil, fmt.Errorf("download client not found") } return cfg, nil } func (s *DownloadClientService) Create(ctx context.Context, req CreateDownloadClientRequest) (int64, error) { category := req.Category if category == "" { category = "umm" } protocol := req.Protocol if protocol == "" { switch req.Implementation { case "sabnzbd": protocol = "nzb" case "qbittorrent": protocol = "torrent" default: protocol = "nzb" } } settings := req.Settings if settings == nil { settings = json.RawMessage("{}") } enabled := true if req.Enabled != nil { enabled = *req.Enabled } priority := 0 if req.Priority != nil { priority = *req.Priority } var id int64 err := s.db.Pool.QueryRow(ctx, `INSERT INTO download_clients (name, implementation, url, api_key, category, priority, protocol, settings, enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, req.Name, req.Implementation, req.URL, req.APIKey, category, priority, protocol, settings, enabled).Scan(&id) if err != nil { return 0, fmt.Errorf("create download client: %w", err) } return id, nil } func (s *DownloadClientService) Update(ctx context.Context, id int64, req UpdateDownloadClientRequest) error { var setClauses []string var args []interface{} idx := 1 addCol := func(col string, val interface{}) { setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx)) args = append(args, val) idx++ } if req.Name != nil { addCol("name", *req.Name) } if req.Implementation != nil { addCol("implementation", *req.Implementation) } if req.URL != nil { addCol("url", *req.URL) } if req.APIKey != nil { addCol("api_key", *req.APIKey) } if req.Category != nil { addCol("category", *req.Category) } if req.Priority != nil { addCol("priority", *req.Priority) } if req.Protocol != nil { addCol("protocol", *req.Protocol) } if req.Settings != nil { addCol("settings", req.Settings) } if req.Enabled != nil { addCol("enabled", *req.Enabled) } if len(setClauses) == 0 { return fmt.Errorf("no fields to update") } addCol("updated_at", time.Now()) query := fmt.Sprintf("UPDATE download_clients SET %s WHERE id = $%d", strings.Join(setClauses, ", "), idx) args = append(args, id) tag, err := s.db.Pool.Exec(ctx, query, args...) if err != nil { return fmt.Errorf("update download client: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("download client not found") } return nil } func (s *DownloadClientService) Delete(ctx context.Context, id int64) error { tag, err := s.db.Pool.Exec(ctx, "DELETE FROM download_clients WHERE id = $1", id) if err != nil { return fmt.Errorf("delete download client: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("download client not found") } return nil } func (s *DownloadClientService) GetClient(ctx context.Context, protocol string) (download.DownloadClient, *DownloadClientConfig, error) { row := s.db.Pool.QueryRow(ctx, fmt.Sprintf("SELECT %s FROM download_clients WHERE enabled = true AND protocol = $1 ORDER BY priority ASC LIMIT 1", downloadClientColumns), protocol) cfg, err := scanDownloadClientConfig(row) if err != nil { return nil, nil, fmt.Errorf("no enabled download client for protocol: %s", protocol) } client, err := s.instantiateClient(cfg) if err != nil { return nil, nil, err } return client, cfg, nil } func (s *DownloadClientService) GetAllEnabled(ctx context.Context, protocol string) ([]DownloadClientWithInfo, error) { rows, err := s.db.Pool.Query(ctx, fmt.Sprintf("SELECT %s FROM download_clients WHERE enabled = true AND protocol = $1 ORDER BY priority ASC", downloadClientColumns), protocol) if err != nil { return nil, fmt.Errorf("list enabled download clients: %w", err) } defer rows.Close() var clients []DownloadClientWithInfo for rows.Next() { cfg, err := scanDownloadClientConfig(rows) if err != nil { slog.Error("failed to scan download client", "error", err) continue } client, err := s.instantiateClient(cfg) if err != nil { slog.Error("failed to instantiate download client", "error", err, "name", cfg.Name) continue } clients = append(clients, DownloadClientWithInfo{ Client: client, Config: *cfg, }) } return clients, nil } func (s *DownloadClientService) Test(ctx context.Context, id int64) (*DownloadClientTestResult, error) { cfg, err := s.GetByID(ctx, id) if err != nil { return nil, err } client, err := s.instantiateClient(cfg) if err != nil { return &DownloadClientTestResult{Success: false, Error: err.Error()}, nil } _, err = client.GetCompleted(ctx) if err != nil { return &DownloadClientTestResult{Success: false, Error: err.Error()}, nil } return &DownloadClientTestResult{Success: true}, nil } func (s *DownloadClientService) instantiateClient(cfg *DownloadClientConfig) (download.DownloadClient, error) { apiKey := "" if cfg.APIKey != nil { apiKey = *cfg.APIKey } switch cfg.Implementation { case "sabnzbd": return download.NewSABnzbdClient(cfg.URL, apiKey), nil case "qbittorrent": return download.NewQBittorrentClient(cfg.URL, apiKey), nil default: return nil, fmt.Errorf("unknown download client implementation: %s", cfg.Implementation) } }