Implement the list endpoint for qbittorrent
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user