Implement the list endpoint for qbittorrent

This commit is contained in:
Alexander
2026-05-06 22:53:55 +02:00
parent 36416081c1
commit 6071bc7980
6 changed files with 288 additions and 9 deletions
+12
View File
@@ -8,7 +8,12 @@ const (
IndexerTypeJackett IndexerType = "jackett" IndexerTypeJackett IndexerType = "jackett"
) )
const (
TorrentClientQbittorrent TorrentClientType = "qbittorrent"
)
type IndexerType string type IndexerType string
type TorrentClientType string
type Config struct { type Config struct {
App struct { App struct {
@@ -22,6 +27,13 @@ type Config struct {
Type IndexerType `yaml:"type"` Type IndexerType `yaml:"type"`
ApiKey string `yaml:"api_key"` ApiKey string `yaml:"api_key"`
} `yaml:"indexer"` } `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 { func (t *IndexerType) UnmarshalYAML(unmarshal func(any) error) error {
+32
View File
@@ -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)
}
+170
View File
@@ -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,
}
}
+3 -3
View File
@@ -16,9 +16,9 @@ type TorrentServer struct {
} }
func NewTorrentServer(cfg config.Config) (*TorrentServer, error) { func NewTorrentServer(cfg config.Config) (*TorrentServer, error) {
service, err := NewIndexerService(cfg) service, err := NewTorrentService(cfg)
if err != nil { if err != nil {
log.Err(err).Msg("Failed to initialize IndexerService") log.Err(err).Msg("failed to initialize TorrentService")
return nil, err 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) { 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) { func (s *TorrentServer) Register(server *grpc.Server) {
+69 -4
View File
@@ -1,11 +1,76 @@
package torrent 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 { type TorrentService struct {
config config.Config client TorrentClient
token string
} }
func NewIndexerService(cfg config.Config) (*TorrentService, error) { func NewTorrentService(cfg config.Config) (*TorrentService, error) {
return &TorrentService{config: cfg}, nil 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
} }
@@ -18,10 +18,10 @@ message ListRequest {
} }
message ListResponse { message ListResponse {
repeated Torrent torrents = 1; repeated ListItem items = 1;
} }
message Torrent { message ListItem {
string hash = 1; string hash = 1;
string name = 2; string name = 2;
int64 size = 3; int64 size = 3;