diff --git a/internal/torrent/client.go b/internal/torrent/client.go index de7ac59..c22d701 100644 --- a/internal/torrent/client.go +++ b/internal/torrent/client.go @@ -26,7 +26,22 @@ type TorrentInfo struct { Availability float64 } +type TorrentFile struct { + Filename string + Data []byte +} + +type FindOptions struct { + Hash string + Name string + Category string + Tag string + State string +} + type TorrentClient interface { Login(username string, password string) (string, error) List() ([]TorrentInfo, error) + Find(opts FindOptions) ([]TorrentInfo, error) + Add(file TorrentFile) error } diff --git a/internal/torrent/qbittorrent_client.go b/internal/torrent/qbittorrent_client.go index ca8b5b3..2a2cae6 100644 --- a/internal/torrent/qbittorrent_client.go +++ b/internal/torrent/qbittorrent_client.go @@ -1,9 +1,11 @@ package torrent import ( + "bytes" "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "net/url" "strings" @@ -75,34 +77,63 @@ func (c *QbittorrentClient) Login(username string, password string) (string, err } func (c *QbittorrentClient) List() ([]TorrentInfo, error) { - log.Trace().Msg("qbittorrent listing torrents") + return c.Find(FindOptions{}) +} - req, err := http.NewRequest("GET", c.baseURL+"/api/v2/torrents/info", nil) +func (c *QbittorrentClient) Find(opts FindOptions) ([]TorrentInfo, error) { + log.Trace(). + Str("hash", opts.Hash). + Str("name", opts.Name). + Str("category", opts.Category). + Str("tag", opts.Tag). + Str("state", opts.State). + Msg("qbittorrent finding torrents") + + params := url.Values{} + if opts.Hash != "" { + params.Set("hashes", opts.Hash) + } + if opts.Category != "" { + params.Set("category", opts.Category) + } + if opts.Tag != "" { + params.Set("tag", opts.Tag) + } + if opts.State != "" { + params.Set("filter", opts.State) + } + + reqURL := c.baseURL + "/api/v2/torrents/info" + if len(params) > 0 { + reqURL += "?" + params.Encode() + } + + req, err := http.NewRequest("GET", reqURL, nil) if err != nil { - log.Error().Err(err).Msg("qbittorrent creating list request failed") - return nil, fmt.Errorf("creating list request: %w", err) + log.Error().Err(err).Msg("qbittorrent creating find request failed") + return nil, fmt.Errorf("creating find 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) + log.Error().Err(err).Msg("qbittorrent find request failed") + return nil, fmt.Errorf("find request failed: %w", err) } defer resp.Body.Close() - log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent list response") + log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent find 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) + log.Error().Int("status", resp.StatusCode).Msg("qbittorrent find returned non-OK status") + return nil, fmt.Errorf("find 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) + log.Error().Err(err).Msg("qbittorrent decoding find response failed") + return nil, fmt.Errorf("decoding find response: %w", err) } torrents := make([]TorrentInfo, len(items)) @@ -110,11 +141,93 @@ func (c *QbittorrentClient) List() ([]TorrentInfo, error) { torrents[i] = item.toTorrentInfo() } - log.Debug().Int("count", len(torrents)).Msg("qbittorrent torrents listed") + torrents = filterLocally(torrents, opts) + + log.Debug().Int("count", len(torrents)).Msg("qbittorrent find results") return torrents, nil } +func filterLocally(torrents []TorrentInfo, opts FindOptions) []TorrentInfo { + var result []TorrentInfo + + for _, t := range torrents { + if opts.Name != "" && !strings.Contains(strings.ToLower(t.Name), strings.ToLower(opts.Name)) { + continue + } + if opts.Hash != "" && !strings.EqualFold(t.Hash, opts.Hash) { + continue + } + if opts.Category != "" && !strings.EqualFold(t.Category, opts.Category) { + continue + } + if opts.Tag != "" && !strings.Contains(strings.ToLower(t.Tags), strings.ToLower(opts.Tag)) { + continue + } + if opts.State != "" && !strings.EqualFold(t.State, opts.State) { + continue + } + result = append(result, t) + } + + return result +} + +func (c *QbittorrentClient) Add(file TorrentFile) error { + log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Msg("qbittorrent adding torrent") + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + part, err := writer.CreateFormFile("torrents", file.Filename) + if err != nil { + log.Error().Err(err).Msg("qbittorrent creating multipart form failed") + return fmt.Errorf("creating multipart form: %w", err) + } + + if _, err := part.Write(file.Data); err != nil { + log.Error().Err(err).Msg("qbittorrent writing torrent data failed") + return fmt.Errorf("writing torrent data: %w", err) + } + + if err := writer.Close(); err != nil { + return fmt.Errorf("closing multipart writer: %w", err) + } + + req, err := http.NewRequest("POST", c.baseURL+"/api/v2/torrents/add", &buf) + if err != nil { + log.Error().Err(err).Msg("qbittorrent creating add request failed") + return fmt.Errorf("creating add request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + 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 add request failed") + return fmt.Errorf("add request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Error().Err(err).Msg("qbittorrent reading add response failed") + return fmt.Errorf("reading add response: %w", err) + } + + log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent add response") + + if resp.StatusCode != http.StatusOK || string(body) != "Ok." { + log.Error().Int("status", resp.StatusCode).Str("body", string(body)).Msg("qbittorrent add torrent failed") + return fmt.Errorf("add torrent failed: status %d, body: %s", resp.StatusCode, string(body)) + } + + log.Info().Str("filename", file.Filename).Msg("qbittorrent torrent added") + + return nil +} + type QbittorrentListItem struct { Hash string `json:"hash"` Name string `json:"name"` diff --git a/internal/torrent/server.go b/internal/torrent/server.go index 264962b..f424b7d 100644 --- a/internal/torrent/server.go +++ b/internal/torrent/server.go @@ -29,6 +29,18 @@ func (server *TorrentServer) List(ctx context.Context, req *pb.ListRequest) (*pb return server.service.List(req) } +func (server *TorrentServer) Add(ctx context.Context, req *pb.AddRequest) (*pb.AddResponse, error) { + log.Debug().Str("download_url", req.GetDownloadUrl()).Str("filename", req.GetFilename()).Msg("add torrent requested") + + resp, err := server.service.Add(req) + if err != nil { + log.Error().Err(err).Msg("add torrent failed") + return nil, err + } + + return resp, nil +} + func (s *TorrentServer) Register(server *grpc.Server) { pb.RegisterTorrentServiceServer(server, s) } diff --git a/internal/torrent/service.go b/internal/torrent/service.go index 16a8d99..e1424e6 100644 --- a/internal/torrent/service.go +++ b/internal/torrent/service.go @@ -2,6 +2,11 @@ package torrent import ( "fmt" + "io" + "net/http" + "path" + "strings" + "time" "github.com/rs/zerolog/log" @@ -43,34 +48,170 @@ func (service *TorrentService) List(req *pb.ListRequest) (*pb.ListResponse, erro 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: toProtoItems(torrents)}, nil +} + +func (service *TorrentService) Add(req *pb.AddRequest) (*pb.AddResponse, error) { + var file TorrentFile + + if len(req.GetTorrentData()) > 0 { + file = TorrentFile{ + Filename: req.GetFilename(), + Data: req.GetTorrentData(), } + } else if req.GetDownloadUrl() != "" { + downloaded, err := downloadTorrentFile(req.GetDownloadUrl()) + if err != nil { + return nil, err + } + file = *downloaded + } else { + return nil, fmt.Errorf("either torrent_data or download_url must be provided") } - return &pb.ListResponse{Items: items}, nil + if err := service.client.Add(file); err != nil { + return nil, err + } + + time.Sleep(500 * time.Millisecond) + + searchName := strings.TrimSuffix(file.Filename, ".torrent") + torrents, err := service.client.Find(FindOptions{Name: searchName}) + if err != nil { + log.Warn().Err(err).Msg("torrent added but failed to find it afterwards") + return &pb.AddResponse{}, nil + } + + if len(torrents) == 0 { + log.Warn().Str("filename", file.Filename).Msg("torrent added but not found in client") + return &pb.AddResponse{}, nil + } + + return &pb.AddResponse{Item: toProtoItem(torrents[0])}, nil +} + +func toProtoItems(torrents []TorrentInfo) []*pb.ListItem { + items := make([]*pb.ListItem, len(torrents)) + for i, t := range torrents { + items[i] = toProtoItem(t) + } + return items +} + +func toProtoItem(t TorrentInfo) *pb.ListItem { + return &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, + + SizeFormatted: formatBytes(t.Size), + ProgressFormatted: fmt.Sprintf("%.1f%%", t.Progress*100), + DlspeedFormatted: formatSpeed(t.DlSpeed), + UpspeedFormatted: formatSpeed(t.UpSpeed), + AddedOnFormatted: formatTimestamp(t.AddedOn), + CompletionOnFormatted: formatTimestamp(t.CompletionOn), + DownloadedFormatted: formatBytes(t.Downloaded), + UploadedFormatted: formatBytes(t.Uploaded), + AmountLeftFormatted: formatBytes(t.AmountLeft), + AvailabilityFormatted: fmt.Sprintf("%.2f", t.Availability), + EtaFormatted: formatETA(t.ETA), + } +} + +func formatBytes(b int64) string { + switch { + case b >= 1<<30: + return fmt.Sprintf("%.2f GB", float64(b)/float64(1<<30)) + case b >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(b)/float64(1<<20)) + case b >= 1<<10: + return fmt.Sprintf("%.0f KB", float64(b)/float64(1<<10)) + default: + return fmt.Sprintf("%d B", b) + } +} + +func formatSpeed(bytesPerSec int64) string { + if bytesPerSec == 0 { + return "0 B/s" + } + return formatBytes(bytesPerSec) + "/s" +} + +func formatTimestamp(ts int64) string { + if ts <= 0 { + return "" + } + return time.Unix(ts, 0).Format("2006-01-02 15:04:05") +} + +func formatETA(seconds int64) string { + if seconds <= 0 || seconds >= 8640000 { + return "∞" + } + d := time.Duration(seconds) * time.Second + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + if h > 0 { + return fmt.Sprintf("%dh %dm %ds", h, m, s) + } + if m > 0 { + return fmt.Sprintf("%dm %ds", m, s) + } + return fmt.Sprintf("%ds", s) +} + +func downloadTorrentFile(url string) (*TorrentFile, error) { + log.Trace().Str("url", url).Msg("downloading torrent file") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + log.Error().Err(err).Str("url", url).Msg("downloading torrent file failed") + return nil, fmt.Errorf("downloading torrent file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Error().Int("status", resp.StatusCode).Str("url", url).Msg("torrent download returned non-OK status") + return nil, fmt.Errorf("torrent download returned status %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading torrent file: %w", err) + } + + filename := path.Base(resp.Request.URL.Path) + if !strings.HasSuffix(strings.ToLower(filename), ".torrent") { + filename += ".torrent" + } + + log.Debug().Str("filename", filename).Int("size", len(data)).Msg("torrent file downloaded") + + return &TorrentFile{ + Filename: filename, + Data: data, + }, nil } diff --git a/proto/music_agregator/torrent/v1/torrent.proto b/proto/music_agregator/torrent/v1/torrent.proto index 7ff9e57..7a34b42 100644 --- a/proto/music_agregator/torrent/v1/torrent.proto +++ b/proto/music_agregator/torrent/v1/torrent.proto @@ -4,6 +4,7 @@ option go_package = "homelab.lan/music-agregator/gen/music_agregator/v1/torrent" service TorrentService { rpc List(ListRequest) returns (ListResponse) {} + rpc Add(AddRequest) returns (AddResponse) {} } message ListRequest { @@ -45,4 +46,27 @@ message ListItem { int64 seeding_time = 21; int64 amount_left = 22; double availability = 23; + + string size_formatted = 100; + string progress_formatted = 101; + string dlspeed_formatted = 102; + string upspeed_formatted = 103; + string added_on_formatted = 104; + string completion_on_formatted = 105; + string downloaded_formatted = 106; + string uploaded_formatted = 107; + string amount_left_formatted = 108; + string availability_formatted = 109; + string eta_formatted = 110; +} + +message AddRequest { + string download_url = 1; + string filename = 2; + bytes torrent_data = 3; + string save_path = 4; +} + +message AddResponse { + ListItem item = 1; }