From 6a5c105f8c8df72ffd0ea9651205589846429218 Mon Sep 17 00:00:00 2001 From: Simon Ding Date: Fri, 4 Oct 2024 01:22:27 +0800 Subject: [PATCH] WIP: qbittorrent support --- pkg/go-qbittorrent/.dockerignore | 32 + pkg/go-qbittorrent/.gitignore | 1 + pkg/go-qbittorrent/README.md | 19 + pkg/go-qbittorrent/docs.txt | 260 ++++++ pkg/go-qbittorrent/go-qbittorrent.go | 66 ++ pkg/go-qbittorrent/qbt/api.go | 1139 ++++++++++++++++++++++++++ pkg/go-qbittorrent/qbt/models.go | 387 +++++++++ pkg/go-qbittorrent/tools/tools.go | 24 + pkg/qbittorrent/qbittorrent.go | 133 +++ pkg/thirdparty/doc.go | 1 + 10 files changed, 2062 insertions(+) create mode 100644 pkg/go-qbittorrent/.dockerignore create mode 100644 pkg/go-qbittorrent/.gitignore create mode 100644 pkg/go-qbittorrent/README.md create mode 100644 pkg/go-qbittorrent/docs.txt create mode 100644 pkg/go-qbittorrent/go-qbittorrent.go create mode 100644 pkg/go-qbittorrent/qbt/api.go create mode 100644 pkg/go-qbittorrent/qbt/models.go create mode 100644 pkg/go-qbittorrent/tools/tools.go create mode 100644 pkg/qbittorrent/qbittorrent.go create mode 100644 pkg/thirdparty/doc.go diff --git a/pkg/go-qbittorrent/.dockerignore b/pkg/go-qbittorrent/.dockerignore new file mode 100644 index 0000000..3aae539 --- /dev/null +++ b/pkg/go-qbittorrent/.dockerignore @@ -0,0 +1,32 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/engine/reference/builder/#dockerignore-file + +**/.DS_Store +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md diff --git a/pkg/go-qbittorrent/.gitignore b/pkg/go-qbittorrent/.gitignore new file mode 100644 index 0000000..4b52586 --- /dev/null +++ b/pkg/go-qbittorrent/.gitignore @@ -0,0 +1 @@ +./main.go diff --git a/pkg/go-qbittorrent/README.md b/pkg/go-qbittorrent/README.md new file mode 100644 index 0000000..bc565b2 --- /dev/null +++ b/pkg/go-qbittorrent/README.md @@ -0,0 +1,19 @@ +go-qbittorrent +================== + +Golang wrapper for qBittorrent Web API (for versions above v4.1) forked from [superturkey650](https://github.com/superturkey650/go-qbittorrent) version (only supporting older API version) + +This wrapper is based on the methods described in [qBittorrent's Official Web API](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)>) + +Some methods are only supported in qBittorent's latest version (v4.5 when writing). + +It'll be best if you upgrade your client to a latest version. + +An example can be found in main.go + +Installation +============ + +The best way is to install with go get:: + + $ go get github.com/simon-ding/go-qbittorrent/qbt diff --git a/pkg/go-qbittorrent/docs.txt b/pkg/go-qbittorrent/docs.txt new file mode 100644 index 0000000..980a0fb --- /dev/null +++ b/pkg/go-qbittorrent/docs.txt @@ -0,0 +1,260 @@ +PACKAGE DOCUMENTATION + +package qbt + import "/Users/me/Repos/go/src/go-qbittorrent/qbt" + + +TYPES + +type BasicTorrent struct { + AddedOn int `json:"added_on"` + Category string `json:"category"` + CompletionOn int64 `json:"completion_on"` + Dlspeed int `json:"dlspeed"` + Eta int `json:"eta"` + ForceStart bool `json:"force_start"` + Hash string `json:"hash"` + Name string `json:"name"` + NumComplete int `json:"num_complete"` + NumIncomplete int `json:"num_incomplete"` + NumLeechs int `json:"num_leechs"` + NumSeeds int `json:"num_seeds"` + Priority int `json:"priority"` + Progress int `json:"progress"` + Ratio int `json:"ratio"` + SavePath string `json:"save_path"` + SeqDl bool `json:"seq_dl"` + Size int `json:"size"` + State string `json:"state"` + SuperSeeding bool `json:"super_seeding"` + Upspeed int `json:"upspeed"` +} + BasicTorrent holds a basic torrent object from qbittorrent + +type Client struct { + URL string + Authenticated bool + Session string //replace with session type + Jar http.CookieJar + // contains filtered or unexported fields +} + Client creates a connection to qbittorrent and performs requests + +func NewClient(url string) *Client + NewClient creates a new client connection to qbittorrent + +func (c *Client) AddTrackers(infoHash string, trackers string) (*http.Response, error) + AddTrackers adds trackers to a specific torrent + +func (c *Client) DecreasePriority(infoHashList []string) (*http.Response, error) + DecreasePriority decreases the priority of a list of torrents + +func (c *Client) DeletePermanently(infoHashList []string) (*http.Response, error) + DeletePermanently deletes all files for a list of torrents + +func (c *Client) DeleteTemp(infoHashList []string) (*http.Response, error) + DeleteTemp deletes the temporary files for a list of torrents + +func (c *Client) DownloadFromFile(file string, options map[string]string) (*http.Response, error) + DownloadFromFile downloads a torrent from a file + +func (c *Client) DownloadFromLink(link string, options map[string]string) (*http.Response, error) + DownloadFromLink starts downloading a torrent from a link + +func (c *Client) ForceStart(infoHashList []string, value bool) (*http.Response, error) + ForceStart force starts a list of torrents + +func (c *Client) GetAlternativeSpeedStatus() (status bool, err error) + GetAlternativeSpeedStatus gets the alternative speed status of your + qbittorrent client + +func (c *Client) GetGlobalDownloadLimit() (limit int, err error) + GetGlobalDownloadLimit gets the global download limit of your + qbittorrent client + +func (c *Client) GetGlobalUploadLimit() (limit int, err error) + GetGlobalUploadLimit gets the global upload limit of your qbittorrent + client + +func (c *Client) GetTorrentDownloadLimit(infoHashList []string) (limits map[string]string, err error) + GetTorrentDownloadLimit gets the download limit for a list of torrents + +func (c *Client) GetTorrentUploadLimit(infoHashList []string) (limits map[string]string, err error) + GetTorrentUploadLimit gets the upload limit for a list of torrents + +func (c *Client) IncreasePriority(infoHashList []string) (*http.Response, error) + IncreasePriority increases the priority of a list of torrents + +func (c *Client) Login(username string, password string) (loggedIn bool, err error) + Login logs you in to the qbittorrent client + +func (c *Client) Logout() (loggedOut bool, err error) + Logout logs you out of the qbittorrent client + +func (c *Client) Pause(infoHash string) (*http.Response, error) + Pause pauses a specific torrent + +func (c *Client) PauseAll() (*http.Response, error) + PauseAll pauses all torrents + +func (c *Client) PauseMultiple(infoHashList []string) (*http.Response, error) + PauseMultiple pauses a list of torrents + +func (c *Client) Recheck(infoHashList []string) (*http.Response, error) + Recheck rechecks a list of torrents + +func (c *Client) Resume(infoHash string) (*http.Response, error) + Resume resumes a specific torrent + +func (c *Client) ResumeAll(infoHashList []string) (*http.Response, error) + ResumeAll resumes all torrents + +func (c *Client) ResumeMultiple(infoHashList []string) (*http.Response, error) + ResumeMultiple resumes a list of torrents + +func (c *Client) SetCategory(infoHashList []string, category string) (*http.Response, error) + SetCategory sets the category for a list of torrents + +func (c *Client) SetFilePriority(infoHash string, fileID string, priority string) (*http.Response, error) + SetFilePriority sets the priority for a specific torrent file + +func (c *Client) SetGlobalDownloadLimit(limit string) (*http.Response, error) + SetGlobalDownloadLimit sets the global download limit of your + qbittorrent client + +func (c *Client) SetGlobalUploadLimit(limit string) (*http.Response, error) + SetGlobalUploadLimit sets the global upload limit of your qbittorrent + client + +func (c *Client) SetLabel(infoHashList []string, label string) (*http.Response, error) + SetLabel sets the labels for a list of torrents + +func (c *Client) SetMaxPriority(infoHashList []string) (*http.Response, error) + SetMaxPriority sets the max priority for a list of torrents + +func (c *Client) SetMinPriority(infoHashList []string) (*http.Response, error) + SetMinPriority sets the min priority for a list of torrents + +func (c *Client) SetPreferences(params map[string]string) (*http.Response, error) + SetPreferences sets the preferences of your qbittorrent client + +func (c *Client) SetTorrentDownloadLimit(infoHashList []string, limit string) (*http.Response, error) + SetTorrentDownloadLimit sets the download limit for a list of torrents + +func (c *Client) SetTorrentUploadLimit(infoHashList []string, limit string) (*http.Response, error) + SetTorrentUploadLimit sets the upload limit of a list of torrents + +func (c *Client) Shutdown() (shuttingDown bool, err error) + Shutdown shuts down the qbittorrent client + +func (c *Client) Sync(rid string) (Sync, error) + Sync syncs main data of qbittorrent + +func (c *Client) ToggleAlternativeSpeed() (*http.Response, error) + ToggleAlternativeSpeed toggles the alternative speed of your qbittorrent + client + +func (c *Client) ToggleFirstLastPiecePriority(infoHashList []string) (*http.Response, error) + ToggleFirstLastPiecePriority toggles first last piece priority of a list + of torrents + +func (c *Client) ToggleSequentialDownload(infoHashList []string) (*http.Response, error) + ToggleSequentialDownload toggles the download sequence of a list of + torrents + +func (c *Client) Torrent(infoHash string) (Torrent, error) + Torrent gets a specific torrent + +func (c *Client) TorrentFiles(infoHash string) ([]TorrentFile, error) + TorrentFiles gets the files of a specifc torrent + +func (c *Client) TorrentTrackers(infoHash string) ([]Tracker, error) + TorrentTrackers gets all trackers for a specific torrent + +func (c *Client) TorrentWebSeeds(infoHash string) ([]WebSeed, error) + TorrentWebSeeds gets seeders for a specific torrent + +func (c *Client) Torrents(filters map[string]string) (torrentList []BasicTorrent, err error) + Torrents gets a list of all torrents in qbittorrent matching your filter + +type Sync struct { + Categories []string `json:"categories"` + FullUpdate bool `json:"full_update"` + Rid int `json:"rid"` + ServerState struct { + ConnectionStatus string `json:"connection_status"` + DhtNodes int `json:"dht_nodes"` + DlInfoData int `json:"dl_info_data"` + DlInfoSpeed int `json:"dl_info_speed"` + DlRateLimit int `json:"dl_rate_limit"` + Queueing bool `json:"queueing"` + RefreshInterval int `json:"refresh_interval"` + UpInfoData int `json:"up_info_data"` + UpInfoSpeed int `json:"up_info_speed"` + UpRateLimit int `json:"up_rate_limit"` + UseAltSpeedLimits bool `json:"use_alt_speed_limits"` + } `json:"server_state"` + Torrents map[string]Torrent `json:"torrents"` +} + Sync holds the sync response struct + +type Torrent struct { + AdditionDate int `json:"addition_date"` + Comment string `json:"comment"` + CompletionDate int `json:"completion_date"` + CreatedBy string `json:"created_by"` + CreationDate int `json:"creation_date"` + DlLimit int `json:"dl_limit"` + DlSpeed int `json:"dl_speed"` + DlSpeedAvg int `json:"dl_speed_avg"` + Eta int `json:"eta"` + LastSeen int `json:"last_seen"` + NbConnections int `json:"nb_connections"` + NbConnectionsLimit int `json:"nb_connections_limit"` + Peers int `json:"peers"` + PeersTotal int `json:"peers_total"` + PieceSize int `json:"piece_size"` + PiecesHave int `json:"pieces_have"` + PiecesNum int `json:"pieces_num"` + Reannounce int `json:"reannounce"` + SavePath string `json:"save_path"` + SeedingTime int `json:"seeding_time"` + Seeds int `json:"seeds"` + SeedsTotal int `json:"seeds_total"` + ShareRatio float64 `json:"share_ratio"` + TimeElapsed int `json:"time_elapsed"` + TotalDownloaded int `json:"total_downloaded"` + TotalDownloadedSession int `json:"total_downloaded_session"` + TotalSize int `json:"total_size"` + TotalUploaded int `json:"total_uploaded"` + TotalUploadedSession int `json:"total_uploaded_session"` + TotalWasted int `json:"total_wasted"` + UpLimit int `json:"up_limit"` + UpSpeed int `json:"up_speed"` + UpSpeedAvg int `json:"up_speed_avg"` +} + Torrent holds a torrent object from qbittorrent + +type TorrentFile struct { + IsSeed bool `json:"is_seed"` + Name string `json:"name"` + Priority int `json:"priority"` + Progress int `json:"progress"` + Size int `json:"size"` +} + TorrentFile holds a torrent file object from qbittorrent + +type Tracker struct { + Msg string `json:"msg"` + NumPeers int `json:"num_peers"` + Status string `json:"status"` + URL string `json:"url"` +} + Tracker holds a tracker object from qbittorrent + +type WebSeed struct { + URL string `json:"url"` +} + WebSeed holds a webseed object from qbittorrent + + diff --git a/pkg/go-qbittorrent/go-qbittorrent.go b/pkg/go-qbittorrent/go-qbittorrent.go new file mode 100644 index 0000000..733c88e --- /dev/null +++ b/pkg/go-qbittorrent/go-qbittorrent.go @@ -0,0 +1,66 @@ +package qbittorrent + +import ( + "fmt" + "polaris/pkg/go-qbittorrent/qbt" + + "github.com/davecgh/go-spew/spew" +) + +func main() { + // connect to qbittorrent client + qb := qbt.NewClient("http://localhost:8181") + + // login to the client + loginOpts := qbt.LoginOptions{ + Username: "username", + Password: "password", + } + err := qb.Login(loginOpts) + if err != nil { + fmt.Println(err) + } + + // ******************** + // DOWNLOAD A TORRENT * + // ******************** + + // were not using any filters so the options map is empty + downloadOpts := qbt.DownloadOptions{} + // set the path to the file + //path := "/Users/me/Downloads/Source.Code.2011.1080p.BluRay.H264.AAC-RARBG-[rarbg.to].torrent" + links := []string{"http://rarbg.to/download.php?id=9buc5hp&h=d73&f=Courage.the.Cowardly.Dog.1999.S01.1080p.AMZN.WEBRip.DD2.0.x264-NOGRP%5Brartv%5D-[rarbg.to].torrent"} + // download the torrent using the file + // the wrapper will handle opening and closing the file for you + err = qb.DownloadLinks(links, downloadOpts) + + if err != nil { + fmt.Println("[-] Download torrent from link") + fmt.Println(err) + } else { + fmt.Println("[+] Download torrent from link") + } + + // ****************** + // GET ALL TORRENTS * + // ****************** + torrentsOpts := qbt.TorrentsOptions{} + filter := "inactive" + sort := "name" + hash := "d739f78a12b241ba62719b1064701ffbb45498a8" + torrentsOpts.Filter = &filter + torrentsOpts.Sort = &sort + torrentsOpts.Hashes = []string{hash} + torrents, err := qb.Torrents(torrentsOpts) + if err != nil { + fmt.Println("[-] Get torrent list") + fmt.Println(err) + } else { + fmt.Println("[+] Get torrent list") + if len(torrents) > 0 { + spew.Dump(torrents[0]) + } else { + fmt.Println("No torrents found") + } + } +} diff --git a/pkg/go-qbittorrent/qbt/api.go b/pkg/go-qbittorrent/qbt/api.go new file mode 100644 index 0000000..884633a --- /dev/null +++ b/pkg/go-qbittorrent/qbt/api.go @@ -0,0 +1,1139 @@ +package qbt + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/http" + "net/http/cookiejar" + "os" + "path" + + "net/url" + "strconv" + "strings" + + wrapper "github.com/pkg/errors" + + "golang.org/x/net/publicsuffix" +) + +// ErrBadPriority means the priority is not allowd by qbittorrent +var ErrBadPriority = errors.New("priority not available") + +// ErrBadResponse means that qbittorrent sent back an unexpected response +var ErrBadResponse = errors.New("received bad response") + +// delimit puts list into a combined (single element) map with all items connected separated by the delimiter +// this is how the WEBUI API recognizes multiple items +func delimit(items []string, delimiter string) (delimited string) { + for i, v := range items { + if i > 0 { + delimited += delimiter + v + } else { + delimited = v + } + } + return delimited +} + +// Client creates a connection to qbittorrent and performs requests +type Client struct { + http *http.Client + URL string + Authenticated bool + Jar http.CookieJar +} + +// NewClient creates a new client connection to qbittorrent +func NewClient(url string) *Client { + client := &Client{} + + // ensure url ends with "/" + if url[len(url)-1:] != "/" { + url += "/" + } + + client.URL = url + + // create cookie jar + client.Jar, _ = cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + client.http = &http.Client{ + Jar: client.Jar, + } + return client +} + +// get will perform a GET request with no parameters +func (client *Client) get(endpoint string, opts map[string]string) (*http.Response, error) { + req, err := http.NewRequest("GET", client.URL+endpoint, nil) + if err != nil { + return nil, wrapper.Wrap(err, "failed to build request") + } + + //add user-agent header to allow qbittorrent to identify us + req.Header.Set("User-Agent", "go-qbittorrent v0.1") + + //add optional parameters that the user wants + if opts != nil { + query := req.URL.Query() + for k, v := range opts { + query.Add(k, v) + } + req.URL.RawQuery = query.Encode() + } + + resp, err := client.http.Do(req) + if err != nil { + return nil, wrapper.Wrap(err, "failed to perform request") + } + + return resp, nil +} + +// post will perform a POST request with no content-type specified +func (client *Client) post(endpoint string, opts map[string]string) (*http.Response, error) { + // add optional parameters that the user wants + form := url.Values{} + for k, v := range opts { + form.Add(k, v) + } + + req, err := http.NewRequest("POST", client.URL+endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, wrapper.Wrap(err, "failed to build request") + } + + // add the content-type so qbittorrent knows what to expect + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // add user-agent header to allow qbittorrent to identify us + req.Header.Set("User-Agent", "go-qbittorrent v0.1") + + resp, err := client.http.Do(req) + if err != nil { + return nil, wrapper.Wrap(err, "failed to perform request") + } + + return resp, nil +} + +// postMultipart will perform a multiple part POST request +func (client *Client) postMultipart(endpoint string, buffer bytes.Buffer, contentType string) (resp *http.Response, err error) { + req, err := http.NewRequest("POST", client.URL+endpoint, &buffer) + if err != nil { + return nil, wrapper.Wrap(err, "error creating request") + } + + // add the content-type so qbittorrent knows what to expect + req.Header.Set("Content-Type", contentType) + // add user-agent header to allow qbittorrent to identify us + req.Header.Set("User-Agent", "go-qbittorrent v0.2") + + resp, err = client.http.Do(req) + if err != nil { + return nil, wrapper.Wrap(err, "failed to perform request") + } + + return resp, nil +} + +// writeOptions will write a map to the buffer through multipart.NewWriter +func writeOptions(writer *multipart.Writer, opts map[string]string) (err error) { + for key, val := range opts { + if err := writer.WriteField(key, val); err != nil { + return err + } + } + return nil +} + +// postMultipartData will perform a multiple part POST request without a file +func (client *Client) postMultipartData(endpoint string, opts map[string]string) (*http.Response, error) { + var buffer bytes.Buffer + writer := multipart.NewWriter(&buffer) + + // write the options to the buffer + // will contain the link string + if err := writeOptions(writer, opts); err != nil { + return nil, wrapper.Wrap(err, "failed to write options") + } + + // close the writer before doing request to get closing line on multipart request + if err := writer.Close(); err != nil { + return nil, wrapper.Wrap(err, "failed to close writer") + } + + resp, err := client.postMultipart(endpoint, buffer, writer.FormDataContentType()) + if err != nil { + return nil, err + } + + return resp, nil +} + +// postMultipartFile will perform a multiple part POST request with a file +func (client *Client) postMultipartFile(endpoint string, fileName string, opts map[string]string) (*http.Response, error) { + var buffer bytes.Buffer + writer := multipart.NewWriter(&buffer) + + // open the file for reading + file, err := os.Open(fileName) + if err != nil { + return nil, wrapper.Wrap(err, "error opening file") + } + // defer the closing of the file until the end of function + // so we can still copy its contents + defer file.Close() + + // write the options to the buffer + writeOptions(writer, opts) + + // create form for writing the file to and give it the filename + formWriter, err := writer.CreateFormFile("torrents", path.Base(fileName)) + if err != nil { + return nil, wrapper.Wrap(err, "error adding file") + } + + // copy the file contents into the form + if _, err = io.Copy(formWriter, file); err != nil { + return nil, wrapper.Wrap(err, "error copying file") + } + + // close the writer before doing request to get closing line on multipart request + if err := writer.Close(); err != nil { + return nil, wrapper.Wrap(err, "failed to close writer") + } + + resp, err := client.postMultipart(endpoint, buffer, writer.FormDataContentType()) + if err != nil { + return nil, err + } + + return resp, nil +} + +// Application endpoints + +// Login logs you in to the qbittorrent client +// returns the current authentication status +func (client *Client) Login(opts LoginOptions) (err error) { + params := map[string]string{} + + if opts.Username != "" { + params["username"] = opts.Username + } + if opts.Password != "" { + params["password"] = opts.Password + } + + resp, err := client.post("api/v2/auth/login", params) + if err != nil { + return err + } else if resp.StatusCode == 403 { + return wrapper.Errorf("User's IP is banned for too many failed login attempts") + } + + // add the cookie to cookie jar to authenticate later requests + if cookies := resp.Cookies(); len(cookies) > 0 { + cookieURL, _ := url.Parse("http://localhost:8080") + client.Jar.SetCookies(cookieURL, cookies) + // create a new client with the cookie jar and replace the old one + // so that all our later requests are authenticated + client.http = &http.Client{ + Jar: client.Jar, + } + } else { + return wrapper.Errorf("Could not get cookie") + } + + // change authentication status so we know were authenticated in later requests + client.Authenticated = true + + return nil +} + +// Logout logs you out of the qbittorrent client +// returns the current authentication status +func (client *Client) Logout() (err error) { + resp, err := client.post("api/v2/auth/logout", nil) + if err != nil { + return err + } + + // change authentication status so we know were not authenticated in later requests + client.Authenticated = (*resp).StatusCode == 200 + if (*resp).StatusCode != 200 { + return wrapper.Errorf("An unknown error occurred causing a status code of: %d", (*resp).StatusCode) + } + return +} + +// ApplicationVersion of the qbittorrent client +func (client *Client) ApplicationVersion() (version string, err error) { + resp, err := client.post("api/v2/app/version", nil) + if err != nil { + return version, err + } + buf := new(strings.Builder) + io.Copy(buf, resp.Body) + version = buf.String() + return +} + +// WebAPIVersion of the qbittorrent client +func (client *Client) WebAPIVersion() (version string, err error) { + resp, err := client.post("api/v2/app/webapiVersion", nil) + if err != nil { + return version, err + } + buf := new(strings.Builder) + io.Copy(buf, resp.Body) + version = buf.String() + return +} + +// BuildInfo of the qbittorrent client +func (client *Client) BuildInfo() (buildInfo BuildInfo, err error) { + resp, err := client.get("api/v2/app/buildInfo", nil) + if err != nil { + return buildInfo, err + } + json.NewDecoder(resp.Body).Decode(&buildInfo) + return buildInfo, err +} + +// Preferences of the qbittorrent client +func (client *Client) Preferences() (prefs Preferences, err error) { + resp, err := client.get("api/v2/app/preferences", nil) + if err != nil { + return prefs, err + } + json.NewDecoder(resp.Body).Decode(&prefs) + return prefs, err +} + +// SetPreferences of the qbittorrent client +func (client *Client) SetPreferences() (prefsSet bool, err error) { + resp, err := client.post("api/v2/app/setPreferences", nil) + return (resp.Status == "200 OK"), err +} + +// DefaultSavePath of the qbittorrent client +func (client *Client) DefaultSavePath() (path string, err error) { + resp, err := client.get("api/v2/app/defaultSavePath", nil) + if err != nil { + return path, err + } + buf := new(strings.Builder) + io.Copy(buf, resp.Body) + path = buf.String() + return +} + +// Shutdown shuts down the qbittorrent client +func (client *Client) Shutdown() (shuttingDown bool, err error) { + resp, err := client.get("api/v2/app/shutdown", nil) + + // return true if successful + return (resp.Status == "200 OK"), err +} + +// Log Endpoints + +// Logs of the qbittorrent client +func (client *Client) Logs(filters map[string]string) (logs []Log, err error) { + resp, err := client.get("api/v2/log/main", filters) + if err != nil { + return logs, err + } + json.NewDecoder(resp.Body).Decode(&logs) + return logs, err +} + +// PeerLogs of the qbittorrent client +func (client *Client) PeerLogs(filters map[string]string) (logs []PeerLog, err error) { + resp, err := client.get("api/v2/log/peers", filters) + if err != nil { + return logs, err + } + json.NewDecoder(resp.Body).Decode(&logs) + return logs, err +} + +// TODO: Sync Endpoints + +// TODO: Transfer Endpoints + +// Info returns info you usually see in qBt status bar. +func (client *Client) Info(opts InfoOptions) (info Info, err error) { + resp, err := client.get("api/v2/transfer/info", nil) + if err != nil { + return info, err + } + json.NewDecoder(resp.Body).Decode(&info) + return info, err +} + +// AltSpeedLimitsEnabled returns info you usually see in qBt status bar. +func (client *Client) AltSpeedLimitsEnabled() (mode bool, err error) { + resp, err := client.get("api/v2/transfer/speedLimitsMode", nil) + if err != nil { + return mode, err + } + var decoded int + json.NewDecoder(resp.Body).Decode(&decoded) + mode = decoded == 1 + return mode, err +} + +// ToggleAltSpeedLimits returns info you usually see in qBt status bar. +func (client *Client) ToggleAltSpeedLimits() (toggled bool, err error) { + resp, err := client.get("api/v2/transfer/toggleSpeedLimitsMode", nil) + if err != nil { + return toggled, err + } + return (resp.Status == "200 OK"), err +} + +// DlLimit returns info you usually see in qBt status bar. +func (client *Client) DlLimit() (dlLimit int, err error) { + resp, err := client.get("api/v2/transfer/downloadLimit", nil) + if err != nil { + return dlLimit, err + } + json.NewDecoder(resp.Body).Decode(&dlLimit) + return dlLimit, err +} + +// SetDlLimit returns info you usually see in qBt status bar. +func (client *Client) SetDlLimit(limit int) (set bool, err error) { + params := map[string]string{"limit": strconv.Itoa(limit)} + resp, err := client.get("api/v2/transfer/setDownloadLimit", params) + if err != nil { + return set, err + } + return (resp.Status == "200 OK"), err +} + +// UlLimit returns info you usually see in qBt status bar. +func (client *Client) UlLimit() (ulLimit int, err error) { + resp, err := client.get("api/v2/transfer/uploadLimit", nil) + if err != nil { + return ulLimit, err + } + json.NewDecoder(resp.Body).Decode(&ulLimit) + return ulLimit, err +} + +// SetUlLimit returns info you usually see in qBt status bar. +func (client *Client) SetUlLimit(limit int) (set bool, err error) { + params := map[string]string{"limit": strconv.Itoa(limit)} + resp, err := client.get("api/v2/transfer/setUploadLimit", params) + if err != nil { + return set, err + } + return (resp.Status == "200 OK"), err +} + +// Torrents returns a list of all torrents in qbittorrent matching your filter +func (client *Client) Torrents(opts TorrentsOptions) (torrentList []TorrentInfo, err error) { + params := map[string]string{} + if opts.Filter != nil { + params["filter"] = *opts.Filter + } + if opts.Category != nil { + params["category"] = *opts.Category + } + if opts.Sort != nil { + params["sort"] = *opts.Sort + } + if opts.Reverse != nil { + params["reverse"] = strconv.FormatBool(*opts.Reverse) + } + if opts.Offset != nil { + params["offset"] = strconv.Itoa(*opts.Offset) + } + if opts.Limit != nil { + params["limit"] = strconv.Itoa(*opts.Limit) + } + if opts.Hashes != nil { + params["hashes"] = delimit(opts.Hashes, "%0A") + } + resp, err := client.get("api/v2/torrents/info", params) + if err != nil { + return torrentList, err + } + json.NewDecoder(resp.Body).Decode(&torrentList) + return torrentList, nil +} + +// deprecated +// Torrent returns a specific torrent matching the hash +func (client *Client) Torrent(hash string) (torrent Torrent, err error) { + var opts = map[string]string{"hash": strings.ToLower(hash)} + resp, err := client.get("api/v2/torrents/properties", opts) + if err != nil { + return torrent, err + } + json.NewDecoder(resp.Body).Decode(&torrent) + return torrent, nil +} + +// TorrentTrackers returns all trackers for a specific torrent matching the hash +func (client *Client) TorrentTrackers(hash string) (trackers []Tracker, err error) { + var opts = map[string]string{"hash": strings.ToLower(hash)} + resp, err := client.get("api/v2/torrents/trackers", opts) + if err != nil { + return trackers, err + } + json.NewDecoder(resp.Body).Decode(&trackers) + return trackers, nil +} + +// TorrentWebSeeds returns seeders for a specific torrent matching the hash +func (client *Client) TorrentWebSeeds(hash string) (webSeeds []WebSeed, err error) { + var opts = map[string]string{"hash": strings.ToLower(hash)} + resp, err := client.get("api/v2/torrents/webseeds", opts) + if err != nil { + return webSeeds, err + } + json.NewDecoder(resp.Body).Decode(&webSeeds) + return webSeeds, nil +} + +// TorrentFiles from given hash +func (client *Client) TorrentFiles(hash string) (files []TorrentFile, err error) { + var opts = map[string]string{"hash": strings.ToLower(hash)} + resp, err := client.get("api/v2/torrents/files", opts) + if err != nil { + return files, err + } + json.NewDecoder(resp.Body).Decode(&files) + return files, nil +} + +// TorrentPieceStates for all pieces of torrent +func (client *Client) TorrentPieceStates(hash string) (states []int, err error) { + var opts = map[string]string{"hash": strings.ToLower(hash)} + resp, err := client.get("api/v2/torrents/pieceStates", opts) + if err != nil { + return states, err + } + json.NewDecoder(resp.Body).Decode(&states) + return states, nil +} + +// TorrentPieceHashes for all pieces of torrent +func (client *Client) TorrentPieceHashes(hash string) (hashes []string, err error) { + var opts = map[string]string{"hash": strings.ToLower(hash)} + resp, err := client.get("api/v2/torrents/pieceHashes", opts) + if err != nil { + return hashes, err + } + json.NewDecoder(resp.Body).Decode(&hashes) + return hashes, nil +} + +// Pause torrents +func (client *Client) Pause(hashes []string) error { + opts := map[string]string{"hashes": delimit(hashes, "|")} + _, err := client.get("api/v2/torrents/pause", opts) + if err != nil { + return err + } + + return nil +} + +// Resume torrents +func (client *Client) Resume(hashes []string) (bool, error) { + opts := map[string]string{"hashes": delimit(hashes, "|")} + resp, err := client.get("api/v2/torrents/resume", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil +} + +// Delete torrents and optionally delete their files +func (client *Client) Delete(hashes []string, deleteFiles bool) (bool, error) { + opts := map[string]string{"hashes": delimit(hashes, "|")} + opts["deleteFiles"] = strconv.FormatBool(deleteFiles) + resp, err := client.post("api/v2/torrents/delete", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil +} + +// Recheck torrents +func (client *Client) Recheck(hashes []string) (bool, error) { + opts := map[string]string{"hashes": delimit(hashes, "|")} + resp, err := client.post("api/v2/torrents/recheck", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil +} + +// Reannounce torrents +func (client *Client) Reannounce(hashes []string) (bool, error) { + opts := map[string]string{"hashes": delimit(hashes, "|")} + resp, err := client.get("api/v2/torrents/reannounce", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil +} + +// DownloadFromLink starts downloading a torrent from a link +func (client *Client) DownloadLinks(links []string, opts DownloadOptions) error { + params := map[string]string{} + if len(links) == 0 { + return wrapper.Errorf("At least one url must be present") + } else { + delimitedURLs := delimit(links, "%0A") + // TODO: Why is encoding causing problems now? + // encodedURLS := url.QueryEscape(delimitedURLs) + params["urls"] = delimitedURLs + } + if opts.Savepath != nil { + params["savepath"] = *opts.Savepath + } + if opts.Cookie != nil { + params["cookie"] = *opts.Cookie + } + if opts.Category != nil { + params["category"] = *opts.Category + } + if opts.SkipHashChecking != nil { + params["skip_checking"] = strconv.FormatBool(*opts.SkipHashChecking) + } + if opts.Paused != nil { + params["paused"] = strconv.FormatBool(*opts.Paused) + } + if opts.RootFolder != nil { + params["root_folder"] = strconv.FormatBool(*opts.RootFolder) + } + if opts.Rename != nil { + params["rename"] = *opts.Rename + } + if opts.UploadSpeedLimit != nil { + params["upLimit"] = strconv.Itoa(*opts.UploadSpeedLimit) + } + if opts.DownloadSpeedLimit != nil { + params["dlLimit"] = strconv.Itoa(*opts.DownloadSpeedLimit) + } + if opts.SequentialDownload != nil { + params["sequentialDownload"] = strconv.FormatBool(*opts.SequentialDownload) + } + if opts.FirstLastPiecePriority != nil { + params["firstLastPiecePrio"] = strconv.FormatBool(*opts.FirstLastPiecePriority) + } + + resp, err := client.postMultipartData("api/v2/torrents/add", params) + if err != nil { + return err + } else if resp.StatusCode == 415 { + return wrapper.Errorf("Torrent file is not valid") + } + + return nil +} + +// DownloadFromFile starts downloading a torrent from a file +func (client *Client) DownloadFromFile(torrents string, opts DownloadOptions) error { + params := map[string]string{} + if torrents == "" { + return wrapper.Errorf("At least one file must be present") + } + if opts.Savepath != nil { + params["savepath"] = *opts.Savepath + } + if opts.Cookie != nil { + params["cookie"] = *opts.Cookie + } + if opts.Category != nil { + params["category"] = *opts.Category + } + if opts.SkipHashChecking != nil { + params["skip_checking"] = strconv.FormatBool(*opts.SkipHashChecking) + } + if opts.Paused != nil { + params["paused"] = strconv.FormatBool(*opts.Paused) + } + if opts.RootFolder != nil { + params["root_folder"] = strconv.FormatBool(*opts.RootFolder) + } + if opts.Rename != nil { + params["rename"] = *opts.Rename + } + if opts.UploadSpeedLimit != nil { + params["upLimit"] = strconv.Itoa(*opts.UploadSpeedLimit) + } + if opts.DownloadSpeedLimit != nil { + params["dlLimit"] = strconv.Itoa(*opts.DownloadSpeedLimit) + } + if opts.AutomaticTorrentManagement != nil { + params["autoTMM"] = strconv.FormatBool(*opts.AutomaticTorrentManagement) + } + if opts.SequentialDownload != nil { + params["sequentialDownload"] = strconv.FormatBool(*opts.SequentialDownload) + } + if opts.FirstLastPiecePriority != nil { + params["firstLastPiecePrio"] = strconv.FormatBool(*opts.FirstLastPiecePriority) + } + resp, err := client.postMultipartFile("api/v2/torrents/add", torrents, params) + if err != nil { + return err + } else if resp.StatusCode == 415 { + return wrapper.Errorf("Torrent file is not valid") + } + + return nil +} + +// AddTrackers to a torrent +func (client *Client) AddTrackers(hash string, trackers []string) error { + params := make(map[string]string) + params["hash"] = strings.ToLower(hash) + delimitedTrackers := delimit(trackers, "%0A") + encodedTrackers := url.QueryEscape(delimitedTrackers) + params["urls"] = encodedTrackers + + resp, err := client.post("api/v2/torrents/addTrackers", params) + if err != nil { + return err + } else if resp != nil && (*resp).StatusCode == 404 { + return wrapper.Errorf("Torrent hash not found") + } + return nil +} + +// EditTracker on a torrent +func (client *Client) EditTracker(hash string, origURL string, newURL string) error { + params := map[string]string{ + "hash": hash, + "origUrl": origURL, + "newUrl": newURL, + } + resp, err := client.get("api/v2/torrents/editTracker", params) + if err != nil { + return err + } + switch sc := (*resp).StatusCode; sc { + case 400: + return wrapper.Errorf("newUrl is not a valid url") + case 404: + return wrapper.Errorf("Torrent hash was not found") + case 409: + return wrapper.Errorf("newUrl already exists for this torrent or origUrl was not found") + default: + return nil + } +} + +// RemoveTrackers from a torrent +func (client *Client) RemoveTrackers(hash string, trackers []string) error { + params := map[string]string{ + "hash": hash, + "urls": delimit(trackers, "|"), + } + resp, err := client.get("api/v2/torrents/removeTrackers", params) + if err != nil { + return err + } + + switch sc := (*resp).StatusCode; sc { + case 200: + return nil + case 404: + return wrapper.Errorf("Torrent hash was not found") + case 409: + return wrapper.Errorf("All URLs were not found") + default: + return wrapper.Errorf("An unknown error occurred causing a status code of: %v", sc) + } +} + +// IncreasePriority of torrents +func (client *Client) IncreasePriority(hashes []string) error { + opts := map[string]string{"hashes": delimit(hashes, "|")} + resp, err := client.post("api/v2/torrents/increasePrio", opts) + if err != nil { + return err + } + + switch sc := (*resp).StatusCode; sc { + case 200: + return nil + case 409: + return wrapper.Errorf("Torrent queueing is not enabled") + default: + return wrapper.Errorf("An unknown error occurred causing a status code of: %v", sc) + } +} + +// DecreasePriority of torrents +func (client *Client) DecreasePriority(hashes []string) error { + opts := map[string]string{"hashes": delimit(hashes, "|")} + resp, err := client.post("api/v2/torrents/decreasePrio", opts) + if err != nil { + return err + } + + switch sc := (*resp).StatusCode; sc { + case 200: + return nil + case 409: + return wrapper.Errorf("Torrent queueing is not enabled") + default: + return wrapper.Errorf("An unknown error occurred causing a status code of: %v", sc) + } +} + +// MaxPriority maximizes the priority of torrents +func (client *Client) MaxPriority(hashes []string) error { + opts := map[string]string{"hashes": delimit(hashes, "|")} + resp, err := client.post("api/v2/torrents/topPrio", opts) + if err != nil { + return err + } + + switch sc := (*resp).StatusCode; sc { + case 200: + return nil + case 409: + return wrapper.Errorf("Torrent queueing is not enabled") + default: + return wrapper.Errorf("An unknown error occurred causing a status code of: %v", sc) + } +} + +// MinPriority maximizes the priority of torrents +func (client *Client) MinPriority(hashes []string) error { + opts := map[string]string{"hashes": delimit(hashes, "|")} + resp, err := client.post("api/v2/torrents/bottomPrio", opts) + if err != nil { + return err + } + + switch sc := (*resp).StatusCode; sc { + case 200: + return nil + case 409: + return wrapper.Errorf("Torrent queueing is not enabled") + default: + return wrapper.Errorf("An unknown error occurred causing a status code of: %v", sc) + } +} + +// FilePriority for a torrent +func (client *Client) FilePriority(hash string, ids []int, priority int) error { + formattedIds := []string{} + for _, id := range ids { + formattedIds = append(formattedIds, strconv.Itoa(id)) + } + + opts := map[string]string{ + "hash": hash, + "id": delimit(formattedIds, "|"), + "priority": strconv.Itoa(priority), + } + resp, err := client.post("api/v2/torrents/filePrio", opts) + if err != nil { + return err + } + + switch sc := (*resp).StatusCode; sc { + case 200: + return nil + case 400: + return wrapper.Errorf("Priority is invalid or at least one id is not an integer") + case 409: + return wrapper.Errorf("Torrent metadata hasn't downloaded yet or at least one file id was not found") + default: + return wrapper.Errorf("An unknown error occurred causing a status code of: %v", sc) + } +} + +// GetTorrentDownloadLimit for a list of torrents +func (client *Client) GetTorrentDownloadLimit(hashes []string) (limits map[string]int, err error) { + opts := map[string]string{"hashes": delimit(hashes, "|")} + resp, err := client.post("api/v2/torrents/downloadLimit", opts) + if err != nil { + return limits, err + } + json.NewDecoder(resp.Body).Decode(&limits) + return limits, nil +} + +// SetTorrentDownloadLimit for a list of torrents +func (client *Client) SetTorrentDownloadLimit(hashes []string, limit int) (bool, error) { + opts := map[string]string{ + "hashes": delimit(hashes, "|"), + "limit": strconv.Itoa(limit), + } + resp, err := client.post("api/v2/torrents/setDownloadLimit", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil +} + +// SetTorrentShareLimit for a list of torrents +func (client *Client) SetTorrentShareLimit(hashes []string, ratioLimit int, seedingTimeLimit int) (bool, error) { + opts := map[string]string{ + "hashes": delimit(hashes, "|"), + "ratioLimit": strconv.Itoa(ratioLimit), + "seedingTimeLimit": strconv.Itoa(seedingTimeLimit), + } + resp, err := client.post("api/v2/torrents/setShareLimits", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil +} + +// GetTorrentUploadLimit for a list of torrents +func (client *Client) GetTorrentUploadLimit(hashes []string) (limits map[string]int, err error) { + opts := map[string]string{"hashes": delimit(hashes, "|")} + resp, err := client.post("api/v2/torrents/uploadLimit", opts) + if err != nil { + return limits, err + } + json.NewDecoder(resp.Body).Decode(&limits) + return limits, nil +} + +// SetTorrentUploadLimit for a list of torrents +func (client *Client) SetTorrentUploadLimit(hashes []string, limit int) (bool, error) { + opts := map[string]string{ + "hashes": delimit(hashes, "|"), + "limit": strconv.Itoa(limit), + } + resp, err := client.post("api/v2/torrents/setUploadLimit", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil +} + +// SetTorrentLocation for a list of torrents +func (client *Client) SetTorrentLocation(hashes []string, location string) (bool, error) { + opts := map[string]string{ + "hashes": delimit(hashes, "|"), + "location": location, + } + resp, err := client.post("api/v2/torrents/setLocation", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// SetTorrentName for a torrent +func (client *Client) SetTorrentName(hash string, name string) (bool, error) { + opts := map[string]string{ + "hash": hash, + "name": name, + } + resp, err := client.post("api/v2/torrents/rename", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// SetTorrentCategory for a list of torrents +func (client *Client) SetTorrentCategory(hashes []string, category string) (bool, error) { + opts := map[string]string{ + "hashes": delimit(hashes, "|"), + "category": category, + } + resp, err := client.post("api/v2/torrents/setCategory", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// GetCategories used by client +func (client *Client) GetCategories() (categories Categories, err error) { + resp, err := client.get("api/v2/torrents/categories", nil) + if err != nil { + return categories, err + } + json.NewDecoder(resp.Body).Decode(&categories) + return categories, nil +} + +// CreateCategory for use by client +func (client *Client) CreateCategory(category string, savePath string) (bool, error) { + opts := map[string]string{ + "category": category, + "savePath": savePath, + } + resp, err := client.post("api/v2/torrents/createCategory", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// UpdateCategory used by client +func (client *Client) UpdateCategory(category string, savePath string) (bool, error) { + opts := map[string]string{ + "category": category, + "savePath": savePath, + } + resp, err := client.post("api/v2/torrents/editCategory", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// DeleteCategories used by client +func (client *Client) DeleteCategories(categories []string) (bool, error) { + opts := map[string]string{"categories": delimit(categories, "\n")} + resp, err := client.post("api/v2/torrents/removeCategories", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// AddTorrentTags to a list of torrents +func (client *Client) AddTorrentTags(hashes []string, tags []string) (bool, error) { + opts := map[string]string{ + "hashes": delimit(hashes, "|"), + "tags": delimit(tags, ","), + } + resp, err := client.post("api/v2/torrents/addTags", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// RemoveTorrentTags from a list of torrents (empty list removes all tags) +func (client *Client) RemoveTorrentTags(hashes []string, tags []string) (bool, error) { + opts := map[string]string{ + "hashes": delimit(hashes, "|"), + "tags": delimit(tags, ","), + } + resp, err := client.post("api/v2/torrents/removeTags", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// GetTorrentTags from a list of torrents (empty list removes all tags) +func (client *Client) GetTorrentTags() (tags []string, err error) { + resp, err := client.get("api/v2/torrents/tags", nil) + if err != nil { + return nil, err + } + json.NewDecoder(resp.Body).Decode(&tags) + return tags, nil +} + +// CreateTags for use by client +func (client *Client) CreateTags(tags []string) (bool, error) { + opts := map[string]string{"tags": delimit(tags, ",")} + resp, err := client.post("api/v2/torrents/createTags", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// DeleteTags used by client +func (client *Client) DeleteTags(tags []string) (bool, error) { + opts := map[string]string{"tags": delimit(tags, ",")} + resp, err := client.post("api/v2/torrents/deleteTags", opts) + if err != nil { + return false, err + } + + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// SetAutoManagement for a list of torrents +func (client *Client) SetAutoManagement(hashes []string, enable bool) (bool, error) { + opts := map[string]string{ + "hashes": delimit(hashes, "|"), + "enable": strconv.FormatBool(enable), + } + resp, err := client.post("api/v2/torrents/setAutoManagement", opts) + if err != nil { + return false, err + } + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// ToggleSequentialDownload for a list of torrents +func (client *Client) ToggleSequentialDownload(hashes []string) (bool, error) { + opts := map[string]string{"hashes": delimit(hashes, "|")} + resp, err := client.get("api/v2/torrents/toggleSequentialDownload", opts) + if err != nil { + return false, err + } + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// ToggleFirstLastPiecePriority for a list of torrents +func (client *Client) ToggleFirstLastPiecePriority(hashes []string) (bool, error) { + opts := map[string]string{"hashes": delimit(hashes, "|")} + resp, err := client.get("api/v2/torrents/toggleFirstLastPiecePrio", opts) + if err != nil { + return false, err + } + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// SetForceStart for a list of torrents +func (client *Client) SetForceStart(hashes []string, value bool) (bool, error) { + opts := map[string]string{ + "hashes": delimit(hashes, "|"), + "value": strconv.FormatBool(value), + } + resp, err := client.post("api/v2/torrents/setForceStart", opts) + if err != nil { + return false, err + } + return resp.StatusCode == 200, nil //TODO: look into other statuses +} + +// SetSuperSeeding for a list of torrents +func (client *Client) SetSuperSeeding(hashes []string, value bool) (bool, error) { + opts := map[string]string{ + "hashes": delimit(hashes, "|"), + "value": strconv.FormatBool(value), + } + resp, err := client.post("api/v2/torrents/setSuperSeeding", opts) + if err != nil { + return false, err + } + return resp.StatusCode == 200, nil //TODO: look into other statuses +} diff --git a/pkg/go-qbittorrent/qbt/models.go b/pkg/go-qbittorrent/qbt/models.go new file mode 100644 index 0000000..4eb5f6c --- /dev/null +++ b/pkg/go-qbittorrent/qbt/models.go @@ -0,0 +1,387 @@ +package qbt + +//BasicTorrent holds a basic torrent object from qbittorrent +type BasicTorrent struct { + Category string `json:"category"` + CompletionOn int64 `json:"completion_on"` + Dlspeed int `json:"dlspeed"` + Eta int `json:"eta"` + ForceStart bool `json:"force_start"` + Hash string `json:"hash"` + Name string `json:"name"` + NumComplete int `json:"num_complete"` + NumIncomplete int `json:"num_incomplete"` + NumLeechs int `json:"num_leechs"` + NumSeeds int `json:"num_seeds"` + Priority int `json:"priority"` + Progress int `json:"progress"` + Ratio int `json:"ratio"` + SavePath string `json:"save_path"` + SeqDl bool `json:"seq_dl"` + Size int `json:"size"` + State string `json:"state"` + SuperSeeding bool `json:"super_seeding"` + Upspeed int `json:"upspeed"` + FirstLastPiecePriority bool `json:"f_l_piece_prio"` +} + +//Torrent holds a torrent object from qbittorrent +//with more information than BasicTorrent +type Torrent struct { + AdditionDate int `json:"addition_date"` + Comment string `json:"comment"` + CompletionDate int `json:"completion_date"` + CreatedBy string `json:"created_by"` + CreationDate int `json:"creation_date"` + DlLimit int `json:"dl_limit"` + DlSpeed int `json:"dl_speed"` + DlSpeedAvg int `json:"dl_speed_avg"` + Eta int `json:"eta"` + LastSeen int `json:"last_seen"` + NbConnections int `json:"nb_connections"` + NbConnectionsLimit int `json:"nb_connections_limit"` + Peers int `json:"peers"` + PeersTotal int `json:"peers_total"` + PieceSize int `json:"piece_size"` + PiecesHave int `json:"pieces_have"` + PiecesNum int `json:"pieces_num"` + Reannounce int `json:"reannounce"` + SavePath string `json:"save_path"` + SeedingTime int `json:"seeding_time"` + Seeds int `json:"seeds"` + SeedsTotal int `json:"seeds_total"` + ShareRatio float64 `json:"share_ratio"` + TimeElapsed int `json:"time_elapsed"` + TotalDl int `json:"total_downloaded"` + TotalDlSession int `json:"total_downloaded_session"` + TotalSize int `json:"total_size"` + TotalUl int `json:"total_uploaded"` + TotalUlSession int `json:"total_uploaded_session"` + TotalWasted int `json:"total_wasted"` + UpLimit int `json:"up_limit"` + UpSpeed int `json:"up_speed"` + UpSpeedAvg int `json:"up_speed_avg"` +} + +type TorrentInfo struct { + AddedOn int64 `json:"added_on"` + AmountLeft int64 `json:"amount_left"` + AutoTmm bool `json:"auto_tmm"` + Availability int64 `json:"availability"` + Category string `json:"category"` + Completed int64 `json:"completed"` + CompletionOn int64 `json:"completion_on"` + ContentPath string `json:"content_path"` + DlLimit int64 `json:"dl_limit"` + Dlspeed int64 `json:"dlspeed"` + Downloaded int64 `json:"downloaded"` + DownloadedSession int64 `json:"downloaded_session"` + Eta int64 `json:"eta"` + FLPiecePrio bool `json:"f_l_piece_prio"` + ForceStart bool `json:"force_start"` + Hash string `json:"hash"` + LastActivity int64 `json:"last_activity"` + MagnetURI string `json:"magnet_uri"` + MaxRatio float64 `json:"max_ratio"` + MaxSeedingTime int64 `json:"max_seeding_time"` + Name string `json:"name"` + NumComplete int64 `json:"num_complete"` + NumIncomplete int64 `json:"num_incomplete"` + NumLeechs int64 `json:"num_leechs"` + NumSeeds int64 `json:"num_seeds"` + Priority int64 `json:"priority"` + Progress int64 `json:"progress"` + Ratio float64 `json:"ratio"` + RatioLimit int64 `json:"ratio_limit"` + SavePath string `json:"save_path"` + SeedingTimeLimit int64 `json:"seeding_time_limit"` + SeenComplete int64 `json:"seen_complete"` + SeqDl bool `json:"seq_dl"` + Size int64 `json:"size"` + State string `json:"state"` + SuperSeeding bool `json:"super_seeding"` + Tags string `json:"tags"` + TimeActive int64 `json:"time_active"` + TotalSize int64 `json:"total_size"` + Tracker string `json:"tracker"` + TrackersCount int64 `json:"trackers_count"` + UpLimit int64 `json:"up_limit"` + Uploaded int64 `json:"uploaded"` + UploadedSession int64 `json:"uploaded_session"` + Upspeed int64 `json:"upspeed"` +} + +//Tracker holds a tracker object from qbittorrent +type Tracker struct { + Msg string `json:"msg"` + NumPeers int `json:"num_peers"` + NumSeeds int `json:"num_seeds"` + NumLeeches int `json:"num_leeches"` + NumDownloaded int `json:"num_downloaded"` + Tier int `json:"tier"` + Status int `json:"status"` + URL string `json:"url"` +} + +//WebSeed holds a webseed object from qbittorrent +type WebSeed struct { + URL string `json:"url"` +} + +//TorrentFile holds a torrent file object from qbittorrent +type TorrentFile struct { + Index int `json:"index"` + IsSeed bool `json:"is_seed"` + Name string `json:"name"` + Availability float32 `json:"availability"` + Priority int `json:"priority"` + Progress int `json:"progress"` + Size int `json:"size"` + PieceRange []int `json:"piece_range"` +} + +//Sync holds the sync response struct which contains +//the server state and a map of infohashes to Torrents +type Sync struct { + Categories []string `json:"categories"` + FullUpdate bool `json:"full_update"` + Rid int `json:"rid"` + ServerState struct { + ConnectionStatus string `json:"connection_status"` + DhtNodes int `json:"dht_nodes"` + DlInfoData int `json:"dl_info_data"` + DlInfoSpeed int `json:"dl_info_speed"` + DlRateLimit int `json:"dl_rate_limit"` + Queueing bool `json:"queueing"` + RefreshInterval int `json:"refresh_interval"` + UpInfoData int `json:"up_info_data"` + UpInfoSpeed int `json:"up_info_speed"` + UpRateLimit int `json:"up_rate_limit"` + UseAltSpeedLimits bool `json:"use_alt_speed_limits"` + } `json:"server_state"` + Torrents map[string]Torrent `json:"torrents"` +} + +type BuildInfo struct { + QTVersion string `json:"qt"` + LibtorrentVersion string `json:"libtorrent"` + BoostVersion string `json:"boost"` + OpenSSLVersion string `json:"openssl"` + AppBitness string `json:"bitness"` +} + +type Preferences struct { + Locale string `json:"locale"` + CreateSubfolderEnabled bool `json:"create_subfolder_enabled"` + StartPausedEnabled bool `json:"start_paused_enabled"` + AutoDeleteMode int `json:"auto_delete_mode"` + PreallocateAll bool `json:"preallocate_all"` + IncompleteFilesExt bool `json:"incomplete_files_ext"` + AutoTMMEnabled bool `json:"auto_tmm_enabled"` + TorrentChangedTMMEnabled bool `json:"torrent_changed_tmm_enabled"` + SavePathChangedTMMEnabled bool `json:"save_path_changed_tmm_enabled"` + CategoryChangedTMMEnabled bool `json:"category_changed_tmm_enabled"` + SavePath string `json:"save_path"` + TempPathEnabled bool `json:"temp_path_enabled"` + TempPath string `json:"temp_path"` + ScanDirs map[string]interface{} `json:"scan_dirs"` + ExportDir string `json:"export_dir"` + ExportDirFin string `json:"export_dir_fin"` + MailNotificationEnabled string `json:"mail_notification_enabled"` + MailNotificationSender string `json:"mail_notification_sender"` + MailNotificationEmail string `json:"mail_notification_email"` + MailNotificationSMPTP string `json:"mail_notification_smtp"` + MailNotificationSSLEnabled bool `json:"mail_notification_ssl_enabled"` + MailNotificationAuthEnabled bool `json:"mail_notification_auth_enabled"` + MailNotificationUsername string `json:"mail_notification_username"` + MailNotificationPassword string `json:"mail_notification_password"` + AutorunEnabled bool `json:"autorun_enabled"` + AutorunProgram string `json:"autorun_program"` + QueueingEnabled bool `json:"queueing_enabled"` + MaxActiveDls int `json:"max_active_downloads"` + MaxActiveTorrents int `json:"max_active_torrents"` + MaxActiveUls int `json:"max_active_uploads"` + DontCountSlowTorrents bool `json:"dont_count_slow_torrents"` + SlowTorrentDlRateThreshold int `json:"slow_torrent_dl_rate_threshold"` + SlowTorrentUlRateThreshold int `json:"slow_torrent_ul_rate_threshold"` + SlowTorrentInactiveTimer int `json:"slow_torrent_inactive_timer"` + MaxRatioEnabled bool `json:"max_ratio_enabled"` + MaxRatio float64 `json:"max_ratio"` + MaxRatioAct bool `json:"max_ratio_act"` + ListenPort int `json:"listen_port"` + UPNP bool `json:"upnp"` + RandomPort bool `json:"random_port"` + DlLimit int `json:"dl_limit"` + UlLimit int `json:"up_limit"` + MaxConnections int `json:"max_connec"` + MaxConnectionsPerTorrent int `json:"max_connec_per_torrent"` + MaxUls int `json:"max_uploads"` + MaxUlsPerTorrent int `json:"max_uploads_per_torrent"` + UTPEnabled bool `json:"enable_utp"` + LimitUTPRate bool `json:"limit_utp_rate"` + LimitTCPOverhead bool `json:"limit_tcp_overhead"` + LimitLANPeers bool `json:"limit_lan_peers"` + AltDlLimit int `json:"alt_dl_limit"` + AltUlLimit int `json:"alt_up_limit"` + SchedulerEnabled bool `json:"scheduler_enabled"` + ScheduleFromHour int `json:"schedule_from_hour"` + ScheduleFromMin int `json:"schedule_from_min"` + ScheduleToHour int `json:"schedule_to_hour"` + ScheduleToMin int `json:"schedule_to_min"` + SchedulerDays int `json:"scheduler_days"` + DHTEnabled bool `json:"dht"` + DHTSameAsBT bool `json:"dhtSameAsBT"` + DHTPort int `json:"dht_port"` + PexEnabled bool `json:"pex"` + LSDEnabled bool `json:"lsd"` + Encryption int `json:"encryption"` + AnonymousMode bool `json:"anonymous_mode"` + ProxyType int `json:"proxy_type"` + ProxyIP string `json:"proxy_ip"` + ProxyPort int `json:"proxy_port"` + ProxyPeerConnections bool `json:"proxy_peer_connections"` + ForceProxy bool `json:"force_proxy"` + ProxyAuthEnabled bool `json:"proxy_auth_enabled"` + ProxyUsername string `json:"proxy_username"` + ProxyPassword string `json:"proxy_password"` + IPFilterEnabled bool `json:"ip_filter_enabled"` + IPFilterPath string `json:"ip_filter_path"` + IPFilterTrackers string `json:"ip_filter_trackers"` + WebUIDomainList string `json:"web_ui_domain_list"` + WebUIAddress string `json:"web_ui_address"` + WebUIPort int `json:"web_ui_port"` + WebUIUPNPEnabled bool `json:"web_ui_upnp"` + WebUIUsername string `json:"web_ui_username"` + WebUIPassword string `json:"web_ui_password"` + WebUICSRFProtectionEnabled bool `json:"web_ui_csrf_protection_enabled"` + WebUIClickjackingProtectionEnabled bool `json:"web_ui_clickjacking_protection_enabled"` + BypassLocalAuth bool `json:"bypass_local_auth"` + BypassAuthSubnetWhitelistEnabled bool `json:"bypass_auth_subnet_whitelist_enabled"` + BypassAuthSubnetWhitelist string `json:"bypass_auth_subnet_whitelist"` + AltWebUIEnabled bool `json:"alternative_webui_enabled"` + AltWebUIPath string `json:"alternative_webui_path"` + UseHTTPS bool `json:"use_https"` + SSLKey string `json:"ssl_key"` + SSLCert string `json:"ssl_cert"` + DynDNSEnabled bool `json:"dyndns_enabled"` + DynDNSService int `json:"dyndns_service"` + DynDNSUsername string `json:"dyndns_username"` + DynDNSPassword string `json:"dyndns_password"` + DynDNSDomain string `json:"dyndns_domain"` + RSSRefreshInterval int `json:"rss_refresh_interval"` + RSSMaxArtPerFeed int `json:"rss_max_articles_per_feed"` + RSSProcessingEnabled bool `json:"rss_processing_enabled"` + RSSAutoDlEnabled bool `json:"rss_auto_downloading_enabled"` +} + +//Log +type Log struct { + ID int `json:"id"` + Message string `json:"message"` + Timestamp int `json:"timestamp"` + Type int `json:"type"` +} + +//PeerLog +type PeerLog struct { + ID int `json:"id"` + IP string `json:"ip"` + Blocked bool `json:"blocked"` + Timestamp int `json:"timestamp"` + Reason string `json:"reason"` +} + +//Info +type Info struct { + ConnectionStatus string `json:"connection_status"` + DHTNodes int `json:"dht_nodes"` + DlInfoData int `json:"dl_info_data"` + DlInfoSpeed int `json:"dl_info_speed"` + DlRateLimit int `json:"dl_rate_limit"` + UlInfoData int `json:"up_info_data"` + UlInfoSpeed int `json:"up_info_speed"` + UlRateLimit int `json:"up_rate_limit"` + Queueing bool `json:"queueing"` + UseAltSpeedLimits bool `json:"use_alt_speed_limits"` + RefreshInterval int `json:"refresh_interval"` +} + +type TorrentsOptions struct { + Filter *string // all, downloading, completed, paused, active, inactive => optional + Category *string // => optional + Sort *string // => optional + Reverse *bool // => optional + Limit *int // => optional (no negatives) + Offset *int // => optional (negatives allowed) + Hashes []string // separated by | => optional +} + +//Category of torrent +type Category struct { + Name string `json:"name"` + SavePath string `json:"savePath"` +} + +//Categories mapping +type Categories struct { + Category map[string]Category +} + +//LoginOptions contains all options for /login endpoint +type LoginOptions struct { + Username string + Password string +} + +//AddTrackersOptions contains all options for /addTrackers endpoint +type AddTrackersOptions struct { + Hash string + Trackers []string +} + +//EditTrackerOptions contains all options for /editTracker endpoint +type EditTrackerOptions struct { + Hash string + OrigURL string + NewURL string +} + +//RemoveTrackersOptions contains all options for /removeTrackers endpoint +type RemoveTrackersOptions struct { + Hash string + Trackers []string +} + +type DownloadOptions struct { + Savepath *string + Cookie *string + Category *string + SkipHashChecking *bool + Paused *bool + RootFolder *bool + Rename *string + UploadSpeedLimit *int + DownloadSpeedLimit *int + SequentialDownload *bool + AutomaticTorrentManagement *bool + FirstLastPiecePriority *bool +} + +type InfoOptions struct { + Filter *string + Category *string + Sort *string + Reverse *bool + Limit *int + Offset *int + Hashes []string +} + +type PriorityValues int + +const ( + Do_not_download PriorityValues = 0 + Normal_priority PriorityValues = 1 + High_priority PriorityValues = 6 + Maximal_priority PriorityValues = 7 +) diff --git a/pkg/go-qbittorrent/tools/tools.go b/pkg/go-qbittorrent/tools/tools.go new file mode 100644 index 0000000..9792122 --- /dev/null +++ b/pkg/go-qbittorrent/tools/tools.go @@ -0,0 +1,24 @@ +package tools + +import ( + "fmt" + "io" + "net/http" + "net/http/httputil" +) + +// PrintResponse prints the body of a response +func PrintResponse(body io.ReadCloser) { + r, _ := io.ReadAll(body) + fmt.Println("response: " + string(r)) +} + +// PrintRequest prints a request +func PrintRequest(req *http.Request) error { + r, err := httputil.DumpRequest(req, true) + if err != nil { + return err + } + fmt.Println("request: " + string(r)) + return nil +} diff --git a/pkg/qbittorrent/qbittorrent.go b/pkg/qbittorrent/qbittorrent.go new file mode 100644 index 0000000..5a11e8e --- /dev/null +++ b/pkg/qbittorrent/qbittorrent.go @@ -0,0 +1,133 @@ +package qbittorrent + +import ( + "encoding/json" + "fmt" + "polaris/pkg" + "polaris/pkg/go-qbittorrent/qbt" + "time" + + "github.com/pkg/errors" +) + +type Client struct { + c *qbt.Client +} + +func (c *Client) Download(link, dir string) (pkg.Torrent, error) { + all, err := c.c.Torrents(qbt.TorrentsOptions{}) + if err != nil { + return nil, errors.Wrap(err, "get old torrents") + } + allHash := make(map[string]bool, len(all)) + for _, t := range all { + allHash[t.Hash] = true + } + err = c.c.DownloadLinks([]string{link}, qbt.DownloadOptions{Savepath: &dir}) + if err != nil { + return nil, errors.Wrap(err, "qbt download") + } + var newHash string + +loop: + for i := 0; i < 10; i++ { + time.Sleep(1 * time.Second) + all, err = c.c.Torrents(qbt.TorrentsOptions{}) + if err != nil { + return nil, errors.Wrap(err, "get new torrents") + } + + for _, t := range all { + if !allHash[t.Hash] { + newHash = t.Hash + break loop + } + } + } + + if newHash == "" { + return nil, fmt.Errorf("download torrent fail: timeout") + } + return &Torrent{Hash: newHash, c: c.c}, nil + +} + +type Torrent struct { + c *qbt.Client + Hash string + URL string + User string + Password string +} + +func (t *Torrent) getTorrent() (*qbt.TorrentInfo, error) { + all, err := t.c.Torrents(qbt.TorrentsOptions{Hashes: []string{t.Hash}}) + if err != nil { + return nil, err + } + if len(all) == 0 { + return nil, fmt.Errorf("no such torrent: %v", t.Hash) + } + return &all[0], nil +} + +func (t *Torrent) Name() (string, error) { + qb, err := t.getTorrent() + if err != nil { + return "", err + } + + return qb.Name, nil +} + +func (t *Torrent) Progress() (int, error) { + qb, err := t.getTorrent() + if err != nil { + return 0, err + } + return int(qb.Progress), nil +} + +func (t *Torrent) Stop() error { + return t.c.Pause([]string{t.Hash}) +} + +func (t *Torrent) Start() error { + ok, err := t.c.Resume([]string{t.Hash}) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("status not 200") + } + return nil +} + +func (t *Torrent) Remove() error { + ok, err := t.c.Delete([]string{t.Hash}, true) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("status not 200") + } + return nil +} + +func (t *Torrent) Save() string { + data, _ := json.Marshal(t) + return string(data) +} + +func (t *Torrent) Exists() bool { + _, err := t.getTorrent() + return err == nil +} + +func (t *Torrent) SeedRatio() (float64, error) { + qb, err := t.getTorrent() + if err != nil { + return 0, err + } + return qb.Ratio, nil +} diff --git a/pkg/thirdparty/doc.go b/pkg/thirdparty/doc.go new file mode 100644 index 0000000..f32d789 --- /dev/null +++ b/pkg/thirdparty/doc.go @@ -0,0 +1 @@ +package thirdparty \ No newline at end of file