├── xmlrpc ├── client_test.go ├── client.go └── marshaller.go ├── rtorrent ├── testdata │ └── Fedora-i3-Live-x86_64-35.torrent ├── rtorrent_test.go └── rtorrent.go ├── README.md ├── test.sh ├── go.mod ├── .gitignore ├── .circleci └── config.yml ├── LICENSE ├── go.sum └── main.go /xmlrpc/client_test.go: -------------------------------------------------------------------------------- 1 | package xmlrpc 2 | 3 | // TODO: add tests 4 | -------------------------------------------------------------------------------- /rtorrent/testdata/Fedora-i3-Live-x86_64-35.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrobinsn/go-rtorrent/HEAD/rtorrent/testdata/Fedora-i3-Live-x86_64-35.torrent -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED 2 | 3 | This repository is deprecated and is no longer maintained. 4 | 5 | Consider using https://github.com/autobrr/go-rtorrent instead which is an active fork. 6 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker rm -f rutorrent 4 | rm -rf tmp 5 | mkdir tmp 6 | docker run -d --name=rutorrent -p 8080:8080 -p 8000:8000 crazymax/rtorrent-rutorrent:latest 7 | sleep 60 8 | go test -v -race ./... 9 | 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mrobinsn/go-rtorrent 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect 7 | github.com/pkg/errors v0.9.1 8 | github.com/stretchr/testify v1.3.0 9 | github.com/urfave/cli v1.22.5 10 | ) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | go-rtorrent 27 | tmp 28 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | docker: 5 | - image: cimg/go:1.17 6 | - image: crazymax/rtorrent-rutorrent:latest 7 | steps: 8 | - checkout 9 | - run: 10 | name: Install Goveralls 11 | command: go install github.com/mattn/goveralls@latest 12 | - run: 13 | name: Wait for rutorrent 14 | command: dockerize -wait tcp://localhost:8080 -timeout 1m && dockerize -wait tcp://localhost:8000 -timeout 1m 15 | - run: 16 | name: Go vet 17 | command: go vet ./... 18 | - run: 19 | name: Run tests 20 | command: goveralls -race -v -show -service=circle-ci -repotoken=$COVERALLS_TOKEN 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Michael Robinson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /xmlrpc/client.go: -------------------------------------------------------------------------------- 1 | package xmlrpc 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "net/http" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | // Client implements a basic XMLRPC client 12 | type Client struct { 13 | addr string 14 | httpClient *http.Client 15 | } 16 | 17 | // NewClient returns a new instance of Client 18 | // Pass in a true value for `insecure` to turn off certificate verification 19 | func NewClient(addr string, insecure bool) *Client { 20 | transport := &http.Transport{} 21 | if insecure { 22 | transport = &http.Transport{ 23 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 24 | } 25 | } 26 | 27 | httpClient := &http.Client{Transport: transport} 28 | 29 | return &Client{ 30 | addr: addr, 31 | httpClient: httpClient, 32 | } 33 | } 34 | 35 | // NewClientWithHTTPClient returns a new instance of Client. 36 | // This allows you to use a custom http.Client setup for your needs. 37 | func NewClientWithHTTPClient(addr string, client *http.Client) *Client { 38 | return &Client{ 39 | addr: addr, 40 | httpClient: client, 41 | } 42 | } 43 | 44 | // Call calls the method with "name" with the given args 45 | // Returns the result, and an error for communication errors 46 | func (c *Client) Call(name string, args ...interface{}) (interface{}, error) { 47 | req := bytes.NewBuffer(nil) 48 | if err := Marshal(req, name, args...); err != nil { 49 | return nil, errors.Wrap(err, "failed to marshal request") 50 | } 51 | resp, err := c.httpClient.Post(c.addr, "text/xml", req) 52 | if err != nil { 53 | return nil, errors.Wrap(err, "POST failed") 54 | } 55 | defer resp.Body.Close() 56 | 57 | _, val, fault, err := Unmarshal(resp.Body) 58 | if fault != nil { 59 | err = errors.Errorf("Error: %v: %v", err, fault) 60 | } 61 | return val, err 62 | } 63 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 5 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 8 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 12 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 13 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 14 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 15 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 16 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 17 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 18 | github.com/urfave/cli v1.22.5 h1:lNq9sAHXK2qfdI8W+GRItjCEkI+2oR4d+MEHy1CKXoU= 19 | github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 20 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 21 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 22 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/mrobinsn/go-rtorrent/rtorrent" 8 | "github.com/pkg/errors" 9 | "github.com/urfave/cli" 10 | ) 11 | 12 | var ( 13 | name = "rTorrent XMLRPC CLI" 14 | version = "1.0.0" 15 | app = initApp() 16 | conn *rtorrent.RTorrent 17 | 18 | endpoint string 19 | view string 20 | hash string 21 | disableCertCheck bool 22 | ) 23 | 24 | func initApp() *cli.App { 25 | nApp := cli.NewApp() 26 | 27 | nApp.Name = name 28 | nApp.Version = version 29 | nApp.Authors = []cli.Author{ 30 | {Name: "Michael Robinson", Email: "m@michaelrobinson.io"}, 31 | } 32 | 33 | // Global flags 34 | nApp.Flags = []cli.Flag{ 35 | cli.StringFlag{ 36 | Name: "endpoint", 37 | Usage: "rTorrent endpoint", 38 | Value: "http://myrtorrent/RPC2", 39 | Destination: &endpoint, 40 | }, 41 | cli.BoolFlag{ 42 | Name: "disable-cert-check", 43 | Usage: "disable certificate checking on this endpoint, useful for testing", 44 | Destination: &disableCertCheck, 45 | }, 46 | } 47 | 48 | nApp.Before = setupConnection 49 | 50 | nApp.Commands = []cli.Command{{ 51 | Name: "get-ip", 52 | Usage: "retrieves the IP for this rTorrent instance", 53 | Action: getIP, 54 | }, { 55 | Name: "get-name", 56 | Usage: "retrieves the name for this rTorrent instance", 57 | Action: getName, 58 | }, { 59 | Name: "get-totals", 60 | Usage: "retrieves the up/down totals for this rTorrent instance", 61 | Action: getTotals, 62 | }, { 63 | Name: "get-torrents", 64 | Usage: "retrieves the torrents from this rTorrent instance", 65 | Action: getTorrents, 66 | Flags: []cli.Flag{ 67 | cli.StringFlag{ 68 | Name: "view", 69 | Usage: "view to use, known values: main, started, stopped, hashing, seeding", 70 | Value: string(rtorrent.ViewMain), 71 | Destination: &view, 72 | }, 73 | }, 74 | }, { 75 | Name: "get-files", 76 | Usage: "retrieves the files for a specific torrent", 77 | Action: getFiles, 78 | Flags: []cli.Flag{ 79 | cli.StringFlag{ 80 | Name: "hash", 81 | Usage: "hash of the torrent", 82 | Value: "unknown", 83 | Destination: &hash, 84 | }, 85 | }, 86 | }, 87 | } 88 | 89 | return nApp 90 | } 91 | 92 | func main() { 93 | if err := app.Run(os.Args); err != nil { 94 | fmt.Println(err) 95 | } 96 | } 97 | 98 | func setupConnection(c *cli.Context) error { 99 | if endpoint == "" { 100 | return errors.New("endpoint must be specified") 101 | } 102 | conn = rtorrent.New(endpoint, disableCertCheck) 103 | return nil 104 | } 105 | 106 | func getIP(c *cli.Context) error { 107 | ip, err := conn.IP() 108 | if err != nil { 109 | return errors.Wrap(err, "failed to get rTorrent IP") 110 | } 111 | fmt.Println(ip) 112 | return nil 113 | } 114 | 115 | func getName(c *cli.Context) error { 116 | name, err := conn.Name() 117 | if err != nil { 118 | return errors.Wrap(err, "failed to get rTorrent name") 119 | } 120 | fmt.Println(name) 121 | return nil 122 | } 123 | 124 | func getTotals(c *cli.Context) error { 125 | // Get Down Total 126 | downTotal, err := conn.DownTotal() 127 | if err != nil { 128 | return errors.Wrap(err, "failed to get rTorrent down total") 129 | } 130 | fmt.Printf("%d\n", downTotal) 131 | 132 | // Get Up Total 133 | upTotal, err := conn.UpTotal() 134 | if err != nil { 135 | return errors.Wrap(err, "failed to get rTorrent up total") 136 | } 137 | fmt.Printf("%d\n", upTotal) 138 | return nil 139 | } 140 | 141 | func getTorrents(c *cli.Context) error { 142 | torrents, err := conn.GetTorrents(rtorrent.View(view)) 143 | if err != nil { 144 | return errors.Wrap(err, "failed to get torrents") 145 | } 146 | for _, torrent := range torrents { 147 | fmt.Println(torrent.Pretty()) 148 | } 149 | return nil 150 | } 151 | 152 | func getFiles(c *cli.Context) error { 153 | files, err := conn.GetFiles(rtorrent.Torrent{Hash: hash}) 154 | if err != nil { 155 | return errors.Wrap(err, "failed to get files") 156 | } 157 | for _, file := range files { 158 | fmt.Println(file.Pretty()) 159 | } 160 | return nil 161 | } 162 | -------------------------------------------------------------------------------- /xmlrpc/marshaller.go: -------------------------------------------------------------------------------- 1 | package xmlrpc 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/xml" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "reflect" 11 | "strconv" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | /// ISO8601 is not very much restrictive, so many combinations exist 17 | const ( 18 | // FullXMLRpcTime is the format of a full XML-RPC time 19 | FullXMLRpcTime = "2006-01-02T15:04:05-07:00" 20 | // LocalXMLRpcTime is the XML-RPC time without timezone 21 | LocalXMLRpcTime = "2006-01-02T15:04:05" 22 | // DenseXMLRpcTime is a dense-formatted local time 23 | DenseXMLRpcTime = "20060102T15:04:05" 24 | // DummyXMLRpcTime is seen in the wild 25 | DummyXMLRpcTime = "20060102T15:04:05-0700" 26 | ) 27 | 28 | // ErrUnsupported is the error of "Unsupported type" 29 | var ErrUnsupported = errors.New("Unsupported type") 30 | 31 | // Fault is the struct for the fault response 32 | type Fault struct { 33 | Code int 34 | Message string 35 | } 36 | 37 | func (f Fault) String() string { 38 | return fmt.Sprintf("%d: %s", f.Code, f.Message) 39 | } 40 | func (f Fault) Error() string { 41 | return f.String() 42 | } 43 | 44 | // WriteXML writes the XML representation of the fault into the Writer 45 | func (f Fault) WriteXML(w io.Writer) (int, error) { 46 | return fmt.Fprintf(w, ` 47 | faultCode%d 48 | faultString%s 49 | `, f.Code, xmlEscape(f.Message)) 50 | } 51 | 52 | var xmlSpecial = map[byte]string{ 53 | '<': "<", 54 | '>': ">", 55 | '"': """, 56 | '\'': "'", 57 | '&': "&", 58 | } 59 | 60 | func xmlEscape(s string) string { 61 | var b bytes.Buffer 62 | for i := 0; i < len(s); i++ { 63 | c := s[i] 64 | if s, ok := xmlSpecial[c]; ok { 65 | b.WriteString(s) 66 | } else { 67 | b.WriteByte(c) 68 | } 69 | } 70 | return b.String() 71 | } 72 | 73 | type valueNode struct { 74 | Type string `xml:",attr"` 75 | Body string `xml:",chardata"` 76 | } 77 | 78 | type state struct { 79 | p *xml.Decoder 80 | level int 81 | remainder *interface{} 82 | last *xml.Token 83 | } 84 | 85 | func newParser(p *xml.Decoder) *state { 86 | return &state{p, 0, nil, nil} 87 | } 88 | 89 | const ( 90 | tokStart = iota 91 | tokText 92 | tokStop 93 | ) 94 | 95 | var ( 96 | errNotStartElement = errors.New("not start element") 97 | errNameMismatch = errors.New("not the required token") 98 | errNotEndElement = errors.New("not end element") 99 | ) 100 | 101 | func (st *state) parseValue() (nv interface{}, e error) { 102 | var se xml.StartElement 103 | if se, e = st.getStart(""); e != nil { 104 | if ErrEq(e, errNotStartElement) { 105 | e = nil 106 | } 107 | return 108 | } 109 | 110 | var vn valueNode 111 | switch se.Name.Local { 112 | case "value": 113 | if nv, e = st.parseValue(); e == nil { 114 | e = st.checkLast("value") 115 | } 116 | return 117 | case "boolean", "string", "int", "i1", "i2", "i4", "i8", "double", "dateTime.iso8601", "base64": //simple 118 | st.last = nil 119 | if e = st.p.DecodeElement(&vn, &se); e != nil { 120 | return 121 | } 122 | 123 | switch se.Name.Local { 124 | case "boolean": 125 | nv, e = strconv.ParseBool(vn.Body) 126 | case "string": 127 | nv = vn.Body 128 | case "int", "i1", "i2", "i4": 129 | var i64 int64 130 | i64, e = strconv.ParseInt(vn.Body, 10, 32) 131 | nv = int(i64) 132 | case "i8": 133 | var i64 int64 134 | i64, e = strconv.ParseInt(vn.Body, 10, 64) 135 | nv = int(i64) 136 | case "double": 137 | nv, e = strconv.ParseFloat(vn.Body, 64) 138 | case "dateTime.iso8601": 139 | for _, format := range []string{FullXMLRpcTime, LocalXMLRpcTime, DenseXMLRpcTime, DummyXMLRpcTime} { 140 | nv, e = time.Parse(format, vn.Body) 141 | if e == nil { 142 | break 143 | } 144 | } 145 | case "base64": 146 | nv, e = base64.StdEncoding.DecodeString(vn.Body) 147 | } 148 | return 149 | 150 | case "struct": 151 | var name string 152 | values := make(map[string]interface{}, 4) 153 | nv = values 154 | for { 155 | if se, e = st.getStart("member"); e != nil { 156 | if ErrEq(e, errNotStartElement) { 157 | e = st.checkLast("struct") 158 | break 159 | } 160 | return 161 | } 162 | if name, e = st.getText("name"); e != nil { 163 | return 164 | } 165 | if se, e = st.getStart("value"); e != nil { 166 | return 167 | } 168 | if values[name], e = st.parseValue(); e != nil { 169 | return 170 | } 171 | if e = st.checkLast("value"); e != nil { 172 | return 173 | } 174 | if e = st.checkLast("member"); e != nil { 175 | return 176 | } 177 | } 178 | return 179 | 180 | case "array": 181 | values := make([]interface{}, 0, 4) 182 | var val interface{} 183 | if _, e = st.getStart("data"); e != nil { 184 | return 185 | } 186 | for { 187 | if se, e = st.getStart("value"); e != nil { 188 | if ErrEq(e, errNotStartElement) { 189 | e = nil //st.checkLast("data") 190 | break 191 | } 192 | return 193 | } 194 | if val, e = st.parseValue(); e != nil { 195 | return 196 | } 197 | values = append(values, val) 198 | if e = st.checkLast("value"); e != nil { 199 | return 200 | } 201 | } 202 | if e = st.checkLast("data"); e == nil { 203 | e = st.checkLast("array") 204 | } 205 | nv = values 206 | return 207 | default: 208 | e = fmt.Errorf("cannot parse unknown tag %s", se) 209 | } 210 | return 211 | } 212 | 213 | func (st *state) token(typ int, name string) (t xml.Token, body string, e error) { 214 | // var ok bool 215 | if st.last != nil { 216 | t = *st.last 217 | st.last = nil 218 | } 219 | Reading: 220 | for { 221 | if t != nil { 222 | switch t.(type) { 223 | case xml.StartElement: 224 | se := t.(xml.StartElement) 225 | if se.Name.Local != "" { 226 | break Reading 227 | } 228 | case xml.EndElement: 229 | ee := t.(xml.EndElement) 230 | if ee.Name.Local != "" { 231 | break Reading 232 | } 233 | default: 234 | } 235 | } 236 | if t, e = st.p.Token(); e != nil { 237 | return 238 | } 239 | if t == nil { 240 | e = errors.New("nil token") 241 | return 242 | } 243 | } 244 | switch typ { 245 | case tokStart, tokText: 246 | se, ok := t.(xml.StartElement) 247 | if !ok { 248 | st.last = &t 249 | e = Errorf2(errNotStartElement, "required startelement(%s), found %s %T", name, t, t) 250 | return 251 | } 252 | switch typ { 253 | case tokStart: 254 | if name != "" && se.Name.Local != name { 255 | e = Errorf2(errNameMismatch, "required <%s>, found <%s>", name, se.Name.Local) 256 | return 257 | } 258 | default: 259 | var vn valueNode 260 | if e = st.p.DecodeElement(&vn, &se); e != nil { 261 | return 262 | } 263 | body = vn.Body 264 | } 265 | default: 266 | ee, ok := t.(xml.EndElement) 267 | if !ok { 268 | st.last = &t 269 | e = Errorf2(errNotEndElement, "required endelement(%s), found %s %T", name, t, t) 270 | return 271 | } 272 | if name != "" && ee.Name.Local != name { 273 | e = Errorf2(errNameMismatch, "required , found ", name, ee.Name.Local) 274 | return 275 | } 276 | } 277 | return 278 | } 279 | 280 | func (st *state) getStart(name string) (se xml.StartElement, e error) { 281 | var t xml.Token 282 | t, _, e = st.token(tokStart, name) 283 | se, _ = t.(xml.StartElement) 284 | if e != nil { 285 | return 286 | } 287 | se = t.(xml.StartElement) 288 | return 289 | } 290 | 291 | func (st *state) getText(name string) (text string, e error) { 292 | _, text, e = st.token(tokText, name) 293 | return 294 | } 295 | 296 | func (st *state) checkLast(name string) (e error) { 297 | _, _, e = st.token(tokStop, name) 298 | return 299 | } 300 | 301 | func toXML(v interface{}, typ bool) (s string) { 302 | r := reflect.ValueOf(v) 303 | t := r.Type() 304 | k := t.Kind() 305 | 306 | if b, ok := v.([]byte); ok { 307 | return "" + base64.StdEncoding.EncodeToString(b) + "" 308 | } 309 | 310 | switch k { 311 | case reflect.Invalid: 312 | panic("Unsupported type") 313 | case reflect.Bool: 314 | return fmt.Sprintf("%v", v) 315 | case reflect.Int, 316 | reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, 317 | reflect.Uint, 318 | reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: 319 | if typ { 320 | return fmt.Sprintf("%v", v) 321 | } 322 | return fmt.Sprintf("%v", v) 323 | case reflect.Uintptr: 324 | panic("Unsupported type") 325 | case reflect.Float32, reflect.Float64: 326 | if typ { 327 | return fmt.Sprintf("%v", v) 328 | } 329 | return fmt.Sprintf("%v", v) 330 | case reflect.Complex64, reflect.Complex128: 331 | panic("Unsupported type") 332 | case reflect.Array, reflect.Slice: 333 | s = "" 334 | for n := 0; n < r.Len(); n++ { 335 | s += "" 336 | s += toXML(r.Index(n).Interface(), typ) 337 | s += "" 338 | } 339 | s += "" 340 | return s 341 | case reflect.Chan: 342 | panic("Unsupported type") 343 | case reflect.Func: 344 | panic("Unsupported type") 345 | case reflect.Interface: 346 | return toXML(r.Elem(), typ) 347 | case reflect.Map: 348 | s = "" 349 | for _, key := range r.MapKeys() { 350 | s += "" 351 | s += "" + xmlEscape(key.Interface().(string)) + "" 352 | s += "" + toXML(r.MapIndex(key).Interface(), typ) + "" 353 | s += "" 354 | } 355 | return s + "" 356 | case reflect.Ptr: 357 | panic("Unsupported type") 358 | case reflect.String: 359 | if typ { 360 | return fmt.Sprintf("%v", xmlEscape(v.(string))) 361 | } 362 | return xmlEscape(v.(string)) 363 | case reflect.Struct: 364 | s = "" 365 | for n := 0; n < r.NumField(); n++ { 366 | s += "" 367 | s += "" + t.Field(n).Name + "" 368 | s += "" + toXML(r.FieldByIndex([]int{n}).Interface(), true) + "" 369 | s += "" 370 | } 371 | return s + "" 372 | case reflect.UnsafePointer: 373 | return toXML(r.Elem(), typ) 374 | } 375 | return 376 | } 377 | 378 | // WriteXML writes v, typed if typ is true, into w Writer 379 | func WriteXML(w io.Writer, v interface{}, typ bool) (err error) { 380 | var ( 381 | r reflect.Value 382 | ok bool 383 | ) 384 | // go back from reflect.Value, if needed. 385 | if r, ok = v.(reflect.Value); !ok { 386 | r = reflect.ValueOf(v) 387 | } else { 388 | v = r.Interface() 389 | } 390 | if fp, ok := getFault(v); ok { 391 | _, err = fp.WriteXML(w) 392 | return 393 | } 394 | if b, ok := v.([]byte); ok { 395 | length := base64.StdEncoding.EncodedLen(len(b)) 396 | dst := make([]byte, length) 397 | base64.StdEncoding.Encode(dst, b) 398 | _, err = taggedWrite(w, []byte("base64"), dst) 399 | return 400 | } 401 | if tim, ok := v.(time.Time); ok { 402 | _, err = taggedWriteString(w, "dateTime.iso8601", tim.Format(FullXMLRpcTime)) 403 | return 404 | } 405 | t := r.Type() 406 | k := t.Kind() 407 | 408 | switch k { 409 | case reflect.Invalid, reflect.Uintptr, reflect.Complex64, reflect.Complex128, reflect.Chan, reflect.Func: 410 | return Errorf2(ErrUnsupported, "v=%#v t=%v k=%s", v, t, k) 411 | case reflect.Bool: 412 | _, err = fmt.Fprintf(w, "%v", v) 413 | return err 414 | case reflect.Int, 415 | reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, 416 | reflect.Uint, 417 | reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: 418 | if typ { 419 | _, err = fmt.Fprintf(w, "%v", v) 420 | return err 421 | } 422 | _, err = fmt.Fprintf(w, "%v", v) 423 | return err 424 | case reflect.Float32, reflect.Float64: 425 | if typ { 426 | _, err = fmt.Fprintf(w, "%v", v) 427 | return err 428 | } 429 | _, err = fmt.Fprintf(w, "%v", v) 430 | return err 431 | case reflect.Array, reflect.Slice: 432 | if _, err = io.WriteString(w, "\n"); err != nil { 433 | return 434 | } 435 | n := r.Len() 436 | for i := 0; i < n; i++ { 437 | if _, err = io.WriteString(w, " "); err != nil { 438 | return 439 | } 440 | if err = WriteXML(w, r.Index(i).Interface(), typ); err != nil { 441 | return 442 | } 443 | if _, err = io.WriteString(w, "\n"); err != nil { 444 | return 445 | } 446 | } 447 | if _, err = io.WriteString(w, "\n"); err != nil { 448 | return 449 | } 450 | case reflect.Interface: 451 | return WriteXML(w, r.Elem(), typ) 452 | case reflect.Map: 453 | if _, err = io.WriteString(w, "\n"); err != nil { 454 | return 455 | } 456 | for _, key := range r.MapKeys() { 457 | if _, err = io.WriteString(w, " "); err != nil { 458 | return 459 | } 460 | if _, err = io.WriteString(w, xmlEscape(key.Interface().(string))); err != nil { 461 | return 462 | } 463 | if _, err = io.WriteString(w, ""); err != nil { 464 | return 465 | } 466 | if err = WriteXML(w, r.MapIndex(key).Interface(), typ); err != nil { 467 | return 468 | } 469 | if _, err = io.WriteString(w, "\n"); err != nil { 470 | return 471 | } 472 | } 473 | _, err = io.WriteString(w, "") 474 | return 475 | case reflect.Ptr: 476 | return WriteXML(w, reflect.Indirect(r), typ) 477 | case reflect.String: 478 | if typ { 479 | _, err = fmt.Fprintf(w, "%v", xmlEscape(v.(string))) 480 | return 481 | } 482 | _, err = io.WriteString(w, xmlEscape(v.(string))) 483 | return 484 | case reflect.Struct: 485 | if _, err = io.WriteString(w, ""); err != nil { 486 | return 487 | } 488 | n := r.NumField() 489 | for i := 0; i < n; i++ { 490 | c := t.Field(i).Name[:1] 491 | if strings.ToLower(c) == c { //have to skip unexported fields 492 | continue 493 | } 494 | if _, err = io.WriteString(w, ""); err != nil { 495 | return 496 | } 497 | if _, err = io.WriteString(w, xmlEscape(getStructFieldName(t.Field(i)))); err != nil { 498 | return 499 | } 500 | if _, err = io.WriteString(w, ""); err != nil { 501 | return 502 | } 503 | if err = WriteXML(w, r.Field(i).Interface(), true); err != nil { 504 | return 505 | } 506 | if _, err = io.WriteString(w, ""); err != nil { 507 | return err 508 | } 509 | } 510 | _, err = io.WriteString(w, "") 511 | return 512 | case reflect.UnsafePointer: 513 | return WriteXML(w, r.Elem(), typ) 514 | } 515 | return 516 | } 517 | 518 | func taggedWrite(w io.Writer, tag, inner []byte) (n int, err error) { 519 | var j int 520 | for _, elt := range [][]byte{[]byte("<"), tag, []byte(">"), inner, 521 | []byte("")} { 522 | j, err = w.Write(elt) 523 | n += j 524 | if err != nil { 525 | return 526 | } 527 | } 528 | return 529 | } 530 | func taggedWriteString(w io.Writer, tag, inner string) (n int, err error) { 531 | if n, err = io.WriteString(w, "<"+tag+">"); err != nil { 532 | return 533 | } 534 | var j int 535 | j, err = io.WriteString(w, inner) 536 | n += j 537 | if err != nil { 538 | return 539 | } 540 | j, err = io.WriteString(w, "") 541 | n += j 542 | return 543 | } 544 | 545 | func getStructFieldName(sf reflect.StructField) string { 546 | if sf.Tag.Get("xml") == "" { 547 | return sf.Name 548 | } 549 | return sf.Tag.Get("xml") 550 | } 551 | 552 | // Marshal marshals the named thing (methodResponse if name == "", otherwise a methodCall) 553 | // into the w Writer 554 | func Marshal(w io.Writer, name string, args ...interface{}) (err error) { 555 | if name == "" { 556 | if _, err = io.WriteString(w, ""); err != nil { 557 | return 558 | } 559 | if len(args) > 0 { 560 | fp, ok := getFault(args[0]) 561 | if ok { 562 | _, err = fp.WriteXML(w) 563 | if err == nil { 564 | _, err = io.WriteString(w, "\n") 565 | } 566 | return 567 | } 568 | } 569 | } else { 570 | if _, err = io.WriteString(w, ""); err != nil { 571 | return 572 | } 573 | if _, err = io.WriteString(w, xmlEscape(name)); err != nil { 574 | return 575 | } 576 | if _, err = io.WriteString(w, "\n"); err != nil { 577 | return 578 | } 579 | } 580 | if _, err = io.WriteString(w, "\n"); err != nil { 581 | return 582 | } 583 | for _, arg := range args { 584 | if _, err = io.WriteString(w, " "); err != nil { 585 | return 586 | } 587 | if err = WriteXML(w, arg, true); err != nil { 588 | return 589 | } 590 | if _, err = io.WriteString(w, "\n"); err != nil { 591 | return 592 | } 593 | } 594 | if name == "" { 595 | _, err = io.WriteString(w, "") 596 | } else { 597 | _, err = io.WriteString(w, "") 598 | } 599 | return err 600 | } 601 | 602 | func getFault(v interface{}) (*Fault, bool) { 603 | if f, ok := v.(Fault); ok { 604 | return &f, true 605 | } 606 | if f, ok := v.(*Fault); ok { 607 | if f != nil { 608 | return f, true 609 | } 610 | } else { 611 | if e, ok := v.(error); ok { 612 | return &Fault{Code: -1, Message: e.Error()}, true 613 | } 614 | } 615 | return nil, false 616 | } 617 | 618 | // Unmarshal unmarshals the thing (methodResponse, methodCall or fault), 619 | // returns the name of the method call in the first return argument; 620 | // the params of the call or the response 621 | // or the Fault if this is a Fault 622 | func Unmarshal(r io.Reader) (name string, params []interface{}, fault *Fault, e error) { 623 | p := xml.NewDecoder(r) 624 | st := newParser(p) 625 | typ := "methodResponse" 626 | if _, e = st.getStart(typ); ErrEq(e, errNameMismatch) { // methodResponse or methodCall 627 | typ = "methodCall" 628 | if name, e = st.getText("methodName"); e != nil { 629 | return 630 | } 631 | } 632 | var se xml.StartElement 633 | if se, e = st.getStart("params"); e != nil { 634 | if ErrEq(e, errNameMismatch) && se.Name.Local == "fault" { 635 | var v interface{} 636 | if v, e = st.parseValue(); e != nil { 637 | return 638 | } 639 | fmap, ok := v.(map[string]interface{}) 640 | if !ok { 641 | e = fmt.Errorf("fault not fault: %+v", v) 642 | return 643 | } 644 | fault = &Fault{Code: -1, Message: ""} 645 | code, ok := fmap["faultCode"] 646 | if !ok { 647 | e = fmt.Errorf("no faultCode in fault: %v", fmap) 648 | return 649 | } 650 | fcode, ok := code.(int) 651 | if !ok { 652 | e = fmt.Errorf("faultCode not int? %v", code) 653 | return 654 | } 655 | fault.Code = int(fcode) 656 | msg, ok := fmap["faultString"] 657 | if !ok { 658 | e = fmt.Errorf("no faultString in fault: %v", fmap) 659 | return 660 | } 661 | if fault.Message, ok = msg.(string); !ok { 662 | e = fmt.Errorf("faultString not strin? %v", msg) 663 | return 664 | } 665 | e = st.checkLast("fault") 666 | } 667 | return 668 | } 669 | params = make([]interface{}, 0, 8) 670 | var v interface{} 671 | for { 672 | if _, e = st.getStart("param"); e != nil { 673 | if ErrEq(e, errNotStartElement) { 674 | e = nil 675 | break 676 | } 677 | return 678 | } 679 | if v, e = st.parseValue(); e != nil { 680 | break 681 | } 682 | params = append(params, v) 683 | if e = st.checkLast("param"); e != nil { 684 | return 685 | } 686 | } 687 | if e = st.checkLast("params"); e == nil { 688 | e = st.checkLast(typ) 689 | } 690 | return 691 | } 692 | 693 | type errorStruct struct { 694 | main error 695 | message string 696 | } 697 | 698 | func (es errorStruct) Error() string { 699 | return es.main.Error() + " [" + es.message + "]" 700 | } 701 | 702 | // Errorf2 returns an error embedding the main error with the formatted message 703 | func Errorf2(err error, format string, a ...interface{}) error { 704 | return &errorStruct{main: err, message: fmt.Sprintf(format, a...)} 705 | } 706 | 707 | // ErrEq checks equality of the errorStructs (equality of the embedded main errors 708 | func ErrEq(a, b error) bool { 709 | var maina, mainb error = a, b 710 | if esa, ok := a.(errorStruct); ok { 711 | maina = esa.main 712 | } else if esa, ok := a.(*errorStruct); ok { 713 | maina = esa.main 714 | } 715 | if esb, ok := b.(errorStruct); ok { 716 | mainb = esb.main 717 | } else if esb, ok := b.(*errorStruct); ok { 718 | mainb = esb.main 719 | } 720 | return maina == mainb 721 | } 722 | -------------------------------------------------------------------------------- /rtorrent/rtorrent_test.go: -------------------------------------------------------------------------------- 1 | package rtorrent 2 | 3 | import ( 4 | "io/ioutil" 5 | "testing" 6 | "time" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestRTorrent(t *testing.T) { 13 | /* 14 | These tests rely on a local instance of rtorrent to be running in a clean state. 15 | Use the included `test.sh` script to run these tests. 16 | */ 17 | client := New("http://localhost:8000", false) 18 | maxRetries := 60 19 | 20 | t.Run("get ip", func(t *testing.T) { 21 | _, err := client.IP() 22 | require.NoError(t, err) 23 | // Don't assert anything about the response, differs based upon the environment 24 | }) 25 | 26 | t.Run("get name", func(t *testing.T) { 27 | name, err := client.Name() 28 | require.NoError(t, err) 29 | require.NotEmpty(t, name) 30 | }) 31 | 32 | t.Run("down total", func(t *testing.T) { 33 | total, err := client.DownTotal() 34 | require.NoError(t, err) 35 | require.Zero(t, total, "expected no data to be transferred yet") 36 | }) 37 | 38 | t.Run("up total", func(t *testing.T) { 39 | total, err := client.UpTotal() 40 | require.NoError(t, err) 41 | require.Zero(t, total, "expected no data to be transferred yet") 42 | }) 43 | 44 | t.Run("down rate", func(t *testing.T) { 45 | rate, err := client.DownRate() 46 | require.NoError(t, err) 47 | require.Zero(t, rate, "expected no download yet") 48 | }) 49 | 50 | t.Run("up rate", func(t *testing.T) { 51 | rate, err := client.UpRate() 52 | require.NoError(t, err) 53 | require.Zero(t, rate, "expected no upload yet") 54 | }) 55 | 56 | t.Run("get no torrents", func(t *testing.T) { 57 | torrents, err := client.GetTorrents(ViewMain) 58 | require.NoError(t, err) 59 | require.Empty(t, torrents, "expected no torrents to be added yet") 60 | }) 61 | 62 | t.Run("add", func(t *testing.T) { 63 | t.Run("by url", func(t *testing.T) { 64 | err := client.Add("https://torrent.fedoraproject.org/torrents/Fedora-i3-Live-x86_64-35.torrent") 65 | require.NoError(t, err) 66 | 67 | t.Run("get torrent", func(t *testing.T) { 68 | // It will take some time to appear, so retry a few times 69 | var torrents []Torrent 70 | var err error 71 | retries := maxRetries 72 | for i := 0; i <= retries; i++ { 73 | <-time.After(time.Second) 74 | torrents, err = client.GetTorrents(ViewMain) 75 | require.NoError(t, err) 76 | if len(torrents) > 0 { 77 | break 78 | } 79 | if i == retries { 80 | require.NoError(t, errors.Errorf("torrent did not show up in time")) 81 | } 82 | } 83 | require.NotEmpty(t, torrents) 84 | require.Len(t, torrents, 1) 85 | require.Equal(t, "299939CFF841ED7FFCA2B3C2A35711C12589632B", torrents[0].Hash) 86 | require.Equal(t, "Fedora-i3-Live-x86_64-35", torrents[0].Name) 87 | require.Equal(t, "", torrents[0].Label) 88 | require.Equal(t, 1437206706, torrents[0].Size) 89 | require.Equal(t, "/downloads/temp/Fedora-i3-Live-x86_64-35", torrents[0].Path) 90 | require.False(t, torrents[0].Completed) 91 | 92 | t.Run("get files", func(t *testing.T) { 93 | files, err := client.GetFiles(torrents[0]) 94 | require.NoError(t, err) 95 | require.NotEmpty(t, files) 96 | require.Len(t, files, 2) 97 | for _, f := range files { 98 | require.NotEmpty(t, f.Path) 99 | require.NotZero(t, f.Size) 100 | } 101 | }) 102 | 103 | t.Run("single get", func(t *testing.T) { 104 | torrent, err := client.GetTorrent(torrents[0].Hash) 105 | require.NoError(t, err) 106 | require.NotEmpty(t, torrent.Hash) 107 | require.NotEmpty(t, torrent.Name) 108 | require.NotEmpty(t, torrent.Path) 109 | require.NotEmpty(t, torrent.Size) 110 | }) 111 | 112 | t.Run("change label", func(t *testing.T) { 113 | err := client.SetLabel(torrents[0], "TestLabel") 114 | require.NoError(t, err) 115 | 116 | // It will take some time to change, so try a few times 117 | retries := maxRetries 118 | for i := 0; i <= retries; i++ { 119 | <-time.After(time.Second) 120 | torrents, err = client.GetTorrents(ViewMain) 121 | require.NoError(t, err) 122 | require.Len(t, torrents, 1) 123 | if torrents[0].Label != "" { 124 | break 125 | } 126 | if i == retries { 127 | require.NoError(t, errors.Errorf("torrent label did not change in time")) 128 | } 129 | } 130 | require.Equal(t, "TestLabel", torrents[0].Label) 131 | }) 132 | 133 | t.Run("get status", func(t *testing.T) { 134 | var status Status 135 | var err error 136 | // It may take some time for the download to start 137 | retries := maxRetries 138 | for i := 0; i <= retries; i++ { 139 | <-time.After(time.Second) 140 | status, err = client.GetStatus(torrents[0]) 141 | require.NoError(t, err) 142 | t.Logf("Status = %+v", status) 143 | if status.CompletedBytes > 0 { 144 | break 145 | } 146 | if i == retries { 147 | require.NoError(t, errors.Errorf("torrent did not start in time")) 148 | } 149 | } 150 | 151 | require.False(t, status.Completed) 152 | require.NotZero(t, status.CompletedBytes) 153 | require.NotZero(t, status.DownRate) 154 | require.NotZero(t, status.Size) 155 | // require.NotZero(t, status.UpRate) 156 | //require.NotZero(t, status.Ratio) 157 | }) 158 | 159 | t.Run("delete torrent", func(t *testing.T) { 160 | err := client.Delete(torrents[0]) 161 | require.NoError(t, err) 162 | 163 | torrents, err := client.GetTorrents(ViewMain) 164 | require.NoError(t, err) 165 | require.Empty(t, torrents) 166 | 167 | t.Run("get torrent", func(t *testing.T) { 168 | // It will take some time to disappear, so retry a few times 169 | var torrents []Torrent 170 | var err error 171 | retries := maxRetries 172 | for i := 0; i <= retries; i++ { 173 | <-time.After(time.Second) 174 | torrents, err = client.GetTorrents(ViewMain) 175 | require.NoError(t, err) 176 | if len(torrents) == 0 { 177 | break 178 | } 179 | if i == retries { 180 | require.NoError(t, errors.Errorf("torrent did not delete in time")) 181 | } 182 | } 183 | require.Empty(t, torrents) 184 | }) 185 | }) 186 | 187 | }) 188 | }) 189 | 190 | t.Run("by url (stopped)", func(t *testing.T) { 191 | label := DLabel.SetValue("test-label") 192 | err := client.AddStopped("https://torrent.fedoraproject.org/torrents/Fedora-i3-Live-x86_64-35.torrent", label) 193 | require.NoError(t, err) 194 | 195 | t.Run("get torrent", func(t *testing.T) { 196 | // It will take some time to appear, so retry a few times 197 | var torrents []Torrent 198 | var err error 199 | retries := maxRetries 200 | for i := 0; i <= retries; i++ { 201 | <-time.After(time.Second) 202 | torrents, err = client.GetTorrents(ViewStopped) 203 | require.NoError(t, err) 204 | if len(torrents) > 0 { 205 | break 206 | } 207 | if i == retries { 208 | require.NoError(t, errors.Errorf("torrent did not show up in time")) 209 | } 210 | } 211 | require.NotEmpty(t, torrents) 212 | require.Len(t, torrents, 1) 213 | require.Equal(t, "299939CFF841ED7FFCA2B3C2A35711C12589632B", torrents[0].Hash) 214 | require.Equal(t, "Fedora-i3-Live-x86_64-35", torrents[0].Name) 215 | require.Equal(t, label.Value, torrents[0].Label) 216 | require.Equal(t, 1437206706, torrents[0].Size) 217 | require.Equal(t, "/downloads/temp/Fedora-i3-Live-x86_64-35", torrents[0].Path) 218 | require.False(t, torrents[0].Completed) 219 | 220 | t.Run("get status", func(t *testing.T) { 221 | <-time.After(time.Second) 222 | status, err := client.GetStatus(torrents[0]) 223 | require.NoError(t, err) 224 | t.Logf("Status = %+v", status) 225 | 226 | require.False(t, status.Completed) 227 | require.Zero(t, status.CompletedBytes) 228 | require.Zero(t, status.DownRate) 229 | require.NotZero(t, status.Size) 230 | }) 231 | 232 | t.Run("start torrent", func(t *testing.T) { 233 | err = client.StartTorrent(torrents[0]) 234 | require.NoError(t, err) 235 | 236 | t.Run("check if started", func(t *testing.T) { 237 | var isOpen, isActive bool 238 | var state, retries int = 0, maxRetries 239 | for i := 0; i <= retries; i++ { 240 | <-time.After(time.Second) 241 | 242 | isOpen, err = client.IsOpen(torrents[0]) 243 | require.NoError(t, err) 244 | 245 | isActive, err = client.IsActive(torrents[0]) 246 | require.NoError(t, err) 247 | 248 | state, err = client.State(torrents[0]) 249 | require.NoError(t, err) 250 | 251 | if isOpen && isActive && state == 1 { 252 | break 253 | } 254 | 255 | if i == retries { 256 | require.NoError(t, errors.Errorf("torrent did not start in time")) 257 | } 258 | } 259 | 260 | require.True(t, isOpen) 261 | require.True(t, isActive) 262 | require.Equal(t, 1, state) 263 | }) 264 | 265 | // wait some seconds to properly start to download bytes so 266 | // to allow testing for up/down total post activity 267 | <-time.After(time.Second * 10) 268 | 269 | t.Run("pause torrent", func(t *testing.T) { 270 | err := client.PauseTorrent(torrents[0]) 271 | require.NoError(t, err) 272 | 273 | var isOpen, isActive bool 274 | var state, retries int = 0, maxRetries 275 | for i := 0; i <= retries; i++ { 276 | <-time.After(time.Second) 277 | isOpen, err = client.IsOpen(torrents[0]) 278 | require.NoError(t, err) 279 | 280 | isActive, err = client.IsActive(torrents[0]) 281 | require.NoError(t, err) 282 | 283 | state, err = client.State(torrents[0]) 284 | require.NoError(t, err) 285 | 286 | if isOpen && !isActive && state == 1 { 287 | break 288 | } 289 | 290 | if i == retries { 291 | require.NoError(t, errors.Errorf("torrent did not pause in time")) 292 | } 293 | } 294 | require.True(t, isOpen) 295 | require.False(t, isActive) 296 | require.Equal(t, 1, state) 297 | }) 298 | 299 | t.Run("resume torrent", func(t *testing.T) { 300 | err := client.ResumeTorrent(torrents[0]) 301 | require.NoError(t, err) 302 | var isOpen, isActive bool 303 | var state, retries int = 0, maxRetries 304 | for i := 0; i <= retries; i++ { 305 | <-time.After(time.Second) 306 | isOpen, err = client.IsOpen(torrents[0]) 307 | require.NoError(t, err) 308 | 309 | isActive, err = client.IsActive(torrents[0]) 310 | require.NoError(t, err) 311 | 312 | state, err = client.State(torrents[0]) 313 | require.NoError(t, err) 314 | 315 | if isOpen && isActive && state == 1 { 316 | break 317 | } 318 | 319 | if i == retries { 320 | require.NoError(t, errors.Errorf("torrent did not resume in time")) 321 | } 322 | } 323 | require.True(t, isOpen) 324 | require.True(t, isActive) 325 | require.Equal(t, 1, state) 326 | }) 327 | 328 | }) 329 | 330 | t.Run("stop torrent", func(t *testing.T) { 331 | err = client.StopTorrent(torrents[0]) 332 | require.NoError(t, err) 333 | 334 | t.Run("check if stopped", func(t *testing.T) { 335 | var isOpen, isActive bool 336 | var state, retries int = 0, maxRetries 337 | for i := 0; i <= retries; i++ { 338 | <-time.After(time.Second) 339 | 340 | isOpen, err = client.IsOpen(torrents[0]) 341 | require.NoError(t, err) 342 | 343 | isActive, err = client.IsActive(torrents[0]) 344 | require.NoError(t, err) 345 | 346 | state, err = client.State(torrents[0]) 347 | require.NoError(t, err) 348 | 349 | if isOpen && !isActive && state == 0 { 350 | break 351 | } 352 | 353 | if i == retries { 354 | require.NoError(t, errors.Errorf("torrent did not stop in time")) 355 | } 356 | } 357 | require.True(t, isOpen) 358 | require.False(t, isActive) 359 | require.Equal(t, 0, state) 360 | }) 361 | }) 362 | 363 | t.Run("close torrent", func(t *testing.T) { 364 | err = client.CloseTorrent(torrents[0]) 365 | require.NoError(t, err) 366 | 367 | t.Run("check if closed", func(t *testing.T) { 368 | var isOpen, isActive bool 369 | var state, retries int = 0, maxRetries 370 | for i := 0; i <= retries; i++ { 371 | <-time.After(time.Second) 372 | 373 | isOpen, err = client.IsOpen(torrents[0]) 374 | require.NoError(t, err) 375 | 376 | isActive, err = client.IsActive(torrents[0]) 377 | require.NoError(t, err) 378 | 379 | state, err = client.State(torrents[0]) 380 | require.NoError(t, err) 381 | 382 | if !isOpen && !isActive && state == 0 { 383 | break 384 | } 385 | 386 | if i == retries { 387 | require.NoError(t, errors.Errorf("torrent did not close in time")) 388 | } 389 | } 390 | require.False(t, isOpen) 391 | require.False(t, isActive) 392 | require.Equal(t, 0, state) 393 | }) 394 | }) 395 | 396 | t.Run("open torrent", func(t *testing.T) { 397 | err = client.OpenTorrent(torrents[0]) 398 | require.NoError(t, err) 399 | 400 | t.Run("check if open", func(t *testing.T) { 401 | var isOpen bool 402 | var retries int = maxRetries 403 | for i := 0; i <= retries; i++ { 404 | <-time.After(time.Second) 405 | 406 | isOpen, err = client.IsOpen(torrents[0]) 407 | require.NoError(t, err) 408 | 409 | if isOpen { 410 | break 411 | } 412 | 413 | if i == retries { 414 | require.NoError(t, errors.Errorf("torrent did not open in time")) 415 | } 416 | } 417 | require.True(t, isOpen) 418 | }) 419 | }) 420 | 421 | t.Run("re-close torrent", func(t *testing.T) { 422 | err = client.CloseTorrent(torrents[0]) 423 | require.NoError(t, err) 424 | 425 | t.Run("check if closed", func(t *testing.T) { 426 | var isOpen, isActive bool 427 | var state, retries int = 0, maxRetries 428 | for i := 0; i <= retries; i++ { 429 | <-time.After(time.Second) 430 | 431 | isOpen, err = client.IsOpen(torrents[0]) 432 | require.NoError(t, err) 433 | 434 | isActive, err = client.IsActive(torrents[0]) 435 | require.NoError(t, err) 436 | 437 | state, err = client.State(torrents[0]) 438 | require.NoError(t, err) 439 | 440 | if !isOpen && !isActive && state == 0 { 441 | break 442 | } 443 | 444 | if i == retries { 445 | require.NoError(t, errors.Errorf("torrent did not close in time")) 446 | } 447 | } 448 | require.False(t, isOpen) 449 | require.False(t, isActive) 450 | require.Equal(t, 0, state) 451 | }) 452 | }) 453 | 454 | t.Run("delete torrent", func(t *testing.T) { 455 | err := client.Delete(torrents[0]) 456 | require.NoError(t, err) 457 | 458 | torrents, err := client.GetTorrents(ViewMain) 459 | require.NoError(t, err) 460 | require.Empty(t, torrents) 461 | 462 | t.Run("get torrent", func(t *testing.T) { 463 | // It will take some time to disappear, so retry a few times 464 | var torrents []Torrent 465 | var err error 466 | retries := maxRetries 467 | for i := 0; i <= retries; i++ { 468 | <-time.After(time.Second) 469 | torrents, err = client.GetTorrents(ViewMain) 470 | require.NoError(t, err) 471 | if len(torrents) == 0 { 472 | break 473 | } 474 | if i == retries { 475 | require.NoError(t, errors.Errorf("torrent did not delete in time")) 476 | } 477 | } 478 | require.Empty(t, torrents) 479 | }) 480 | }) 481 | }) 482 | }) 483 | 484 | t.Run("with data", func(t *testing.T) { 485 | b, err := ioutil.ReadFile("testdata/Fedora-i3-Live-x86_64-35.torrent") 486 | require.NoError(t, err) 487 | require.NotEmpty(t, b) 488 | 489 | err = client.AddTorrent(b) 490 | require.NoError(t, err) 491 | 492 | t.Run("get torrent", func(t *testing.T) { 493 | // It will take some time to appear, so retry a few times 494 | var torrents []Torrent 495 | var err error 496 | retries := maxRetries 497 | for i := 0; i <= retries; i++ { 498 | <-time.After(time.Second) 499 | torrents, err = client.GetTorrents(ViewMain) 500 | require.NoError(t, err) 501 | if len(torrents) > 0 { 502 | break 503 | } 504 | if i == retries { 505 | require.NoError(t, errors.Errorf("torrent did not show up in time")) 506 | } 507 | } 508 | require.NotEmpty(t, torrents) 509 | require.Len(t, torrents, 1) 510 | require.Equal(t, "299939CFF841ED7FFCA2B3C2A35711C12589632B", torrents[0].Hash) 511 | require.Equal(t, "Fedora-i3-Live-x86_64-35", torrents[0].Name) 512 | require.Equal(t, "", torrents[0].Label) 513 | require.Equal(t, 1437206706, torrents[0].Size) 514 | require.Equal(t, "/downloads/temp/Fedora-i3-Live-x86_64-35", torrents[0].Path) 515 | require.False(t, torrents[0].Completed) 516 | 517 | t.Run("get files", func(t *testing.T) { 518 | files, err := client.GetFiles(torrents[0]) 519 | require.NoError(t, err) 520 | require.NotEmpty(t, files) 521 | require.Len(t, files, 2) 522 | for _, f := range files { 523 | require.NotEmpty(t, f.Path) 524 | require.NotZero(t, f.Size) 525 | } 526 | }) 527 | 528 | t.Run("delete torrent", func(t *testing.T) { 529 | err := client.Delete(torrents[0]) 530 | require.NoError(t, err) 531 | 532 | torrents, err := client.GetTorrents(ViewMain) 533 | require.NoError(t, err) 534 | require.Empty(t, torrents) 535 | 536 | t.Run("get torrent", func(t *testing.T) { 537 | // It will take some time to disappear, so retry a few times 538 | var torrents []Torrent 539 | var err error 540 | retries := maxRetries 541 | for i := 0; i <= retries; i++ { 542 | <-time.After(time.Second) 543 | torrents, err = client.GetTorrents(ViewMain) 544 | require.NoError(t, err) 545 | if len(torrents) == 0 { 546 | break 547 | } 548 | if i == retries { 549 | require.NoError(t, errors.Errorf("torrent did not delete in time")) 550 | } 551 | } 552 | require.Empty(t, torrents) 553 | }) 554 | }) 555 | }) 556 | }) 557 | 558 | t.Run("with data (stopped)", func(t *testing.T) { 559 | b, err := ioutil.ReadFile("testdata/Fedora-i3-Live-x86_64-35.torrent") 560 | require.NoError(t, err) 561 | require.NotEmpty(t, b) 562 | 563 | label := DLabel.SetValue("test-label") 564 | err = client.AddTorrentStopped(b, label) 565 | require.NoError(t, err) 566 | 567 | t.Run("get torrent", func(t *testing.T) { 568 | // It will take some time to appear, so retry a few times 569 | <-time.After(time.Second) 570 | torrents, err := client.GetTorrents(ViewMain) 571 | require.NoError(t, err) 572 | 573 | require.NotEmpty(t, torrents) 574 | require.Len(t, torrents, 1) 575 | require.Equal(t, "299939CFF841ED7FFCA2B3C2A35711C12589632B", torrents[0].Hash) 576 | require.Equal(t, "Fedora-i3-Live-x86_64-35", torrents[0].Name) 577 | require.Equal(t, label.Value, torrents[0].Label) 578 | require.Equal(t, 1437206706, torrents[0].Size) 579 | 580 | t.Run("delete torrent", func(t *testing.T) { 581 | err := client.Delete(torrents[0]) 582 | require.NoError(t, err) 583 | 584 | torrents, err := client.GetTorrents(ViewMain) 585 | require.NoError(t, err) 586 | require.Empty(t, torrents) 587 | 588 | t.Run("get torrent", func(t *testing.T) { 589 | // It will take some time to disappear, so retry a few times 590 | var torrents []Torrent 591 | var err error 592 | retries := maxRetries 593 | for i := 0; i <= retries; i++ { 594 | <-time.After(time.Second) 595 | torrents, err = client.GetTorrents(ViewMain) 596 | require.NoError(t, err) 597 | if len(torrents) == 0 { 598 | break 599 | } 600 | if i == retries { 601 | require.NoError(t, errors.Errorf("torrent did not delete in time")) 602 | } 603 | } 604 | require.Empty(t, torrents) 605 | }) 606 | }) 607 | }) 608 | }) 609 | }) 610 | 611 | t.Run("down total post activity", func(t *testing.T) { 612 | total, err := client.DownTotal() 613 | require.NoError(t, err) 614 | require.NotZero(t, total, "expected data to be transferred") 615 | }) 616 | 617 | t.Run("up total post activity", func(t *testing.T) { 618 | total, err := client.UpTotal() 619 | require.NoError(t, err) 620 | require.NotZero(t, total, "expected data to be transferred") 621 | }) 622 | 623 | } 624 | -------------------------------------------------------------------------------- /rtorrent/rtorrent.go: -------------------------------------------------------------------------------- 1 | package rtorrent 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/mrobinsn/go-rtorrent/xmlrpc" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | // RTorrent is used to communicate with a remote rTorrent instance 13 | type RTorrent struct { 14 | addr string 15 | xmlrpcClient *xmlrpc.Client 16 | } 17 | 18 | // FieldValue contains the Field and Value of an attribute on a rTorrent 19 | type FieldValue struct { 20 | Field Field 21 | Value string 22 | } 23 | 24 | // Torrent represents a torrent in rTorrent 25 | type Torrent struct { 26 | Hash string 27 | Name string 28 | Path string 29 | Size int 30 | Label string 31 | Completed bool 32 | Ratio float64 33 | Created time.Time 34 | Started time.Time 35 | Finished time.Time 36 | } 37 | 38 | // Status represents the status of a torrent 39 | type Status struct { 40 | Completed bool 41 | CompletedBytes int 42 | DownRate int 43 | UpRate int 44 | Ratio float64 45 | Size int 46 | } 47 | 48 | // File represents a file in rTorrent 49 | type File struct { 50 | Path string 51 | Size int 52 | } 53 | 54 | // Field represents a attribute on a RTorrent entity that can be queried or set 55 | type Field string 56 | 57 | // View represents a "view" within RTorrent 58 | type View string 59 | 60 | const ( 61 | // ViewMain represents the "main" view, containing all torrents 62 | ViewMain View = "main" 63 | // ViewStarted represents the "started" view, containing only torrents that have been started 64 | ViewStarted View = "started" 65 | // ViewStopped represents the "stopped" view, containing only torrents that have been stopped 66 | ViewStopped View = "stopped" 67 | // ViewHashing represents the "hashing" view, containing only torrents that are currently hashing 68 | ViewHashing View = "hashing" 69 | // ViewSeeding represents the "seeding" view, containing only torrents that are currently seeding 70 | ViewSeeding View = "seeding" 71 | 72 | // DName represents the name of a "Downloading Items" 73 | DName Field = "d.name" 74 | // DLabel represents the label of a "Downloading Item" 75 | DLabel Field = "d.custom1" 76 | // DSizeInBytes represents the size in bytes of a "Downloading Item" 77 | DSizeInBytes Field = "d.size_bytes" 78 | // DHash represents the hash of a "Downloading Item" 79 | DHash Field = "d.hash" 80 | // DBasePath represents the base path of a "Downloading Item" 81 | DBasePath Field = "d.base_path" 82 | // DDirectory represents the directory of a "Downloading Item" 83 | DDirectory Field = "d.directory" 84 | // DIsActive represents whether a "Downloading Item" is active or not 85 | DIsActive Field = "d.is_active" 86 | // DRatio represents the ratio of a "Downloading Item" 87 | DRatio Field = "d.ratio" 88 | // DComplete represents whether the "Downloading Item" is complete or not 89 | DComplete Field = "d.complete" 90 | // DCompletedBytes represents the total of completed bytes of the "Downloading Item" 91 | DCompletedBytes Field = "d.completed_bytes" 92 | // DDownRate represents the download rate of the "Downloading Item" 93 | DDownRate Field = "d.down.rate" 94 | // DUpRate represents the upload rate of the "Downloading Item" 95 | DUpRate Field = "d.up.rate" 96 | // DCreationTime represents the date the torrent was created 97 | DCreationTime Field = "d.creation_date" 98 | // DFinishedTime represents the date the torrent finished downloading 99 | DFinishedTime Field = "d.timestamp.finished" 100 | // DStartedTime represents the date the torrent started downloading 101 | DStartedTime Field = "d.timestamp.started" 102 | 103 | // FPath represents the path of a "File Item" 104 | FPath Field = "f.path" 105 | // FSizeInBytes represents the size in bytes of a "File Item" 106 | FSizeInBytes Field = "f.size_bytes" 107 | ) 108 | 109 | // Query converts the field to a string which allows it to be queried 110 | // Example: 111 | // DName.Query() // returns "d.name=" 112 | func (f Field) Query() string { 113 | return fmt.Sprintf("%s=", f) 114 | } 115 | 116 | // SetValue returns a FieldValue struct which can be used to set the field on a particular item in rTorrent to the specified value 117 | func (f Field) SetValue(value string) *FieldValue { 118 | return &FieldValue{f, value} 119 | } 120 | 121 | // Cmd returns the representation of the field which allows it to be used a command with RTorrent 122 | func (f Field) Cmd() string { 123 | return string(f) 124 | } 125 | 126 | func (f *FieldValue) String() string { 127 | return fmt.Sprintf("%s.set=\"%s\"", f.Field, f.Value) 128 | } 129 | 130 | // Pretty returns a formatted string representing this Torrent 131 | func (t *Torrent) Pretty() string { 132 | return fmt.Sprintf("Torrent:\n\tHash: %v\n\tName: %v\n\tPath: %v\n\tLabel: %v\n\tSize: %v bytes\n\tCompleted: %v\n\tRatio: %v\n", t.Hash, t.Name, t.Path, t.Label, t.Size, t.Completed, t.Ratio) 133 | } 134 | 135 | // Pretty returns a formatted string representing this File 136 | func (f *File) Pretty() string { 137 | return fmt.Sprintf("File:\n\tPath: %v\n\tSize: %v bytes\n", f.Path, f.Size) 138 | } 139 | 140 | // New returns a new instance of `RTorrent` 141 | // Pass in a true value for `insecure` to turn off certificate verification 142 | func New(addr string, insecure bool) *RTorrent { 143 | return &RTorrent{ 144 | addr: addr, 145 | xmlrpcClient: xmlrpc.NewClient(addr, insecure), 146 | } 147 | } 148 | 149 | // WithHTTPClient allows you to a provide a custom http.Client. 150 | func (r *RTorrent) WithHTTPClient(client *http.Client) *RTorrent { 151 | r.xmlrpcClient = xmlrpc.NewClientWithHTTPClient(r.addr, client) 152 | return r 153 | } 154 | 155 | // AddStopped adds a new torrent by URL in a stopped state 156 | // 157 | // extraArgs can be any valid rTorrent rpc command. For instance: 158 | // 159 | // Adds the Torrent by URL (stopped) and sets the label on the torrent 160 | // AddStopped("some-url", &FieldValue{"d.custom1", "my-label"}) 161 | // Or: 162 | // AddStopped("some-url", DLabel.SetValue("my-label")) 163 | // 164 | // Adds the Torrent by URL (stopped) and sets the label and base path 165 | // AddStopped("some-url", &FieldValue{"d.custom1", "my-label"}, &FiedValue{"d.base_path", "/some/valid/path"}) 166 | // Or: 167 | // AddStopped("some-url", DLabel.SetValue("my-label"), DBasePath.SetValue("/some/valid/path")) 168 | func (r *RTorrent) AddStopped(url string, extraArgs ...*FieldValue) error { 169 | return r.add("load.normal", []byte(url), extraArgs...) 170 | } 171 | 172 | // Add adds a new torrent by URL and starts the torrent 173 | // 174 | // extraArgs can be any valid rTorrent rpc command. For instance: 175 | // 176 | // Adds the Torrent by URL and sets the label on the torrent 177 | // Add("some-url", "d.custom1.set=\"my-label\"") 178 | // Or: 179 | // Add("some-url", DLabel.SetValue("my-label")) 180 | // 181 | // Adds the Torrent by URL and sets the label as well as base path 182 | // Add("some-url", "d.custom1.set=\"my-label\"", "d.base_path=\"/some/valid/path\"") 183 | // Or: 184 | // Add("some-url", DLabel.SetValue("my-label"), DBasePath.SetValue("/some/valid/path")) 185 | func (r *RTorrent) Add(url string, extraArgs ...*FieldValue) error { 186 | return r.add("load.start", []byte(url), extraArgs...) 187 | } 188 | 189 | // AddTorrentStopped adds a new torrent by the torrent files data but does not start the torrent 190 | // 191 | // extraArgs can be any valid rTorrent rpc command. For instance: 192 | // 193 | // Adds the Torrent file (stopped) and sets the label on the torrent 194 | // AddTorrentStopped(fileData, "d.custom1.set=\"my-label\"") 195 | // Or: 196 | // AddTorrentStopped(fileData, DLabel.SetValue("my-label")) 197 | // 198 | // Adds the Torrent file and (stopped) sets the label and base path 199 | // AddTorrentStopped(fileData, "d.custom1.set=\"my-label\"", "d.base_path=\"/some/valid/path\"") 200 | // Or: 201 | // AddTorrentStopped(fileData, DLabel.SetValue("my-label"), DBasePath.SetValue("/some/valid/path")) 202 | func (r *RTorrent) AddTorrentStopped(data []byte, extraArgs ...*FieldValue) error { 203 | return r.add("load.raw", data, extraArgs...) 204 | } 205 | 206 | // AddTorrent adds a new torrent by the torrent files data and starts the torrent 207 | // 208 | // extraArgs can be any valid rTorrent rpc command. For instance: 209 | // 210 | // Adds the Torrent file and sets the label on the torrent 211 | // Add(fileData, "d.custom1.set=\"my-label\"") 212 | // Or: 213 | // AddTorrent(fileData, DLabel.SetValue("my-label")) 214 | // 215 | // Adds the Torrent file and sets the label and base path 216 | // Add(fileData, "d.custom1.set=\"my-label\"", "d.base_path=\"/some/valid/path\"") 217 | // Or: 218 | // AddTorrent(fileData, DLabel.SetValue("my-label"), DBasePath.SetValue("/some/valid/path")) 219 | func (r *RTorrent) AddTorrent(data []byte, extraArgs ...*FieldValue) error { 220 | return r.add("load.raw_start", data, extraArgs...) 221 | } 222 | 223 | func (r *RTorrent) add(cmd string, data []byte, extraArgs ...*FieldValue) error { 224 | args := []interface{}{data} 225 | for _, v := range extraArgs { 226 | args = append(args, v.String()) 227 | } 228 | 229 | _, err := r.xmlrpcClient.Call(cmd, "", args) 230 | if err != nil { 231 | return errors.Wrap(err, fmt.Sprintf("%s XMLRPC call failed", cmd)) 232 | } 233 | return nil 234 | } 235 | 236 | // IP returns the IP reported by this RTorrent instance 237 | func (r *RTorrent) IP() (string, error) { 238 | result, err := r.xmlrpcClient.Call("network.bind_address") 239 | if err != nil { 240 | return "", errors.Wrap(err, "network.bind_address XMLRPC call failed") 241 | } 242 | if ips, ok := result.([]interface{}); ok { 243 | result = ips[0] 244 | } 245 | if ip, ok := result.(string); ok { 246 | return ip, nil 247 | } 248 | return "", errors.Errorf("result isn't string: %v", result) 249 | } 250 | 251 | // Name returns the name reported by this RTorrent instance 252 | func (r *RTorrent) Name() (string, error) { 253 | result, err := r.xmlrpcClient.Call("system.hostname") 254 | if err != nil { 255 | return "", errors.Wrap(err, "system.hostname XMLRPC call failed") 256 | } 257 | if names, ok := result.([]interface{}); ok { 258 | result = names[0] 259 | } 260 | if name, ok := result.(string); ok { 261 | return name, nil 262 | } 263 | return "", errors.Errorf("result isn't string: %v", result) 264 | } 265 | 266 | // DownTotal returns the total downloaded metric reported by this RTorrent instance (bytes) 267 | func (r *RTorrent) DownTotal() (int, error) { 268 | result, err := r.xmlrpcClient.Call("throttle.global_down.total") 269 | if err != nil { 270 | return 0, errors.Wrap(err, "throttle.global_down.total XMLRPC call failed") 271 | } 272 | if totals, ok := result.([]interface{}); ok { 273 | result = totals[0] 274 | } 275 | if total, ok := result.(int); ok { 276 | return total, nil 277 | } 278 | return 0, errors.Errorf("result isn't int: %v", result) 279 | } 280 | 281 | // DownRate returns the current download rate reported by this RTorrent instance (bytes/s) 282 | func (r *RTorrent) DownRate() (int, error) { 283 | result, err := r.xmlrpcClient.Call("throttle.global_down.rate") 284 | if err != nil { 285 | return 0, errors.Wrap(err, "throttle.global_down.rate XMLRPC call failed") 286 | } 287 | if totals, ok := result.([]interface{}); ok { 288 | result = totals[0] 289 | } 290 | if total, ok := result.(int); ok { 291 | return total, nil 292 | } 293 | return 0, errors.Errorf("result isn't int: %v", result) 294 | } 295 | 296 | // UpTotal returns the total uploaded metric reported by this RTorrent instance (bytes) 297 | func (r *RTorrent) UpTotal() (int, error) { 298 | result, err := r.xmlrpcClient.Call("throttle.global_up.total") 299 | if err != nil { 300 | return 0, errors.Wrap(err, "throttle.global_up.total XMLRPC call failed") 301 | } 302 | if totals, ok := result.([]interface{}); ok { 303 | result = totals[0] 304 | } 305 | if total, ok := result.(int); ok { 306 | return total, nil 307 | } 308 | return 0, errors.Errorf("result isn't int: %v", result) 309 | } 310 | 311 | // UpRate returns the current upload rate reported by this RTorrent instance (bytes/s) 312 | func (r *RTorrent) UpRate() (int, error) { 313 | result, err := r.xmlrpcClient.Call("throttle.global_up.rate") 314 | if err != nil { 315 | return 0, errors.Wrap(err, "throttle.global_up.rate XMLRPC call failed") 316 | } 317 | if totals, ok := result.([]interface{}); ok { 318 | result = totals[0] 319 | } 320 | if total, ok := result.(int); ok { 321 | return total, nil 322 | } 323 | return 0, errors.Errorf("result isn't int: %v", result) 324 | } 325 | 326 | // GetTorrents returns all of the torrents reported by this RTorrent instance 327 | func (r *RTorrent) GetTorrents(view View) ([]Torrent, error) { 328 | args := []interface{}{"", string(view), DName.Query(), DSizeInBytes.Query(), DHash.Query(), DLabel.Query(), DDirectory.Query(), DIsActive.Query(), DComplete.Query(), DRatio.Query(), DCreationTime.Query(), DFinishedTime.Query(), DStartedTime.Query()} 329 | results, err := r.xmlrpcClient.Call("d.multicall2", args...) 330 | var torrents []Torrent 331 | if err != nil { 332 | return torrents, errors.Wrap(err, "d.multicall2 XMLRPC call failed") 333 | } 334 | for _, outerResult := range results.([]interface{}) { 335 | for _, innerResult := range outerResult.([]interface{}) { 336 | torrentData := innerResult.([]interface{}) 337 | torrents = append(torrents, Torrent{ 338 | Hash: torrentData[2].(string), 339 | Name: torrentData[0].(string), 340 | Path: torrentData[4].(string), 341 | Size: torrentData[1].(int), 342 | Label: torrentData[3].(string), 343 | Completed: torrentData[6].(int) > 0, 344 | Ratio: float64(torrentData[7].(int)) / float64(1000), 345 | Created: time.Unix(int64(torrentData[8].(int)), 0), 346 | Finished: time.Unix(int64(torrentData[9].(int)), 0), 347 | Started: time.Unix(int64(torrentData[10].(int)), 0), 348 | }) 349 | } 350 | } 351 | return torrents, nil 352 | } 353 | 354 | // GetTorrent returns the torrent identified by the given hash 355 | func (r *RTorrent) GetTorrent(hash string) (Torrent, error) { 356 | var t Torrent 357 | t.Hash = hash 358 | // Name 359 | results, err := r.xmlrpcClient.Call("d.name", t.Hash) 360 | if err != nil { 361 | return t, errors.Wrap(err, "d.name XMLRPC call failed") 362 | } 363 | t.Name = results.([]interface{})[0].(string) 364 | // Size 365 | results, err = r.xmlrpcClient.Call("d.size_bytes", t.Hash) 366 | if err != nil { 367 | return t, errors.Wrap(err, "d.size_bytes XMLRPC call failed") 368 | } 369 | t.Size = results.([]interface{})[0].(int) 370 | // Label 371 | results, err = r.xmlrpcClient.Call("d.custom1", t.Hash) 372 | if err != nil { 373 | return t, errors.Wrap(err, "d.custom1 XMLRPC call failed") 374 | } 375 | t.Label = results.([]interface{})[0].(string) 376 | // Path 377 | results, err = r.xmlrpcClient.Call("d.directory", t.Hash) 378 | if err != nil { 379 | return t, errors.Wrap(err, "d.directory XMLRPC call failed") 380 | } 381 | t.Path = results.([]interface{})[0].(string) 382 | // Completed 383 | results, err = r.xmlrpcClient.Call("d.complete", t.Hash) 384 | if err != nil { 385 | return t, errors.Wrap(err, "d.complete XMLRPC call failed") 386 | } 387 | t.Completed = results.([]interface{})[0].(int) > 0 388 | // Ratio 389 | results, err = r.xmlrpcClient.Call("d.ratio", t.Hash) 390 | if err != nil { 391 | return t, errors.Wrap(err, "d.ratio XMLRPC call failed") 392 | } 393 | t.Ratio = float64(results.([]interface{})[0].(int)) / float64(1000) 394 | // Created 395 | results, err = r.xmlrpcClient.Call(string(DCreationTime), t.Hash) 396 | if err != nil { 397 | return t, errors.Wrap(err, fmt.Sprintf("%s XMLRPC call failed", string(DCreationTime))) 398 | } 399 | t.Created = time.Unix(int64(results.([]interface{})[0].(int)), 0) 400 | // Finished 401 | results, err = r.xmlrpcClient.Call(string(DFinishedTime), t.Hash) 402 | if err != nil { 403 | return t, errors.Wrap(err, fmt.Sprintf("%s XMLRPC call failed", string(DFinishedTime))) 404 | } 405 | t.Finished = time.Unix(int64(results.([]interface{})[0].(int)), 0) 406 | // Started 407 | results, err = r.xmlrpcClient.Call(string(DStartedTime), t.Hash) 408 | if err != nil { 409 | return t, errors.Wrap(err, fmt.Sprintf("%s XMLRPC call failed", string(DStartedTime))) 410 | } 411 | t.Created = time.Unix(int64(results.([]interface{})[0].(int)), 0) 412 | 413 | return t, nil 414 | } 415 | 416 | // Delete removes the torrent 417 | func (r *RTorrent) Delete(t Torrent) error { 418 | _, err := r.xmlrpcClient.Call("d.erase", t.Hash) 419 | if err != nil { 420 | return errors.Wrap(err, "d.erase XMLRPC call failed") 421 | } 422 | return nil 423 | } 424 | 425 | // GetFiles returns all of the files for a given `Torrent` 426 | func (r *RTorrent) GetFiles(t Torrent) ([]File, error) { 427 | args := []interface{}{t.Hash, 0, FPath.Query(), FSizeInBytes.Query()} 428 | results, err := r.xmlrpcClient.Call("f.multicall", args...) 429 | var files []File 430 | if err != nil { 431 | return files, errors.Wrap(err, "f.multicall XMLRPC call failed") 432 | } 433 | for _, outerResult := range results.([]interface{}) { 434 | for _, innerResult := range outerResult.([]interface{}) { 435 | fileData := innerResult.([]interface{}) 436 | files = append(files, File{ 437 | Path: fileData[0].(string), 438 | Size: fileData[1].(int), 439 | }) 440 | } 441 | } 442 | return files, nil 443 | } 444 | 445 | // SetLabel sets the label on the given Torrent 446 | func (r *RTorrent) SetLabel(t Torrent, newLabel string) error { 447 | t.Label = newLabel 448 | args := []interface{}{t.Hash, newLabel} 449 | if _, err := r.xmlrpcClient.Call("d.custom1.set", args...); err != nil { 450 | return errors.Wrap(err, "d.custom1.set XMLRPC call failed") 451 | } 452 | return nil 453 | } 454 | 455 | // GetStatus returns the Status for a given Torrent 456 | func (r *RTorrent) GetStatus(t Torrent) (Status, error) { 457 | var s Status 458 | // Completed 459 | results, err := r.xmlrpcClient.Call("d.complete", t.Hash) 460 | if err != nil { 461 | return s, errors.Wrap(err, "d.complete XMLRPC call failed") 462 | } 463 | s.Completed = results.([]interface{})[0].(int) > 0 464 | // CompletedBytes 465 | results, err = r.xmlrpcClient.Call("d.completed_bytes", t.Hash) 466 | if err != nil { 467 | return s, errors.Wrap(err, "d.completed_bytes XMLRPC call failed") 468 | } 469 | s.CompletedBytes = results.([]interface{})[0].(int) 470 | // DownRate 471 | results, err = r.xmlrpcClient.Call("d.down.rate", t.Hash) 472 | if err != nil { 473 | return s, errors.Wrap(err, "d.down.rate XMLRPC call failed") 474 | } 475 | s.DownRate = results.([]interface{})[0].(int) 476 | // UpRate 477 | results, err = r.xmlrpcClient.Call("d.up.rate", t.Hash) 478 | if err != nil { 479 | return s, errors.Wrap(err, "d.up.rate XMLRPC call failed") 480 | } 481 | s.UpRate = results.([]interface{})[0].(int) 482 | // Ratio 483 | results, err = r.xmlrpcClient.Call("d.ratio", t.Hash) 484 | if err != nil { 485 | return s, errors.Wrap(err, "d.ratio XMLRPC call failed") 486 | } 487 | s.Ratio = float64(results.([]interface{})[0].(int)) / float64(1000) 488 | // Size 489 | results, err = r.xmlrpcClient.Call("d.size_bytes", t.Hash) 490 | if err != nil { 491 | return s, errors.Wrap(err, "d.size_bytes XMLRPC call failed") 492 | } 493 | s.Size = results.([]interface{})[0].(int) 494 | return s, nil 495 | } 496 | 497 | // StartTorrent starts the torrent 498 | func (r *RTorrent) StartTorrent(t Torrent) error { 499 | _, err := r.xmlrpcClient.Call("d.start", t.Hash) 500 | if err != nil { 501 | return errors.Wrap(err, "d.start XMLRPC call failed") 502 | } 503 | return nil 504 | } 505 | 506 | // StopTorrent stops the torrent 507 | func (r *RTorrent) StopTorrent(t Torrent) error { 508 | _, err := r.xmlrpcClient.Call("d.stop", t.Hash) 509 | if err != nil { 510 | return errors.Wrap(err, "d.stop XMLRPC call failed") 511 | } 512 | return nil 513 | } 514 | 515 | // CloseTorrent closes the torrent 516 | func (r *RTorrent) CloseTorrent(t Torrent) error { 517 | _, err := r.xmlrpcClient.Call("d.close", t.Hash) 518 | if err != nil { 519 | return errors.Wrap(err, "d.close XMLRPC call failed") 520 | } 521 | return nil 522 | } 523 | 524 | // OpenTorrent opens the torrent 525 | func (r *RTorrent) OpenTorrent(t Torrent) error { 526 | _, err := r.xmlrpcClient.Call("d.open", t.Hash) 527 | if err != nil { 528 | return errors.Wrap(err, "d.open XMLRPC call failed") 529 | } 530 | return nil 531 | } 532 | 533 | // PauseTorrent pauses the torrent 534 | func (r *RTorrent) PauseTorrent(t Torrent) error { 535 | _, err := r.xmlrpcClient.Call("d.pause", t.Hash) 536 | if err != nil { 537 | return errors.Wrap(err, "d.pause XMLRPC call failed") 538 | } 539 | return nil 540 | } 541 | 542 | // ResumeTorrent resumes the torrent 543 | func (r *RTorrent) ResumeTorrent(t Torrent) error { 544 | _, err := r.xmlrpcClient.Call("d.resume", t.Hash) 545 | if err != nil { 546 | return errors.Wrap(err, "d.resume XMLRPC call failed") 547 | } 548 | return nil 549 | } 550 | 551 | // IsActive checks if the torrent is active 552 | func (r *RTorrent) IsActive(t Torrent) (bool, error) { 553 | results, err := r.xmlrpcClient.Call("d.is_active", t.Hash) 554 | if err != nil { 555 | return false, errors.Wrap(err, "d.is_active XMLRPC call failed") 556 | } 557 | // active = 1; inactive = 0 558 | return results.([]interface{})[0].(int) == 1, nil 559 | } 560 | 561 | // IsOpen checks if the torrent is open 562 | func (r *RTorrent) IsOpen(t Torrent) (bool, error) { 563 | results, err := r.xmlrpcClient.Call("d.is_open", t.Hash) 564 | if err != nil { 565 | return false, errors.Wrap(err, "d.is_open XMLRPC call failed") 566 | } 567 | // open = 1; closed = 0 568 | return results.([]interface{})[0].(int) == 1, nil 569 | } 570 | 571 | // State returns the state that the torrent is into 572 | // It returns: 0 for stopped, 1 for started/paused 573 | func (r *RTorrent) State(t Torrent) (int, error) { 574 | results, err := r.xmlrpcClient.Call("d.state", t.Hash) 575 | if err != nil { 576 | return 0, errors.Wrap(err, "d.state XMLRPC call failed") 577 | } 578 | return results.([]interface{})[0].(int), nil 579 | } 580 | --------------------------------------------------------------------------------