diff --git a/internal/config/config.go b/internal/config/config.go index 69e61c1..a89af01 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,7 +8,12 @@ const ( IndexerTypeJackett IndexerType = "jackett" ) +const ( + TorrentClientQbittorrent TorrentClientType = "qbittorrent" +) + type IndexerType string +type TorrentClientType string type Config struct { App struct { @@ -22,6 +27,13 @@ type Config struct { Type IndexerType `yaml:"type"` ApiKey string `yaml:"api_key"` } `yaml:"indexer"` + + Torrent struct { + ClientType TorrentClientType `yaml:"client_type"` + Url string `yaml:"url"` + Username string `yaml:"username"` + Password string `yaml:"password"` + } `yaml:"torrent"` } func (t *IndexerType) UnmarshalYAML(unmarshal func(any) error) error { diff --git a/internal/torrent/client.go b/internal/torrent/client.go new file mode 100644 index 0000000..de7ac59 --- /dev/null +++ b/internal/torrent/client.go @@ -0,0 +1,32 @@ +package torrent + +type TorrentInfo struct { + Hash string + Name string + Size int64 + Progress float64 + DlSpeed int64 + UpSpeed int64 + NumSeeds int32 + NumLeechs int32 + State string + ETA int64 + Ratio float64 + Category string + Tags string + AddedOn int64 + CompletionOn int64 + SavePath string + ContentPath string + Downloaded int64 + Uploaded int64 + Tracker string + SeedingTime int64 + AmountLeft int64 + Availability float64 +} + +type TorrentClient interface { + Login(username string, password string) (string, error) + List() ([]TorrentInfo, error) +} diff --git a/internal/torrent/qbittorrent_client.go b/internal/torrent/qbittorrent_client.go new file mode 100644 index 0000000..ca8b5b3 --- /dev/null +++ b/internal/torrent/qbittorrent_client.go @@ -0,0 +1,170 @@ +package torrent + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/rs/zerolog/log" +) + +type QbittorrentClient struct { + baseURL string + client *http.Client + sid string +} + +func NewQbittorrentClient(baseURL string) *QbittorrentClient { + return &QbittorrentClient{ + baseURL: strings.TrimRight(baseURL, "/"), + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (c *QbittorrentClient) Login(username string, password string) (string, error) { + log.Trace().Str("url", c.baseURL).Str("username", username).Msg("qbittorrent login attempt") + + data := url.Values{ + "username": {username}, + "password": {password}, + } + + start := time.Now() + resp, err := c.client.PostForm(c.baseURL+"/api/v2/auth/login", data) + if err != nil { + log.Error().Err(err).Str("url", c.baseURL).Msg("qbittorrent login request failed") + return "", fmt.Errorf("login request failed: %w", err) + } + defer resp.Body.Close() + + log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent login response") + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Error().Err(err).Msg("qbittorrent reading login response failed") + return "", fmt.Errorf("reading login response: %w", err) + } + + if resp.StatusCode == http.StatusForbidden { + log.Warn().Msg("qbittorrent login forbidden: too many failed attempts") + return "", fmt.Errorf("login forbidden: too many failed attempts") + } + + if string(body) != "Ok." { + log.Warn().Str("response", string(body)).Msg("qbittorrent login rejected") + return "", fmt.Errorf("login failed: %s", string(body)) + } + + for _, cookie := range resp.Cookies() { + if cookie.Name == "SID" { + c.sid = cookie.Value + log.Info().Str("url", c.baseURL).Msg("qbittorrent login successful") + log.Trace().Str("sid", c.sid).Msg("qbittorrent session ID acquired") + return c.sid, nil + } + } + + log.Error().Msg("qbittorrent login succeeded but no SID cookie returned") + return "", fmt.Errorf("login succeeded but no SID cookie returned") +} + +func (c *QbittorrentClient) List() ([]TorrentInfo, error) { + log.Trace().Msg("qbittorrent listing torrents") + + req, err := http.NewRequest("GET", c.baseURL+"/api/v2/torrents/info", nil) + if err != nil { + log.Error().Err(err).Msg("qbittorrent creating list request failed") + return nil, fmt.Errorf("creating list request: %w", err) + } + req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid}) + + start := time.Now() + resp, err := c.client.Do(req) + if err != nil { + log.Error().Err(err).Msg("qbittorrent list request failed") + return nil, fmt.Errorf("list request failed: %w", err) + } + defer resp.Body.Close() + + log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent list response") + + if resp.StatusCode != http.StatusOK { + log.Error().Int("status", resp.StatusCode).Msg("qbittorrent list returned non-OK status") + return nil, fmt.Errorf("list request returned status %d", resp.StatusCode) + } + + var items []QbittorrentListItem + if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { + log.Error().Err(err).Msg("qbittorrent decoding list response failed") + return nil, fmt.Errorf("decoding list response: %w", err) + } + + torrents := make([]TorrentInfo, len(items)) + for i, item := range items { + torrents[i] = item.toTorrentInfo() + } + + log.Debug().Int("count", len(torrents)).Msg("qbittorrent torrents listed") + + return torrents, nil +} + +type QbittorrentListItem struct { + Hash string `json:"hash"` + Name string `json:"name"` + Size int64 `json:"size"` + Progress float64 `json:"progress"` + DlSpeed int64 `json:"dlspeed"` + UpSpeed int64 `json:"upspeed"` + NumSeeds int32 `json:"num_seeds"` + NumLeechs int32 `json:"num_leechs"` + State string `json:"state"` + ETA int64 `json:"eta"` + Ratio float64 `json:"ratio"` + Category string `json:"category"` + Tags string `json:"tags"` + AddedOn int64 `json:"added_on"` + CompletionOn int64 `json:"completion_on"` + SavePath string `json:"save_path"` + ContentPath string `json:"content_path"` + Downloaded int64 `json:"downloaded"` + Uploaded int64 `json:"uploaded"` + Tracker string `json:"tracker"` + SeedingTime int64 `json:"seeding_time"` + AmountLeft int64 `json:"amount_left"` + Availability float64 `json:"availability"` +} + +func (t *QbittorrentListItem) toTorrentInfo() TorrentInfo { + return TorrentInfo{ + Hash: t.Hash, + Name: t.Name, + Size: t.Size, + Progress: t.Progress, + DlSpeed: t.DlSpeed, + UpSpeed: t.UpSpeed, + NumSeeds: t.NumSeeds, + NumLeechs: t.NumLeechs, + State: t.State, + ETA: t.ETA, + Ratio: t.Ratio, + Category: t.Category, + Tags: t.Tags, + AddedOn: t.AddedOn, + CompletionOn: t.CompletionOn, + SavePath: t.SavePath, + ContentPath: t.ContentPath, + Downloaded: t.Downloaded, + Uploaded: t.Uploaded, + Tracker: t.Tracker, + SeedingTime: t.SeedingTime, + AmountLeft: t.AmountLeft, + Availability: t.Availability, + } +} diff --git a/internal/torrent/server.go b/internal/torrent/server.go index 8562abf..264962b 100644 --- a/internal/torrent/server.go +++ b/internal/torrent/server.go @@ -16,9 +16,9 @@ type TorrentServer struct { } func NewTorrentServer(cfg config.Config) (*TorrentServer, error) { - service, err := NewIndexerService(cfg) + service, err := NewTorrentService(cfg) if err != nil { - log.Err(err).Msg("Failed to initialize IndexerService") + log.Err(err).Msg("failed to initialize TorrentService") return nil, err } @@ -26,7 +26,7 @@ func NewTorrentServer(cfg config.Config) (*TorrentServer, error) { } func (server *TorrentServer) List(ctx context.Context, req *pb.ListRequest) (*pb.ListResponse, error) { - return nil, nil + return server.service.List(req) } func (s *TorrentServer) Register(server *grpc.Server) { diff --git a/internal/torrent/service.go b/internal/torrent/service.go index 24400cf..16a8d99 100644 --- a/internal/torrent/service.go +++ b/internal/torrent/service.go @@ -1,11 +1,76 @@ package torrent -import "homelab.lan/music-agregator/internal/config" +import ( + "fmt" + + "github.com/rs/zerolog/log" + + pb "homelab.lan/music-agregator/gen/music_agregator/torrent/v1" + "homelab.lan/music-agregator/internal/config" +) type TorrentService struct { - config config.Config + client TorrentClient + token string } -func NewIndexerService(cfg config.Config) (*TorrentService, error) { - return &TorrentService{config: cfg}, nil +func NewTorrentService(cfg config.Config) (*TorrentService, error) { + var client TorrentClient + + switch cfg.Torrent.ClientType { + case config.TorrentClientQbittorrent: + client = NewQbittorrentClient(cfg.Torrent.Url) + default: + return nil, fmt.Errorf("unknown torrent client type: %s", cfg.Torrent.ClientType) + } + + token, err := client.Login(cfg.Torrent.Username, cfg.Torrent.Password) + if err != nil { + return nil, fmt.Errorf("torrent client login failed: %w", err) + } + + log.Info().Str("client", string(cfg.Torrent.ClientType)).Msg("torrent client connected") + + return &TorrentService{ + client: client, + token: token, + }, nil +} + +func (service *TorrentService) List(req *pb.ListRequest) (*pb.ListResponse, error) { + torrents, err := service.client.List() + if err != nil { + return nil, err + } + + items := make([]*pb.ListItem, len(torrents)) + for i, t := range torrents { + items[i] = &pb.ListItem{ + Hash: t.Hash, + Name: t.Name, + Size: t.Size, + Progress: t.Progress, + Dlspeed: t.DlSpeed, + Upspeed: t.UpSpeed, + NumSeeds: t.NumSeeds, + NumLeechs: t.NumLeechs, + State: t.State, + Eta: t.ETA, + Ratio: t.Ratio, + Category: t.Category, + Tags: t.Tags, + AddedOn: t.AddedOn, + CompletionOn: t.CompletionOn, + SavePath: t.SavePath, + ContentPath: t.ContentPath, + Downloaded: t.Downloaded, + Uploaded: t.Uploaded, + Tracker: t.Tracker, + SeedingTime: t.SeedingTime, + AmountLeft: t.AmountLeft, + Availability: t.Availability, + } + } + + return &pb.ListResponse{Items: items}, nil } diff --git a/proto/music_agregator/torrent/v1/torrent.proto b/proto/music_agregator/torrent/v1/torrent.proto index e1e7bbb..7ff9e57 100644 --- a/proto/music_agregator/torrent/v1/torrent.proto +++ b/proto/music_agregator/torrent/v1/torrent.proto @@ -18,10 +18,10 @@ message ListRequest { } message ListResponse { - repeated Torrent torrents = 1; + repeated ListItem items = 1; } -message Torrent { +message ListItem { string hash = 1; string name = 2; int64 size = 3;