├── .gitignore
├── tools
└── tools.go
├── main.go
├── .idea
├── libraries
│ └── GOPATH__go_qbittorrent_.xml
└── workspace.xml
├── qbt
├── models.go
└── api.go
├── README.rst
└── docs.txt
/.gitignore:
--------------------------------------------------------------------------------
1 | ./main.go
2 | ./.idea/*
3 |
--------------------------------------------------------------------------------
/tools/tools.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "io/ioutil"
7 | "net/http"
8 | "net/http/httputil"
9 | )
10 |
11 | //PrintResponse prints the body of a response
12 | func PrintResponse(body io.ReadCloser) {
13 | r := make([]byte, 256)
14 | r, _ = ioutil.ReadAll(body)
15 | fmt.Println("response: " + string(r))
16 | }
17 |
18 | //PrintRequest prints a request
19 | func PrintRequest(req *http.Request) error {
20 | r, err := httputil.DumpRequest(req, true)
21 | if err != nil {
22 | return err
23 | }
24 | fmt.Println("request: " + string(r))
25 | return nil
26 | }
27 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "go-qbittorrent/qbt"
6 | "go-qbittorrent/tools"
7 | )
8 |
9 | func main() {
10 | // connect to qbittorrent client
11 | qb := qbt.NewClient("http://localhost:8080/")
12 |
13 | // login to the client
14 | _, err := qb.Login("username", "password")
15 | if err != nil {
16 | fmt.Println(err)
17 | }
18 |
19 | // were not using any filters so the options map is empty
20 | options := map[string]string{}
21 | // set the path to the file
22 | file := "/Users/me/Downloads/Source.Code.2011.1080p.BluRay.H264.AAC-RARBG-[rarbg.to].torrent"
23 | // download the torrent using the file
24 | // the wrapper will handle opening and closing the file for you
25 | resp, err := qb.DownloadFromFile(file, options)
26 | if err != nil {
27 | tools.PrintResponse(resp.Body)
28 | fmt.Println(err)
29 | } else {
30 | fmt.Println("downloaded successful")
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/.idea/libraries/GOPATH__go_qbittorrent_.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/qbt/models.go:
--------------------------------------------------------------------------------
1 | package qbt
2 |
3 | //BasicTorrent holds a basic torrent object from qbittorrent
4 | type BasicTorrent struct {
5 | AddedOn int `json:"added_on"`
6 | Category string `json:"category"`
7 | CompletionOn int64 `json:"completion_on"`
8 | Dlspeed int `json:"dlspeed"`
9 | Eta int `json:"eta"`
10 | ForceStart bool `json:"force_start"`
11 | Hash string `json:"hash"`
12 | Name string `json:"name"`
13 | NumComplete int `json:"num_complete"`
14 | NumIncomplete int `json:"num_incomplete"`
15 | NumLeechs int `json:"num_leechs"`
16 | NumSeeds int `json:"num_seeds"`
17 | Priority int `json:"priority"`
18 | Progress int `json:"progress"`
19 | Ratio int `json:"ratio"`
20 | SavePath string `json:"save_path"`
21 | SeqDl bool `json:"seq_dl"`
22 | Size int `json:"size"`
23 | State string `json:"state"`
24 | SuperSeeding bool `json:"super_seeding"`
25 | Upspeed int `json:"upspeed"`
26 | }
27 |
28 | //Torrent holds a torrent object from qbittorrent
29 | //with more information than BasicTorrent
30 | type Torrent struct {
31 | AdditionDate int `json:"addition_date"`
32 | Comment string `json:"comment"`
33 | CompletionDate int `json:"completion_date"`
34 | CreatedBy string `json:"created_by"`
35 | CreationDate int `json:"creation_date"`
36 | DlLimit int `json:"dl_limit"`
37 | DlSpeed int `json:"dl_speed"`
38 | DlSpeedAvg int `json:"dl_speed_avg"`
39 | Eta int `json:"eta"`
40 | LastSeen int `json:"last_seen"`
41 | NbConnections int `json:"nb_connections"`
42 | NbConnectionsLimit int `json:"nb_connections_limit"`
43 | Peers int `json:"peers"`
44 | PeersTotal int `json:"peers_total"`
45 | PieceSize int `json:"piece_size"`
46 | PiecesHave int `json:"pieces_have"`
47 | PiecesNum int `json:"pieces_num"`
48 | Reannounce int `json:"reannounce"`
49 | SavePath string `json:"save_path"`
50 | SeedingTime int `json:"seeding_time"`
51 | Seeds int `json:"seeds"`
52 | SeedsTotal int `json:"seeds_total"`
53 | ShareRatio float64 `json:"share_ratio"`
54 | TimeElapsed int `json:"time_elapsed"`
55 | TotalDownloaded int `json:"total_downloaded"`
56 | TotalDownloadedSession int `json:"total_downloaded_session"`
57 | TotalSize int `json:"total_size"`
58 | TotalUploaded int `json:"total_uploaded"`
59 | TotalUploadedSession int `json:"total_uploaded_session"`
60 | TotalWasted int `json:"total_wasted"`
61 | UpLimit int `json:"up_limit"`
62 | UpSpeed int `json:"up_speed"`
63 | UpSpeedAvg int `json:"up_speed_avg"`
64 | }
65 |
66 | //Tracker holds a tracker object from qbittorrent
67 | type Tracker struct {
68 | Msg string `json:"msg"`
69 | NumPeers int `json:"num_peers"`
70 | Status string `json:"status"`
71 | URL string `json:"url"`
72 | }
73 |
74 | //WebSeed holds a webseed object from qbittorrent
75 | type WebSeed struct {
76 | URL string `json:"url"`
77 | }
78 |
79 | //TorrentFile holds a torrent file object from qbittorrent
80 | type TorrentFile struct {
81 | IsSeed bool `json:"is_seed"`
82 | Name string `json:"name"`
83 | Priority int `json:"priority"`
84 | Progress int `json:"progress"`
85 | Size int `json:"size"`
86 | }
87 |
88 | //Sync holds the sync response struct which contains
89 | //the server state and a map of infohashes to Torrents
90 | type Sync struct {
91 | Categories []string `json:"categories"`
92 | FullUpdate bool `json:"full_update"`
93 | Rid int `json:"rid"`
94 | ServerState struct {
95 | ConnectionStatus string `json:"connection_status"`
96 | DhtNodes int `json:"dht_nodes"`
97 | DlInfoData int `json:"dl_info_data"`
98 | DlInfoSpeed int `json:"dl_info_speed"`
99 | DlRateLimit int `json:"dl_rate_limit"`
100 | Queueing bool `json:"queueing"`
101 | RefreshInterval int `json:"refresh_interval"`
102 | UpInfoData int `json:"up_info_data"`
103 | UpInfoSpeed int `json:"up_info_speed"`
104 | UpRateLimit int `json:"up_rate_limit"`
105 | UseAltSpeedLimits bool `json:"use_alt_speed_limits"`
106 | } `json:"server_state"`
107 | Torrents map[string]Torrent `json:"torrents"`
108 | }
109 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | ==================
2 | go-qbittorrent
3 | ==================
4 |
5 | Golang wrapper for qBittorrent Web API (for versions above v3.1.x).
6 |
7 | This wrapper is based on the methods described in `qBittorrent's Official Web API Documentation `__
8 |
9 | This project is based on the Python wrapper by `v1k45 `__
10 |
11 | Some methods are only supported in qBittorent's latest version (v3.3.1 when writing).
12 |
13 | It'll be best if you upgrade your client to a latest version.
14 |
15 | An example can be found in main.go
16 |
17 | Installation
18 | ============
19 |
20 | The best way is to install with go get::
21 |
22 | $ go get github.com/superturkey650/go-qbittorrent/qbt
23 |
24 |
25 | Quick usage guide
26 | =================
27 | .. code-block:: go
28 |
29 | import (
30 | "superturkey650/go-qbittorrent/qbt
31 | )
32 |
33 | qb := qbt.NewClient("http://localhost:8080/")
34 |
35 | qb.Login("admin", "your-secret-password")
36 | // not required when 'Bypass from localhost' setting is active.
37 |
38 | torrents = qb.Torrents()
39 |
40 | for torrent := range torrents{
41 | fmt.Println(torrent.name)
42 | }
43 |
44 | API methods
45 | ===========
46 |
47 | Getting torrents
48 | ----------------
49 |
50 | - Get all ``active`` torrents::
51 | .. code-block:: go
52 |
53 | qb.Torrents()
54 |
55 | - Filter torrents::
56 | .. code-block:: go
57 |
58 | filters := map[string]string{
59 | "filter": "downloading",
60 | "category": "my category",
61 | }
62 | qb.Torrents(filters)
63 | // This will return all torrents which are currently
64 | // downloading and are labeled as `my category`.
65 |
66 | filters := map[string]string{
67 | "filter": paused,
68 | "sort": ratio,
69 | }
70 | qb.Torrents(filters)
71 | // This will return all paused torrents sorted by their Leech:Seed ratio.
72 |
73 | Refer to qBittorents WEB API documentation for all possible filters.
74 |
75 | Downloading torrents
76 | --------------------
77 |
78 | - Download torrents by link::
79 | .. code-block:: go
80 |
81 | options := map[string]string{}
82 | magnetLink = "magnet:?xt=urn:btih:e334ab9ddd91c10938a7....."
83 | qb.DownloadFromLink(magnetLink, options)
84 |
85 | // Will return response object with `200:OK` status code
86 | // regardless of sucess of failure.
87 |
88 | - Download multipe torrents by looping over links::
89 | .. code-block:: go
90 |
91 | options := map[string]string{}
92 | links := [...]string{link1, link2, link3}
93 | for l := range links{
94 | qb.DownloadFromLink(l, options)
95 | }
96 |
97 | - Downloading torrents by file::
98 | .. code-block:: go
99 |
100 | options := map[string]string{}
101 | file = "path/to/file.torrent"
102 | qb.DownloadFromFile(file, options)
103 |
104 | - Downloading multiple torrents by using files::
105 | .. code-block:: go
106 |
107 | options := map[string]string{}
108 | file = [...]string{path/to/file1, path/to/file2, path/to/file3}
109 | qb.DownloadFromFile(file, options)
110 |
111 | - Specifing save path for downloads::
112 | .. code-block:: go
113 |
114 | savePath = "/home/user/Downloads/special-dir/"
115 | options := map[string]string{
116 | "savepath": savePath
117 | }
118 | file = "path/to/file.torrent"
119 | qb.DownloadFromFile(file, options)
120 |
121 | // same for links.
122 | savePath = "/home/user/Downloads/special-dir/"
123 | options := map[string]string{
124 | "savepath": savePath
125 | }
126 | magnetLink = "magnet:?xt=urn:btih:e334ab9ddd91c10938a7....."
127 | qb.DownloadFromLink(magnetLink, options)
128 |
129 | - Applying labels to downloads::
130 | .. code-block:: go
131 |
132 | label = "secret-files ;)"
133 | options := map[string]string{
134 | "label": label
135 | }
136 | file = "path/to/file.torrent"
137 | qb.DownloadFromFile(file, options)
138 |
139 | // same for links.
140 | category = "anime"
141 | options := map[string]string{
142 | "label": label
143 | }
144 | magnetLink = "magnet:?xt=urn:btih:e334ab9ddd91c10938a7....."
145 | qb.DownloadFromLink(magnetLink, options)
146 |
147 | Pause / Resume torrents
148 | -----------------------
149 |
150 | - Pausing/ Resuming all torrents::
151 | .. code-block:: go
152 |
153 | qb.PauseAll()
154 | qb.ResumeAll()
155 |
156 | - Pausing/ Resuming a specific torrent::
157 | .. code-block:: go
158 |
159 | infoHash = "e334ab9ddd....infohash....5d7fff526cb4"
160 | qb.Pause(infoHash)
161 | qb.Resume(infoHash)
162 |
163 | - Pausing/ Resuming multiple torrents::
164 | .. code-block:: go
165 |
166 | infoHashes = [...]string{
167 | "e334ab9ddd9......infohash......fff526cb4",
168 | "c9dc36f46d9......infohash......90ebebc46",
169 | "4c859243615......infohash......8b1f20108",
170 | }
171 |
172 | qb.PauseMultiple(infoHashes)
173 | qb.ResumeMultiple(infoHashes)
174 |
175 |
176 | Full API method documentation
177 | =============================
178 |
179 | All API methods of qBittorrent are mentioned in docs.txt
180 |
181 | Authors
182 | =======
183 |
184 | Maintainer
185 | ----------
186 |
187 | - `Jared Mosley (jaredlmosley) `__
188 |
189 | Contributors
190 | ------------
191 |
192 | - Your name here :)
193 |
194 | TODO
195 | ====
196 |
197 | - Write tests
198 |
--------------------------------------------------------------------------------
/docs.txt:
--------------------------------------------------------------------------------
1 | PACKAGE DOCUMENTATION
2 |
3 | package qbt
4 | import "/Users/me/Repos/go/src/go-qbittorrent/qbt"
5 |
6 |
7 | TYPES
8 |
9 | type BasicTorrent struct {
10 | AddedOn int `json:"added_on"`
11 | Category string `json:"category"`
12 | CompletionOn int64 `json:"completion_on"`
13 | Dlspeed int `json:"dlspeed"`
14 | Eta int `json:"eta"`
15 | ForceStart bool `json:"force_start"`
16 | Hash string `json:"hash"`
17 | Name string `json:"name"`
18 | NumComplete int `json:"num_complete"`
19 | NumIncomplete int `json:"num_incomplete"`
20 | NumLeechs int `json:"num_leechs"`
21 | NumSeeds int `json:"num_seeds"`
22 | Priority int `json:"priority"`
23 | Progress int `json:"progress"`
24 | Ratio int `json:"ratio"`
25 | SavePath string `json:"save_path"`
26 | SeqDl bool `json:"seq_dl"`
27 | Size int `json:"size"`
28 | State string `json:"state"`
29 | SuperSeeding bool `json:"super_seeding"`
30 | Upspeed int `json:"upspeed"`
31 | }
32 | BasicTorrent holds a basic torrent object from qbittorrent
33 |
34 | type Client struct {
35 | URL string
36 | Authenticated bool
37 | Session string //replace with session type
38 | Jar http.CookieJar
39 | // contains filtered or unexported fields
40 | }
41 | Client creates a connection to qbittorrent and performs requests
42 |
43 | func NewClient(url string) *Client
44 | NewClient creates a new client connection to qbittorrent
45 |
46 | func (c *Client) AddTrackers(infoHash string, trackers string) (*http.Response, error)
47 | AddTrackers adds trackers to a specific torrent
48 |
49 | func (c *Client) DecreasePriority(infoHashList []string) (*http.Response, error)
50 | DecreasePriority decreases the priority of a list of torrents
51 |
52 | func (c *Client) DeletePermanently(infoHashList []string) (*http.Response, error)
53 | DeletePermanently deletes all files for a list of torrents
54 |
55 | func (c *Client) DeleteTemp(infoHashList []string) (*http.Response, error)
56 | DeleteTemp deletes the temporary files for a list of torrents
57 |
58 | func (c *Client) DownloadFromFile(file string, options map[string]string) (*http.Response, error)
59 | DownloadFromFile downloads a torrent from a file
60 |
61 | func (c *Client) DownloadFromLink(link string, options map[string]string) (*http.Response, error)
62 | DownloadFromLink starts downloading a torrent from a link
63 |
64 | func (c *Client) ForceStart(infoHashList []string, value bool) (*http.Response, error)
65 | ForceStart force starts a list of torrents
66 |
67 | func (c *Client) GetAlternativeSpeedStatus() (status bool, err error)
68 | GetAlternativeSpeedStatus gets the alternative speed status of your
69 | qbittorrent client
70 |
71 | func (c *Client) GetGlobalDownloadLimit() (limit int, err error)
72 | GetGlobalDownloadLimit gets the global download limit of your
73 | qbittorrent client
74 |
75 | func (c *Client) GetGlobalUploadLimit() (limit int, err error)
76 | GetGlobalUploadLimit gets the global upload limit of your qbittorrent
77 | client
78 |
79 | func (c *Client) GetTorrentDownloadLimit(infoHashList []string) (limits map[string]string, err error)
80 | GetTorrentDownloadLimit gets the download limit for a list of torrents
81 |
82 | func (c *Client) GetTorrentUploadLimit(infoHashList []string) (limits map[string]string, err error)
83 | GetTorrentUploadLimit gets the upload limit for a list of torrents
84 |
85 | func (c *Client) IncreasePriority(infoHashList []string) (*http.Response, error)
86 | IncreasePriority increases the priority of a list of torrents
87 |
88 | func (c *Client) Login(username string, password string) (loggedIn bool, err error)
89 | Login logs you in to the qbittorrent client
90 |
91 | func (c *Client) Logout() (loggedOut bool, err error)
92 | Logout logs you out of the qbittorrent client
93 |
94 | func (c *Client) Pause(infoHash string) (*http.Response, error)
95 | Pause pauses a specific torrent
96 |
97 | func (c *Client) PauseAll() (*http.Response, error)
98 | PauseAll pauses all torrents
99 |
100 | func (c *Client) PauseMultiple(infoHashList []string) (*http.Response, error)
101 | PauseMultiple pauses a list of torrents
102 |
103 | func (c *Client) Recheck(infoHashList []string) (*http.Response, error)
104 | Recheck rechecks a list of torrents
105 |
106 | func (c *Client) Resume(infoHash string) (*http.Response, error)
107 | Resume resumes a specific torrent
108 |
109 | func (c *Client) ResumeAll(infoHashList []string) (*http.Response, error)
110 | ResumeAll resumes all torrents
111 |
112 | func (c *Client) ResumeMultiple(infoHashList []string) (*http.Response, error)
113 | ResumeMultiple resumes a list of torrents
114 |
115 | func (c *Client) SetCategory(infoHashList []string, category string) (*http.Response, error)
116 | SetCategory sets the category for a list of torrents
117 |
118 | func (c *Client) SetFilePriority(infoHash string, fileID string, priority string) (*http.Response, error)
119 | SetFilePriority sets the priority for a specific torrent file
120 |
121 | func (c *Client) SetGlobalDownloadLimit(limit string) (*http.Response, error)
122 | SetGlobalDownloadLimit sets the global download limit of your
123 | qbittorrent client
124 |
125 | func (c *Client) SetGlobalUploadLimit(limit string) (*http.Response, error)
126 | SetGlobalUploadLimit sets the global upload limit of your qbittorrent
127 | client
128 |
129 | func (c *Client) SetLabel(infoHashList []string, label string) (*http.Response, error)
130 | SetLabel sets the labels for a list of torrents
131 |
132 | func (c *Client) SetMaxPriority(infoHashList []string) (*http.Response, error)
133 | SetMaxPriority sets the max priority for a list of torrents
134 |
135 | func (c *Client) SetMinPriority(infoHashList []string) (*http.Response, error)
136 | SetMinPriority sets the min priority for a list of torrents
137 |
138 | func (c *Client) SetPreferences(params map[string]string) (*http.Response, error)
139 | SetPreferences sets the preferences of your qbittorrent client
140 |
141 | func (c *Client) SetTorrentDownloadLimit(infoHashList []string, limit string) (*http.Response, error)
142 | SetTorrentDownloadLimit sets the download limit for a list of torrents
143 |
144 | func (c *Client) SetTorrentUploadLimit(infoHashList []string, limit string) (*http.Response, error)
145 | SetTorrentUploadLimit sets the upload limit of a list of torrents
146 |
147 | func (c *Client) Shutdown() (shuttingDown bool, err error)
148 | Shutdown shuts down the qbittorrent client
149 |
150 | func (c *Client) Sync(rid string) (Sync, error)
151 | Sync syncs main data of qbittorrent
152 |
153 | func (c *Client) ToggleAlternativeSpeed() (*http.Response, error)
154 | ToggleAlternativeSpeed toggles the alternative speed of your qbittorrent
155 | client
156 |
157 | func (c *Client) ToggleFirstLastPiecePriority(infoHashList []string) (*http.Response, error)
158 | ToggleFirstLastPiecePriority toggles first last piece priority of a list
159 | of torrents
160 |
161 | func (c *Client) ToggleSequentialDownload(infoHashList []string) (*http.Response, error)
162 | ToggleSequentialDownload toggles the download sequence of a list of
163 | torrents
164 |
165 | func (c *Client) Torrent(infoHash string) (Torrent, error)
166 | Torrent gets a specific torrent
167 |
168 | func (c *Client) TorrentFiles(infoHash string) ([]TorrentFile, error)
169 | TorrentFiles gets the files of a specifc torrent
170 |
171 | func (c *Client) TorrentTrackers(infoHash string) ([]Tracker, error)
172 | TorrentTrackers gets all trackers for a specific torrent
173 |
174 | func (c *Client) TorrentWebSeeds(infoHash string) ([]WebSeed, error)
175 | TorrentWebSeeds gets seeders for a specific torrent
176 |
177 | func (c *Client) Torrents(filters map[string]string) (torrentList []BasicTorrent, err error)
178 | Torrents gets a list of all torrents in qbittorrent matching your filter
179 |
180 | type Sync struct {
181 | Categories []string `json:"categories"`
182 | FullUpdate bool `json:"full_update"`
183 | Rid int `json:"rid"`
184 | ServerState struct {
185 | ConnectionStatus string `json:"connection_status"`
186 | DhtNodes int `json:"dht_nodes"`
187 | DlInfoData int `json:"dl_info_data"`
188 | DlInfoSpeed int `json:"dl_info_speed"`
189 | DlRateLimit int `json:"dl_rate_limit"`
190 | Queueing bool `json:"queueing"`
191 | RefreshInterval int `json:"refresh_interval"`
192 | UpInfoData int `json:"up_info_data"`
193 | UpInfoSpeed int `json:"up_info_speed"`
194 | UpRateLimit int `json:"up_rate_limit"`
195 | UseAltSpeedLimits bool `json:"use_alt_speed_limits"`
196 | } `json:"server_state"`
197 | Torrents map[string]Torrent `json:"torrents"`
198 | }
199 | Sync holds the sync response struct
200 |
201 | type Torrent struct {
202 | AdditionDate int `json:"addition_date"`
203 | Comment string `json:"comment"`
204 | CompletionDate int `json:"completion_date"`
205 | CreatedBy string `json:"created_by"`
206 | CreationDate int `json:"creation_date"`
207 | DlLimit int `json:"dl_limit"`
208 | DlSpeed int `json:"dl_speed"`
209 | DlSpeedAvg int `json:"dl_speed_avg"`
210 | Eta int `json:"eta"`
211 | LastSeen int `json:"last_seen"`
212 | NbConnections int `json:"nb_connections"`
213 | NbConnectionsLimit int `json:"nb_connections_limit"`
214 | Peers int `json:"peers"`
215 | PeersTotal int `json:"peers_total"`
216 | PieceSize int `json:"piece_size"`
217 | PiecesHave int `json:"pieces_have"`
218 | PiecesNum int `json:"pieces_num"`
219 | Reannounce int `json:"reannounce"`
220 | SavePath string `json:"save_path"`
221 | SeedingTime int `json:"seeding_time"`
222 | Seeds int `json:"seeds"`
223 | SeedsTotal int `json:"seeds_total"`
224 | ShareRatio float64 `json:"share_ratio"`
225 | TimeElapsed int `json:"time_elapsed"`
226 | TotalDownloaded int `json:"total_downloaded"`
227 | TotalDownloadedSession int `json:"total_downloaded_session"`
228 | TotalSize int `json:"total_size"`
229 | TotalUploaded int `json:"total_uploaded"`
230 | TotalUploadedSession int `json:"total_uploaded_session"`
231 | TotalWasted int `json:"total_wasted"`
232 | UpLimit int `json:"up_limit"`
233 | UpSpeed int `json:"up_speed"`
234 | UpSpeedAvg int `json:"up_speed_avg"`
235 | }
236 | Torrent holds a torrent object from qbittorrent
237 |
238 | type TorrentFile struct {
239 | IsSeed bool `json:"is_seed"`
240 | Name string `json:"name"`
241 | Priority int `json:"priority"`
242 | Progress int `json:"progress"`
243 | Size int `json:"size"`
244 | }
245 | TorrentFile holds a torrent file object from qbittorrent
246 |
247 | type Tracker struct {
248 | Msg string `json:"msg"`
249 | NumPeers int `json:"num_peers"`
250 | Status string `json:"status"`
251 | URL string `json:"url"`
252 | }
253 | Tracker holds a tracker object from qbittorrent
254 |
255 | type WebSeed struct {
256 | URL string `json:"url"`
257 | }
258 | WebSeed holds a webseed object from qbittorrent
259 |
260 |
261 |
--------------------------------------------------------------------------------
/qbt/api.go:
--------------------------------------------------------------------------------
1 | package qbt
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "io"
8 | "mime/multipart"
9 | "net/http"
10 | "net/http/cookiejar"
11 | "os"
12 | "path"
13 |
14 | "net/url"
15 | "strconv"
16 | "strings"
17 |
18 | wrapper "github.com/pkg/errors"
19 |
20 | "golang.org/x/net/publicsuffix"
21 | )
22 |
23 | //ErrBadPriority means the priority is not allowd by qbittorrent
24 | var ErrBadPriority = errors.New("priority not available")
25 |
26 | //ErrBadResponse means that qbittorrent sent back an unexpected response
27 | var ErrBadResponse = errors.New("received bad response")
28 |
29 | //Client creates a connection to qbittorrent and performs requests
30 | type Client struct {
31 | http *http.Client
32 | URL string
33 | Authenticated bool
34 | Jar http.CookieJar
35 | }
36 |
37 | //NewClient creates a new client connection to qbittorrent
38 | func NewClient(url string) *Client {
39 | client := &Client{}
40 |
41 | // ensure url ends with "/"
42 | if url[len(url)-1:] != "/" {
43 | url += "/"
44 | }
45 |
46 | client.URL = url
47 |
48 | // create cookie jar
49 | client.Jar, _ = cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
50 | client.http = &http.Client{
51 | Jar: client.Jar,
52 | }
53 | return client
54 | }
55 |
56 | //get will perform a GET request with no parameters
57 | func (client *Client) get(endpoint string, opts map[string]string) (*http.Response, error) {
58 | req, err := http.NewRequest("GET", client.URL+endpoint, nil)
59 | if err != nil {
60 | return nil, wrapper.Wrap(err, "failed to build request")
61 | }
62 |
63 | // add user-agent header to allow qbittorrent to identify us
64 | req.Header.Set("User-Agent", "go-qbittorrent v0.1")
65 |
66 | // add optional parameters that the user wants
67 | if opts != nil {
68 | query := req.URL.Query()
69 | for k, v := range opts {
70 | query.Add(k, v)
71 | }
72 | req.URL.RawQuery = query.Encode()
73 | }
74 |
75 | resp, err := client.http.Do(req)
76 | if err != nil {
77 | return nil, wrapper.Wrap(err, "failed to perform request")
78 | }
79 |
80 | return resp, nil
81 | }
82 |
83 | //post will perform a POST request with no content-type specified
84 | func (client *Client) post(endpoint string, opts map[string]string) (*http.Response, error) {
85 | req, err := http.NewRequest("POST", client.URL+endpoint, nil)
86 | if err != nil {
87 | return nil, wrapper.Wrap(err, "failed to build request")
88 | }
89 |
90 | // add the content-type so qbittorrent knows what to expect
91 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
92 | // add user-agent header to allow qbittorrent to identify us
93 | req.Header.Set("User-Agent", "go-qbittorrent v0.1")
94 |
95 | // add optional parameters that the user wants
96 | if opts != nil {
97 | form := url.Values{}
98 | for k, v := range opts {
99 | form.Add(k, v)
100 | }
101 | req.PostForm = form
102 | }
103 |
104 | resp, err := client.http.Do(req)
105 | if err != nil {
106 | return nil, wrapper.Wrap(err, "failed to perform request")
107 | }
108 |
109 | return resp, nil
110 |
111 | }
112 |
113 | //postMultipart will perform a multiple part POST request
114 | func (client *Client) postMultipart(endpoint string, buffer bytes.Buffer, contentType string) (*http.Response, error) {
115 | req, err := http.NewRequest("POST", client.URL+endpoint, &buffer)
116 | if err != nil {
117 | return nil, wrapper.Wrap(err, "error creating request")
118 | }
119 |
120 | // add the content-type so qbittorrent knows what to expect
121 | req.Header.Set("Content-Type", contentType)
122 | // add user-agent header to allow qbittorrent to identify us
123 | req.Header.Set("User-Agent", "go-qbittorrent v0.1")
124 |
125 | resp, err := client.http.Do(req)
126 | if err != nil {
127 | return nil, wrapper.Wrap(err, "failed to perform request")
128 | }
129 |
130 | return resp, nil
131 | }
132 |
133 | //writeOptions will write a map to the buffer through multipart.NewWriter
134 | func writeOptions(writer *multipart.Writer, opts map[string]string) {
135 | for key, val := range opts {
136 | writer.WriteField(key, val)
137 | }
138 | }
139 |
140 | //postMultipartData will perform a multiple part POST request without a file
141 | func (client *Client) postMultipartData(endpoint string, opts map[string]string) (*http.Response, error) {
142 | var buffer bytes.Buffer
143 | writer := multipart.NewWriter(&buffer)
144 |
145 | // write the options to the buffer
146 | // will contain the link string
147 | writeOptions(writer, opts)
148 |
149 | // close the writer before doing request to get closing line on multipart request
150 | if err := writer.Close(); err != nil {
151 | return nil, wrapper.Wrap(err, "failed to close writer")
152 | }
153 |
154 | resp, err := client.postMultipart(endpoint, buffer, writer.FormDataContentType())
155 | if err != nil {
156 | return nil, err
157 | }
158 |
159 | return resp, nil
160 | }
161 |
162 | //postMultipartFile will perform a multiple part POST request with a file
163 | func (client *Client) postMultipartFile(endpoint string, fileName string, opts map[string]string) (*http.Response, error) {
164 | var buffer bytes.Buffer
165 | writer := multipart.NewWriter(&buffer)
166 |
167 | // open the file for reading
168 | file, err := os.Open(fileName)
169 | if err != nil {
170 | return nil, wrapper.Wrap(err, "error opening file")
171 | }
172 | // defer the closing of the file until the end of function
173 | // so we can still copy its contents
174 | defer file.Close()
175 |
176 | // create form for writing the file to and give it the filename
177 | formWriter, err := writer.CreateFormFile("torrents", path.Base(fileName))
178 | if err != nil {
179 | return nil, wrapper.Wrap(err, "error adding file")
180 | }
181 |
182 | // write the options to the buffer
183 | writeOptions(writer, opts)
184 |
185 | // copy the file contents into the form
186 | if _, err = io.Copy(formWriter, file); err != nil {
187 | return nil, wrapper.Wrap(err, "error copying file")
188 | }
189 |
190 | // close the writer before doing request to get closing line on multipart request
191 | if err := writer.Close(); err != nil {
192 | return nil, wrapper.Wrap(err, "failed to close writer")
193 | }
194 |
195 | resp, err := client.postMultipart(endpoint, buffer, writer.FormDataContentType())
196 | if err != nil {
197 | return nil, err
198 | }
199 |
200 | return resp, nil
201 | }
202 |
203 | //Login logs you in to the qbittorrent client
204 | //returns the current authentication status
205 | func (client *Client) Login(username string, password string) (loggedIn bool, err error) {
206 | credentials := make(map[string]string)
207 | credentials["username"] = username
208 | credentials["password"] = password
209 |
210 | resp, err := client.post("login", credentials)
211 | if err != nil {
212 | return false, err
213 | } else if resp.Status != "200 OK" { // check for correct status code
214 | return false, wrapper.Wrap(ErrBadResponse, "couldnt log in")
215 | }
216 |
217 | // change authentication status so we know were authenticated in later requests
218 | client.Authenticated = true
219 |
220 | // add the cookie to cookie jar to authenticate later requests
221 | if cookies := resp.Cookies(); len(cookies) > 0 {
222 | cookieURL, _ := url.Parse("http://localhost:8080")
223 | client.Jar.SetCookies(cookieURL, cookies)
224 | }
225 |
226 | // create a new client with the cookie jar and replace the old one
227 | // so that all our later requests are authenticated
228 | client.http = &http.Client{
229 | Jar: client.Jar,
230 | }
231 |
232 | return client.Authenticated, nil
233 | }
234 |
235 | //Logout logs you out of the qbittorrent client
236 | //returns the current authentication status
237 | func (client *Client) Logout() (loggedOut bool, err error) {
238 | resp, err := client.get("logout", nil)
239 | if err != nil {
240 | return false, err
241 | }
242 |
243 | // check for correct status code
244 | if resp.Status != "200 OK" {
245 | return false, wrapper.Wrap(ErrBadResponse, "couldnt log in")
246 | }
247 |
248 | // change authentication status so we know were not authenticated in later requests
249 | client.Authenticated = false
250 |
251 | return client.Authenticated, nil
252 | }
253 |
254 | //Shutdown shuts down the qbittorrent client
255 | func (client *Client) Shutdown() (shuttingDown bool, err error) {
256 | resp, err := client.get("command/shutdown", nil)
257 |
258 | // return true if successful
259 | return (resp.Status == "200 OK"), err
260 | }
261 |
262 | //Torrents returns a list of all torrents in qbittorrent matching your filter
263 | func (client *Client) Torrents(filters map[string]string) (torrentList []BasicTorrent, err error) {
264 | var t []BasicTorrent
265 | resp, err := client.get("query/torrents", filters)
266 | if err != nil {
267 | return t, err
268 | }
269 | json.NewDecoder(resp.Body).Decode(&t)
270 | return t, nil
271 | }
272 |
273 | //Torrent returns a specific torrent matching the infoHash
274 | func (client *Client) Torrent(infoHash string) (Torrent, error) {
275 | var t Torrent
276 | resp, err := client.get("query/propertiesGeneral/"+strings.ToLower(infoHash), nil)
277 | if err != nil {
278 | return t, err
279 | }
280 | json.NewDecoder(resp.Body).Decode(&t)
281 | return t, nil
282 | }
283 |
284 | //TorrentTrackers returns all trackers for a specific torrent matching the infoHash
285 | func (client *Client) TorrentTrackers(infoHash string) ([]Tracker, error) {
286 | var t []Tracker
287 | resp, err := client.get("query/propertiesTrackers/"+strings.ToLower(infoHash), nil)
288 | if err != nil {
289 | return t, err
290 | }
291 | json.NewDecoder(resp.Body).Decode(&t)
292 | return t, nil
293 | }
294 |
295 | //TorrentWebSeeds returns seeders for a specific torrent matching the infoHash
296 | func (client *Client) TorrentWebSeeds(infoHash string) ([]WebSeed, error) {
297 | var w []WebSeed
298 | resp, err := client.get("query/propertiesWebSeeds/"+strings.ToLower(infoHash), nil)
299 | if err != nil {
300 | return w, err
301 | }
302 | json.NewDecoder(resp.Body).Decode(&w)
303 | return w, nil
304 | }
305 |
306 | //TorrentFiles gets the files of a specifc torrent matching the infoHash
307 | func (client *Client) TorrentFiles(infoHash string) ([]TorrentFile, error) {
308 | var t []TorrentFile
309 | resp, err := client.get("query/propertiesFiles"+strings.ToLower(infoHash), nil)
310 | if err != nil {
311 | return t, err
312 | }
313 | json.NewDecoder(resp.Body).Decode(&t)
314 | return t, nil
315 | }
316 |
317 | //Sync returns the server state and list of torrents in one struct
318 | func (client *Client) Sync(rid string) (Sync, error) {
319 | var s Sync
320 |
321 | params := make(map[string]string)
322 | params["rid"] = rid
323 |
324 | resp, err := client.get("sync/maindata", params)
325 | if err != nil {
326 | return s, err
327 | }
328 | json.NewDecoder(resp.Body).Decode(&s)
329 | return s, nil
330 | }
331 |
332 | //DownloadFromLink starts downloading a torrent from a link
333 | func (client *Client) DownloadFromLink(link string, options map[string]string) (*http.Response, error) {
334 | options["urls"] = link
335 | return client.postMultipartData("command/download", options)
336 | }
337 |
338 | //DownloadFromFile starts downloading a torrent from a file
339 | func (client *Client) DownloadFromFile(file string, options map[string]string) (*http.Response, error) {
340 | return client.postMultipartFile("command/upload", file, options)
341 | }
342 |
343 | //AddTrackers adds trackers to a specific torrent matching infoHash
344 | func (client *Client) AddTrackers(infoHash string, trackers string) (*http.Response, error) {
345 | params := make(map[string]string)
346 | params["hash"] = strings.ToLower(infoHash)
347 | params["urls"] = trackers
348 |
349 | return client.post("command/addTrackers", params)
350 | }
351 |
352 | //processInfoHashList puts list into a combined (single element) map with all hashes connected with '|'
353 | //this is how the WEBUI API recognizes multiple hashes
354 | func (Client) processInfoHashList(infoHashList []string) (hashMap map[string]string) {
355 | params := map[string]string{}
356 | infoHash := ""
357 | for i, v := range infoHashList {
358 | if i > 0 {
359 | infoHash += "|" + v
360 | } else {
361 | infoHash = v
362 | }
363 | }
364 | params["hashes"] = infoHash
365 | return params
366 | }
367 |
368 | //Pause pauses a specific torrent matching infoHash
369 | func (client *Client) Pause(infoHash string) (*http.Response, error) {
370 | params := make(map[string]string)
371 | params["hash"] = strings.ToLower(infoHash)
372 |
373 | return client.post("command/pause", params)
374 | }
375 |
376 | //PauseAll pauses all torrents
377 | func (client *Client) PauseAll() (*http.Response, error) {
378 | return client.get("command/pauseAll", nil)
379 | }
380 |
381 | //PauseMultiple pauses a list of torrents matching the infoHashes
382 | func (client *Client) PauseMultiple(infoHashList []string) (*http.Response, error) {
383 | params := client.processInfoHashList(infoHashList)
384 | return client.post("command/pauseAll", params)
385 | }
386 |
387 | //SetLabel sets the labels for a list of torrents matching infoHashes
388 | func (client *Client) SetLabel(infoHashList []string, label string) (*http.Response, error) {
389 | params := client.processInfoHashList(infoHashList)
390 | params["label"] = label
391 |
392 | return client.post("command/setLabel", params)
393 | }
394 |
395 | //SetCategory sets the category for a list of torrents matching infoHashes
396 | func (client *Client) SetCategory(infoHashList []string, category string) (*http.Response, error) {
397 | params := client.processInfoHashList(infoHashList)
398 | params["category"] = category
399 |
400 | return client.post("command/setLabel", params)
401 | }
402 |
403 | //Resume resumes a specific torrent matching infoHash
404 | func (client *Client) Resume(infoHash string) (*http.Response, error) {
405 | params := make(map[string]string)
406 | params["hash"] = strings.ToLower(infoHash)
407 | return client.post("command/resume", params)
408 | }
409 |
410 | //ResumeAll resumes all torrents matching infoHashes
411 | func (client *Client) ResumeAll(infoHashList []string) (*http.Response, error) {
412 | return client.get("command/resumeAll", nil)
413 | }
414 |
415 | //ResumeMultiple resumes a list of torrents matching infoHashes
416 | func (client *Client) ResumeMultiple(infoHashList []string) (*http.Response, error) {
417 | params := client.processInfoHashList(infoHashList)
418 | return client.post("command/resumeAll", params)
419 | }
420 |
421 | //DeleteTemp deletes the temporary files for a list of torrents matching infoHashes
422 | func (client *Client) DeleteTemp(infoHashList []string) (*http.Response, error) {
423 | params := client.processInfoHashList(infoHashList)
424 | return client.post("command/delete", params)
425 | }
426 |
427 | //DeletePermanently deletes all files for a list of torrents matching infoHashes
428 | func (client *Client) DeletePermanently(infoHashList []string) (*http.Response, error) {
429 | params := client.processInfoHashList(infoHashList)
430 | return client.post("command/deletePerm", params)
431 | }
432 |
433 | //Recheck rechecks a list of torrents matching infoHashes
434 | func (client *Client) Recheck(infoHashList []string) (*http.Response, error) {
435 | params := client.processInfoHashList(infoHashList)
436 | return client.post("command/recheck", params)
437 | }
438 |
439 | //IncreasePriority increases the priority of a list of torrents matching infoHashes
440 | func (client *Client) IncreasePriority(infoHashList []string) (*http.Response, error) {
441 | params := client.processInfoHashList(infoHashList)
442 | return client.post("command/increasePrio", params)
443 | }
444 |
445 | //DecreasePriority decreases the priority of a list of torrents matching infoHashes
446 | func (client *Client) DecreasePriority(infoHashList []string) (*http.Response, error) {
447 | params := client.processInfoHashList(infoHashList)
448 | return client.post("command/decreasePrio", params)
449 | }
450 |
451 | //SetMaxPriority sets the max priority for a list of torrents matching infoHashes
452 | func (client *Client) SetMaxPriority(infoHashList []string) (*http.Response, error) {
453 | params := client.processInfoHashList(infoHashList)
454 | return client.post("command/topPrio", params)
455 | }
456 |
457 | //SetMinPriority sets the min priority for a list of torrents matching infoHashes
458 | func (client *Client) SetMinPriority(infoHashList []string) (*http.Response, error) {
459 | params := client.processInfoHashList(infoHashList)
460 | return client.post("command/bottomPrio", params)
461 | }
462 |
463 | //SetFilePriority sets the priority for a specific torrent filematching infoHash
464 | func (client *Client) SetFilePriority(infoHash string, fileID string, priority string) (*http.Response, error) {
465 | // disallow certain priorities that are not allowed by the WEBUI API
466 | priorities := [...]string{"0", "1", "2", "7"}
467 | for _, v := range priorities {
468 | if v == priority {
469 | return nil, ErrBadPriority
470 | }
471 | }
472 |
473 | params := make(map[string]string)
474 | params["hash"] = infoHash
475 | params["id"] = fileID
476 | params["priority"] = priority
477 |
478 | return client.post("command/setFilePriority", params)
479 | }
480 |
481 | //GetGlobalDownloadLimit gets the global download limit of your qbittorrent client
482 | func (client *Client) GetGlobalDownloadLimit() (limit int, err error) {
483 | var l int
484 | resp, err := client.get("command/getGlobalDlLimit", nil)
485 | if err != nil {
486 | return l, err
487 | }
488 | json.NewDecoder(resp.Body).Decode(&l)
489 | return l, nil
490 | }
491 |
492 | //SetGlobalDownloadLimit sets the global download limit of your qbittorrent client
493 | func (client *Client) SetGlobalDownloadLimit(limit string) (*http.Response, error) {
494 | params := make(map[string]string)
495 | params["limit"] = limit
496 | return client.post("command/setGlobalDlLimit", params)
497 | }
498 |
499 | //GetGlobalUploadLimit gets the global upload limit of your qbittorrent client
500 | func (client *Client) GetGlobalUploadLimit() (limit int, err error) {
501 | var l int
502 | resp, err := client.get("command/getGlobalUpLimit", nil)
503 | if err != nil {
504 | return l, err
505 | }
506 | json.NewDecoder(resp.Body).Decode(&l)
507 | return l, nil
508 | }
509 |
510 | //SetGlobalUploadLimit sets the global upload limit of your qbittorrent client
511 | func (client *Client) SetGlobalUploadLimit(limit string) (*http.Response, error) {
512 | params := make(map[string]string)
513 | params["limit"] = limit
514 | return client.post("command/setGlobalUpLimit", params)
515 | }
516 |
517 | //GetTorrentDownloadLimit gets the download limit for a list of torrents
518 | func (client *Client) GetTorrentDownloadLimit(infoHashList []string) (limits map[string]string, err error) {
519 | var l map[string]string
520 | params := client.processInfoHashList(infoHashList)
521 | resp, err := client.post("command/getTorrentsDlLimit", params)
522 | if err != nil {
523 | return l, err
524 | }
525 | json.NewDecoder(resp.Body).Decode(&l)
526 | return l, nil
527 | }
528 |
529 | //SetTorrentDownloadLimit sets the download limit for a list of torrents
530 | func (client *Client) SetTorrentDownloadLimit(infoHashList []string, limit string) (*http.Response, error) {
531 | params := client.processInfoHashList(infoHashList)
532 | params["limit"] = limit
533 | return client.post("command/setTorrentsDlLimit", params)
534 | }
535 |
536 | //GetTorrentUploadLimit gets the upload limit for a list of torrents
537 | func (client *Client) GetTorrentUploadLimit(infoHashList []string) (limits map[string]string, err error) {
538 | var l map[string]string
539 | params := client.processInfoHashList(infoHashList)
540 | resp, err := client.post("command/getTorrentsUpLimit", params)
541 | if err != nil {
542 | return l, err
543 | }
544 | json.NewDecoder(resp.Body).Decode(&l)
545 | return l, nil
546 | }
547 |
548 | //SetTorrentUploadLimit sets the upload limit of a list of torrents
549 | func (client *Client) SetTorrentUploadLimit(infoHashList []string, limit string) (*http.Response, error) {
550 | params := client.processInfoHashList(infoHashList)
551 | params["limit"] = limit
552 | return client.post("command/setTorrentsUpLimit", params)
553 | }
554 |
555 | //SetPreferences sets the preferences of your qbittorrent client
556 | func (client *Client) SetPreferences(params map[string]string) (*http.Response, error) {
557 | return client.post("command/setPreferences", params)
558 | }
559 |
560 | //GetAlternativeSpeedStatus gets the alternative speed status of your qbittorrent client
561 | func (client *Client) GetAlternativeSpeedStatus() (status bool, err error) {
562 | var s bool
563 | resp, err := client.get("command/alternativeSpeedLimitsEnabled", nil)
564 | if err != nil {
565 | return s, err
566 | }
567 | json.NewDecoder(resp.Body).Decode(&s)
568 | return s, nil
569 | }
570 |
571 | //ToggleAlternativeSpeed toggles the alternative speed of your qbittorrent client
572 | func (client *Client) ToggleAlternativeSpeed() (*http.Response, error) {
573 | return client.get("command/toggleAlternativeSpeedLimits", nil)
574 | }
575 |
576 | //ToggleSequentialDownload toggles the download sequence of a list of torrents
577 | func (client *Client) ToggleSequentialDownload(infoHashList []string) (*http.Response, error) {
578 | params := client.processInfoHashList(infoHashList)
579 | return client.post("command/toggleSequentialDownload", params)
580 | }
581 |
582 | //ToggleFirstLastPiecePriority toggles first last piece priority of a list of torrents
583 | func (client *Client) ToggleFirstLastPiecePriority(infoHashList []string) (*http.Response, error) {
584 | params := client.processInfoHashList(infoHashList)
585 | return client.post("command/toggleFirstLastPiecePrio", params)
586 | }
587 |
588 | //ForceStart force starts a list of torrents
589 | func (client *Client) ForceStart(infoHashList []string, value bool) (*http.Response, error) {
590 | params := client.processInfoHashList(infoHashList)
591 | params["value"] = strconv.FormatBool(value)
592 | return client.post("command/setForceStart", params)
593 | }
594 |
--------------------------------------------------------------------------------
/.idea/workspace.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
58 |
59 |
60 |
61 | (c Client)
62 | params := map[string]string{}
63 | file
64 | printResp
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 | true
95 | DEFINITION_ORDER
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 | project
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
306 |
307 |
308 |
309 |
310 |
311 |
312 |
313 |
314 |
315 |
316 |
317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 |
326 |
327 |
328 |
329 |
330 |
331 |
332 |
333 |
334 |
335 |
336 |
337 |
338 |
339 |
340 |
341 |
342 |
343 |
344 |
345 |
346 |
347 |
348 |
349 |
350 |
351 |
352 |
353 |
354 |
355 |
356 |
357 |
358 |
359 |
360 |
361 |
362 |
363 |
364 |
365 |
366 |
367 |
368 |
369 |
370 |
371 |
372 |
373 |
374 |
375 |
376 |
377 |
378 |
379 |
380 |
381 |
382 |
383 |
384 |
385 |
386 |
387 |
388 |
389 |
390 |
391 |
392 |
393 |
394 |
395 |
396 |
397 |
398 |
399 |
400 |
401 |
402 |
403 |
404 |
405 |
406 |
407 |
408 |
409 |
410 |
411 |
412 |
413 |
414 |
415 |
416 |
417 |
418 |
419 |
420 |
421 |
422 |
423 |
424 |
425 |
426 |
427 |
428 |
429 |
430 |
431 |
432 |
433 |
434 |
435 |
436 |
437 |
--------------------------------------------------------------------------------