From fdbff144256521dfef573cec80e68ab5b3dd806d Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Sun, 30 Jun 2019 14:04:43 -0700 Subject: [PATCH 1/2] Add Deluge 2 struct, version method, debug logs, cleanup. --- config.go | 263 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- deluge.go | 159 ++++++++++++++++++++++++++++----- 2 files changed, 391 insertions(+), 31 deletions(-) diff --git a/config.go b/config.go index b98d789..a45298b 100644 --- a/config.go +++ b/config.go @@ -5,7 +5,7 @@ import ( "time" ) -// Deluge methods. +// Deluge WebUI methods. const ( AuthLogin = "auth.login" AddMagnet = "core.add_torrent_magnet" @@ -13,15 +13,28 @@ const ( AddTorrentFile = "core.add_torrent_file" GetTorrentStat = "core.get_torrent_status" GetAllTorrents = "core.get_torrents_status" + HostStatus = "web.get_host_status" + GeHosts = "web.get_hosts" ) // Config is the data needed to poll Deluge. type Config struct { - URL string `json:"url" toml:"url" xml:"url" yaml:"url"` - Password string `json:"password" toml:"password" xml:"password" yaml:"password"` - HTTPPass string `json:"http_pass" toml:"http_pass" xml:"http_pass" yaml:"http_pass"` - HTTPUser string `json:"http_user" toml:"http_user" xml:"http_user" yaml:"http_user"` - Timeout time.Duration `json:"timeout" toml:"timeout" xml:"timeout" yaml:"timeout"` + URL string `json:"url" toml:"url" xml:"url" yaml:"url"` + Password string `json:"password" toml:"password" xml:"password" yaml:"password"` + HTTPPass string `json:"http_pass" toml:"http_pass" xml:"http_pass" yaml:"http_pass"` + HTTPUser string `json:"http_user" toml:"http_user" xml:"http_user" yaml:"http_user"` + Timeout Duration `json:"timeout" toml:"timeout" xml:"timeout" yaml:"timeout"` + VerifySSL bool `json:"verify_ssl" toml:"verify_ssl" xml:"verify_ssl" yaml:"verify_ssl"` + Version string `json:"version" toml:"version" xml:"version" yaml:"version"` +} + +// Duration is used to UnmarshalTOML into a time.Duration value. +type Duration struct{ time.Duration } + +// UnmarshalText parses a duration type from a config file. +func (d *Duration) UnmarshalText(data []byte) (err error) { + d.Duration, err = time.ParseDuration(string(data)) + return } // Response from Deluge @@ -34,7 +47,128 @@ type Response struct { } `json:"error"` } -// XferStatus represents a transfer in Deluge. +// Backend holds a WebUI's backend server data. +type Backend struct { + ID string + Addr string + Prot string +} + +// XferStatus2 is the Deluge 2.0 WebUI API layout for Active Transfers. +type XferStatus2 struct { + ActiveTime float64 `json:"active_time"` + SeedingTime float64 `json:"seeding_time"` + FinishedTime float64 `json:"finished_time"` + AllTimeDownload float64 `json:"all_time_download"` + StorageMode string `json:"storage_mode"` + DistributedCopies float64 `json:"distributed_copies"` + DownloadPayloadRate float64 `json:"download_payload_rate"` + FilePriorities []int `json:"file_priorities"` + Hash string `json:"hash"` + AutoManaged bool `json:"auto_managed"` + IsAutoManaged bool `json:"is_auto_managed"` + IsFinished bool `json:"is_finished"` + MaxConnections float64 `json:"max_connections"` + MaxDownloadSpeed float64 `json:"max_download_speed"` + MaxUploadSlots float64 `json:"max_upload_slots"` + MaxUploadSpeed float64 `json:"max_upload_speed"` + Message string `json:"message"` + MoveOnCompletedPath string `json:"move_on_completed_path"` + MoveOnCompleted bool `json:"move_on_completed"` + MoveCompletedPath string `json:"move_completed_path"` + MoveCompleted bool `json:"move_completed"` + NextAnnounce float64 `json:"next_announce"` + NumPeers int64 `json:"num_peers"` + NumSeeds int64 `json:"num_seeds"` + Owner string `json:"owner"` + Paused bool `json:"paused"` + PrioritizeFirstLast bool `json:"prioritize_first_last"` + PrioritizeFirstLastPieces bool `json:"prioritize_first_last_pieces"` + SequentialDownload bool `json:"sequential_download"` + Progress float64 `json:"progress"` + Shared bool `json:"shared"` + RemoveAtRatio bool `json:"remove_at_ratio"` + SavePath string `json:"save_path"` + DownloadLocation string `json:"download_location"` + SeedsPeersRatio float64 `json:"seeds_peers_ratio"` + SeedRank int `json:"seed_rank"` + State string `json:"state"` + StopAtRatio bool `json:"stop_at_ratio"` + StopRatio float64 `json:"stop_ratio"` + TimeAdded float64 `json:"time_added"` + TotalDone float64 `json:"total_done"` + TotalPayloadDownload float64 `json:"total_payload_download"` + TotalPayloadUpload float64 `json:"total_payload_upload"` + TotalPeers int64 `json:"total_peers"` + TotalSeeds float64 `json:"total_seeds"` + TotalUploaded float64 `json:"total_uploaded"` + TotalWanted float64 `json:"total_wanted"` + TotalRemaining float64 `json:"total_remaining"` + Tracker string `json:"tracker"` + TrackerHost string `json:"tracker_host"` + Trackers []struct { + URL string `json:"url"` + Trackerid string `json:"trackerid"` + Tier int `json:"tier"` + FailLimit int `json:"fail_limit"` + Source int `json:"source"` + Verified bool `json:"verified"` + Message string `json:"message"` + LastError struct { + Value int `json:"value"` + Category string `json:"category"` + } `json:"last_error"` + NextAnnounce interface{} `json:"next_announce"` + MinAnnounce interface{} `json:"min_announce"` + ScrapeIncomplete float64 `json:"scrape_incomplete"` + ScrapeComplete float64 `json:"scrape_complete"` + ScrapeDownloaded float64 `json:"scrape_downloaded"` + Fails int64 `json:"fails"` + Updating bool `json:"updating"` + StartSent bool `json:"start_sent"` + CompleteSent bool `json:"complete_sent"` + Endpoints []interface{} `json:"endpoints"` + SendStats bool `json:"send_stats"` + } `json:"trackers"` + TrackerStatus string `json:"tracker_status"` + UploadPayloadRate float64 `json:"upload_payload_rate"` + Comment string `json:"comment"` + Creator string `json:"creator"` + NumFiles float64 `json:"num_files"` + NumPieces float64 `json:"num_pieces"` + PieceLength float64 `json:"piece_length"` + Private bool `json:"private"` + TotalSize float64 `json:"total_size"` + Eta json.Number `json:"eta"` + FileProgress []float64 `json:"file_progress"` + Files []struct { + Index int64 `json:"index"` + Path string `json:"path"` + Size int64 `json:"size"` + Offset int64 `json:"offset"` + } `json:"files"` + OrigFiles []struct { + Index int64 `json:"index"` + Path string `json:"path"` + Size int64 `json:"size"` + Offset int64 `json:"offset"` + } `json:"orig_files"` + IsSeed bool `json:"is_seed"` + Peers []interface{} `json:"peers"` + Queue int `json:"queue"` + Ratio float64 `json:"ratio"` + CompletedTime float64 `json:"completed_time"` + LastSeenComplete float64 `json:"last_seen_complete"` + Name string `json:"name"` + Pieces interface{} `json:"pieces"` + SeedMode bool `json:"seed_mode"` + SuperSeeding bool `json:"super_seeding"` + TimeSinceDownload float64 `json:"time_since_download"` + TimeSinceUpload float64 `json:"time_since_upload"` + TimeSinceTransfer float64 `json:"time_since_transfer"` +} + +// XferStatus is the Deluge 1.0 WebUI API layout for Active Transfers. type XferStatus struct { Comment string `json:"comment"` ActiveTime int64 `json:"active_time"` @@ -115,3 +249,118 @@ type XferStatus struct { StopRatio float64 `json:"stop_ratio"` IsFinished bool `json:"is_finished"` } + +// XferStatusCompat is a compatibile struct for Deluge 1 and 2 API data. +type XferStatusCompat struct { + ActiveTime float64 `json:"active_time"` + SeedingTime float64 `json:"seeding_time"` + FinishedTime float64 `json:"finished_time"` + AllTimeDownload float64 `json:"all_time_download"` + StorageMode string `json:"storage_mode"` + DistributedCopies float64 `json:"distributed_copies"` + DownloadPayloadRate float64 `json:"download_payload_rate"` + FilePriorities []int `json:"file_priorities"` + Hash string `json:"hash"` + AutoManaged bool `json:"auto_managed"` + IsAutoManaged bool `json:"is_auto_managed"` + IsFinished bool `json:"is_finished"` + MaxConnections float64 `json:"max_connections"` + MaxDownloadSpeed float64 `json:"max_download_speed"` + MaxUploadSlots float64 `json:"max_upload_slots"` + MaxUploadSpeed float64 `json:"max_upload_speed"` + Message string `json:"message"` + MoveOnCompletedPath string `json:"move_on_completed_path"` + MoveOnCompleted bool `json:"move_on_completed"` + MoveCompletedPath string `json:"move_completed_path"` + MoveCompleted bool `json:"move_completed"` + NextAnnounce float64 `json:"next_announce"` + NumPeers int64 `json:"num_peers"` + NumSeeds int64 `json:"num_seeds"` + Owner string `json:"owner"` + Paused bool `json:"paused"` + PrioritizeFirstLast bool `json:"prioritize_first_last"` + PrioritizeFirstLastPieces bool `json:"prioritize_first_last_pieces"` + SequentialDownload bool `json:"sequential_download"` + Progress float64 `json:"progress"` + Shared bool `json:"shared"` + RemoveAtRatio bool `json:"remove_at_ratio"` + SavePath string `json:"save_path"` + DownloadLocation string `json:"download_location"` + SeedsPeersRatio float64 `json:"seeds_peers_ratio"` + SeedRank int `json:"seed_rank"` + State string `json:"state"` + StopAtRatio bool `json:"stop_at_ratio"` + StopRatio float64 `json:"stop_ratio"` + TimeAdded float64 `json:"time_added"` + TotalDone float64 `json:"total_done"` + TotalPayloadDownload float64 `json:"total_payload_download"` + TotalPayloadUpload float64 `json:"total_payload_upload"` + TotalPeers int64 `json:"total_peers"` + TotalSeeds float64 `json:"total_seeds"` + TotalUploaded float64 `json:"total_uploaded"` + TotalWanted float64 `json:"total_wanted"` + TotalRemaining float64 `json:"total_remaining"` + Tracker string `json:"tracker"` + TrackerHost string `json:"tracker_host"` + TrackerStatus string `json:"tracker_status"` + UploadPayloadRate float64 `json:"upload_payload_rate"` + Comment string `json:"comment"` + Creator string `json:"creator"` + NumFiles float64 `json:"num_files"` + NumPieces float64 `json:"num_pieces"` + PieceLength float64 `json:"piece_length"` + Private bool `json:"private"` + TotalSize float64 `json:"total_size"` + Eta json.Number `json:"eta"` + FileProgress []float64 `json:"file_progress"` + Files []struct { + Index int64 `json:"index"` + Path string `json:"path"` + Size int64 `json:"size"` + Offset int64 `json:"offset"` + } `json:"files"` + OrigFiles []struct { + Index int64 `json:"index"` + Path string `json:"path"` + Size int64 `json:"size"` + Offset int64 `json:"offset"` + } `json:"orig_files"` + IsSeed bool `json:"is_seed"` + Peers []interface{} `json:"peers"` + Queue int64 `json:"queue"` + Ratio float64 `json:"ratio"` + CompletedTime float64 `json:"completed_time"` + LastSeenComplete float64 `json:"last_seen_complete"` + Name string `json:"name"` + Pieces interface{} `json:"pieces"` + SeedMode bool `json:"seed_mode"` + SuperSeeding bool `json:"super_seeding"` + TimeSinceDownload float64 `json:"time_since_download"` + TimeSinceUpload float64 `json:"time_since_upload"` + TimeSinceTransfer float64 `json:"time_since_transfer"` + Label string `json:"label"` + Trackers []struct { + SendStats bool `json:"send_stats"` + Source float64 `json:"source"` + StartSent bool `json:"start_sent"` + URL string `json:"url"` + Trackerid string `json:"trackerid"` + Tier float64 `json:"tier"` + FailLimit int64 `json:"fail_limit"` + Verified bool `json:"verified"` + Message string `json:"message"` + LastError struct { + Value int `json:"value"` + Category string `json:"category"` + } `json:"last_error"` + NextAnnounce interface{} `json:"next_announce"` + MinAnnounce interface{} `json:"min_announce"` + ScrapeIncomplete float64 `json:"scrape_incomplete"` + ScrapeComplete float64 `json:"scrape_complete"` + ScrapeDownloaded float64 `json:"scrape_downloaded"` + Fails int64 `json:"fails"` + Updating bool `json:"updating"` + CompleteSent bool `json:"complete_sent"` + Endpoints []interface{} `json:"endpoints"` + } `json:"trackers"` +} diff --git a/deluge.go b/deluge.go index 9c533f6..8cbefb1 100644 --- a/deluge.go +++ b/deluge.go @@ -5,10 +5,13 @@ import ( "crypto/tls" "encoding/base64" "encoding/json" + "io" "io/ioutil" "net/http" "net/http/cookiejar" + "strconv" "strings" + "time" "github.com/pkg/errors" ) @@ -16,9 +19,12 @@ import ( // Deluge is what you get for providing a password. type Deluge struct { *http.Client - URL string - auth string - id int + URL string + auth string + id int + Version string // Currently unused, for display purposes only. + Backends map[string]Backend // Currently unused, for display purposes only. + DebugLog func(msg string, fmt ...interface{}) } // New creates a http.Client with authenticated cookies. @@ -35,28 +41,96 @@ func New(config Config) (*Deluge, error) { config.URL += "json" // This app allows http auth, in addition to deluge web password. - if config.HTTPUser += ":" + config.HTTPPass; config.HTTPUser != ":" { - config.HTTPUser = "Basic " + base64.StdEncoding.EncodeToString([]byte(config.HTTPUser)) + if both := config.HTTPUser + ":" + config.HTTPPass; both != ":" { + config.HTTPUser = "Basic " + base64.StdEncoding.EncodeToString([]byte(both)) } else { config.HTTPUser = "" } - deluge := &Deluge{&http.Client{ - Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, - Jar: jar, - Timeout: config.Timeout, - }, config.URL, config.HTTPUser, 0} + deluge := &Deluge{ + URL: config.URL, + auth: config.HTTPUser, + Backends: make(map[string]Backend, 0), + Client: &http.Client{ + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: config.VerifySSL}}, + Jar: jar, + Timeout: config.Timeout.Round(time.Millisecond), + }, + } + if err := deluge.Login(config.Password); err != nil { + return deluge, err + } + if deluge.Version = config.Version; deluge.Version == "" { + if err := deluge.setVersion(); err != nil { + return deluge, err + } + } + return deluge, nil +} +// Login sets the cookie jar with authentication information. +func (d *Deluge) Login(password string) error { // This []string{config.Password} line is how you send auth creds. It's weird. - if req, err := deluge.DelReq(AuthLogin, []string{config.Password}); err != nil { - return nil, errors.Wrap(err, "DelReq(LoginPath, json)") - } else if resp, err := deluge.Do(req); err != nil { - return nil, errors.Wrap(err, "d.Do(req)") - } else if resp.StatusCode != http.StatusOK { - return nil, errors.Errorf("authentication failed: %v[%v] (status: %v/%v)", - config.URL, AuthLogin, resp.StatusCode, resp.Status) + req, err := d.DelReq(AuthLogin, []string{password}) + if err != nil { + return errors.Wrap(err, "DelReq(AuthLogin, json)") } - return deluge, nil + resp, err := d.Do(req) + if err != nil { + return errors.Wrap(err, "d.Do(req)") + } + defer func() { + _, _ = io.Copy(ioutil.Discard, resp.Body) + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK { + return errors.Errorf("authentication failed: %v[%v] (status: %v/%v)", + req.URL.String(), AuthLogin, resp.StatusCode, resp.Status) + } + return nil +} + +// setVersion digs into the first server in the web UI to find the version. +// This is currently unused in this libyrar, and provided for display only. +func (d *Deluge) setVersion() error { + response, err := d.Get(GeHosts, []string{}) + if err != nil { + return err + } + // This method returns a "mixed list" which requires an interface. + // Deluge devs apparently hate Go. :( + servers := make([][]interface{}, 0) + if err := json.Unmarshal(response.Result, &servers); err != nil { + d.logPayload(response.Result) + return errors.Wrap(err, "json.Unmarshal(rawResult1)") + } + + // Store each server info (so consumers can access them easily). + serverID := "" + for _, server := range servers { + serverID = server[0].(string) + d.Backends[serverID] = Backend{ + ID: serverID, + Addr: server[1].(string) + ":" + strconv.FormatFloat(server[2].(float64), 'f', 0, 64), + Prot: server[3].(string), + } + } + + // Store the last server's version as "the version" + response, err = d.Get(HostStatus, []string{serverID}) + if err != nil { + return err + } + server := make([]string, 0) + if err = json.Unmarshal(response.Result, &server); err != nil { + d.logPayload(response.Result) + return errors.Wrap(err, "json.Unmarshal(rawResult2)") + } + if len(server) != 3 { + return errors.Errorf("invalid data returned while checking version") + } + d.Version = server[2] + return nil } // DelReq is a small helper function that adds headers and marshals the json. @@ -82,6 +156,24 @@ func (d Deluge) GetXfers() (map[string]*XferStatus, error) { if response, err := d.Get(GetAllTorrents, []string{"", ""}); err != nil { return xfers, errors.Wrap(err, "get(GetAllTorrents)") } else if err := json.Unmarshal(response.Result, &xfers); err != nil { + d.logPayload(response.Result) + return xfers, errors.Wrap(err, "json.Unmarshal(xfers)") + } + return xfers, nil +} + +// GetXfersCompat gets all the Transfers from Deluge 1.x or 2.x. +// Depend on what you're actually trying to do, this is likely the best method to use. +// This will return a combined struct hat has data for Deluge 1 and Deluge 2. +// All of the data for either version will be made available with this method. +func (d Deluge) GetXfersCompat() (map[string]*XferStatusCompat, error) { + xfers := make(map[string]*XferStatusCompat) + response, err := d.Get(GetAllTorrents, []string{"", ""}) + if err != nil { + return xfers, errors.Wrap(err, "get(GetAllTorrents)") + } + if err := json.Unmarshal(response.Result, &xfers); err != nil { + d.logPayload(response.Result) return xfers, errors.Wrap(err, "json.Unmarshal(xfers)") } return xfers, nil @@ -99,16 +191,35 @@ func (d Deluge) Get(method string, params interface{}) (*Response, error) { return response, errors.Wrap(err, "d.Do") } defer func() { - if err := resp.Body.Close(); err != nil { - // boo. - } + _ = resp.Body.Close() }() - if body, err := ioutil.ReadAll(resp.Body); err != nil { + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { return response, errors.Wrap(err, "ioutil.ReadAll") - } else if err = json.Unmarshal(body, &response); err != nil { + } + if err = json.Unmarshal(body, &response); err != nil { + d.logPayload(response.Result) return response, errors.Wrap(err, "json.Unmarshal(response)") - } else if response.Error.Code != 0 { + } + if response.Error.Code != 0 { return response, errors.New("deluge error: " + response.Error.Message) } return response, nil } + +// Log logs a debug message. +func (d *Deluge) Log(msg string, fmt ...interface{}) { + if d.DebugLog != nil { + d.DebugLog(msg, fmt...) + } +} + +// logPayload writes a json payload to output. Used for debugging API data. +func (d *Deluge) logPayload(result json.RawMessage) { + out, err := result.MarshalJSON() + d.Log("Failed Payload:\n%s\n", string(out)) + if err != nil { + d.Log("Payload Marshal Error: %v", err) + } +} From c33319d256f178c6f2872f7b0a63f3a3eae07129 Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Sun, 30 Jun 2019 22:50:14 -0700 Subject: [PATCH 2/2] Pass debug in from config. --- config.go | 2 ++ deluge.go | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/config.go b/config.go index a45298b..a189e0d 100644 --- a/config.go +++ b/config.go @@ -26,6 +26,8 @@ type Config struct { Timeout Duration `json:"timeout" toml:"timeout" xml:"timeout" yaml:"timeout"` VerifySSL bool `json:"verify_ssl" toml:"verify_ssl" xml:"verify_ssl" yaml:"verify_ssl"` Version string `json:"version" toml:"version" xml:"version" yaml:"version"` + + DebugLog func(msg string, fmt ...interface{}) `json:"-" toml:"-" xml:"-" yaml:"-"` } // Duration is used to UnmarshalTOML into a time.Duration value. diff --git a/deluge.go b/deluge.go index 8cbefb1..b9e7f83 100644 --- a/deluge.go +++ b/deluge.go @@ -51,6 +51,7 @@ func New(config Config) (*Deluge, error) { URL: config.URL, auth: config.HTTPUser, Backends: make(map[string]Backend, 0), + DebugLog: config.DebugLog, Client: &http.Client{ Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: config.VerifySSL}}, Jar: jar, @@ -121,15 +122,17 @@ func (d *Deluge) setVersion() error { if err != nil { return err } - server := make([]string, 0) + server := make([]interface{}, 0) if err = json.Unmarshal(response.Result, &server); err != nil { d.logPayload(response.Result) return errors.Wrap(err, "json.Unmarshal(rawResult2)") } - if len(server) != 3 { + if len(server) < 3 { + d.logPayload(response.Result) return errors.Errorf("invalid data returned while checking version") } - d.Version = server[2] + // Version comes last in the mixed list. + d.Version = server[len(server)-1].(string) return nil }