Files
unified-media-manager/internal/service/download_client.go
2026-04-24 10:45:19 -07:00

354 lines
10 KiB
Go

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