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