├── .github ├── dependabot.yml └── workflows │ └── test.yaml ├── .gitignore ├── LICENSE ├── README.md ├── domain.go ├── domain_test.go ├── errors ├── errors.go └── errors_test.go ├── examples └── basic │ └── main.go ├── go.mod ├── go.sum ├── http.go ├── maindata.go ├── methods.go ├── methods_test.go └── qbittorrent.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | day: saturday 8 | time: "07:00" 9 | groups: 10 | github: 11 | patterns: 12 | - "*" 13 | 14 | - package-ecosystem: gomod 15 | directory: / 16 | schedule: 17 | interval: monthly 18 | groups: 19 | golang: 20 | patterns: 21 | - "*" 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | - "develop" 8 | tags: 9 | - 'v*' 10 | pull_request: 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: '1.21.0' 26 | cache: true 27 | 28 | - name: Test 29 | run: go test -tags ci -v ./... 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs and databases # 2 | ###################### 3 | *.log 4 | 5 | # OS generated files # 6 | ###################### 7 | .DS_Store 8 | .DS_Store? 9 | ._* 10 | .Spotlight-V100 11 | .Trashes 12 | ehthumbs.db 13 | Thumbs.db 14 | 15 | # Other 16 | .idea 17 | eb/build 18 | bin/ 19 | log/ 20 | dist/ 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 autobrr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-qbittorrent 2 | 3 | Go library for communicating with qBittorrent. 4 | -------------------------------------------------------------------------------- /domain.go: -------------------------------------------------------------------------------- 1 | package qbittorrent 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/autobrr/go-qbittorrent/errors" 7 | ) 8 | 9 | var ( 10 | ErrBadCredentials = errors.New("bad credentials") 11 | ErrIPBanned = errors.New("User's IP is banned for too many failed login attempts") 12 | 13 | ErrUnexpectedStatus = errors.New("unexpected status code") 14 | 15 | ErrNoTorrentURLProvided = errors.New("no torrent URL provided") 16 | ErrEmptySavePath = errors.New("save path is empty") 17 | ErrNoWriteAccessToPath = errors.New("user does not have write access to directory") 18 | ErrCannotCreateSavePath = errors.New("unable to create save path directory") 19 | ErrEmptyCategoryName = errors.New("category name is empty") 20 | ErrInvalidCategoryName = errors.New("category name is invalid") 21 | ErrCategoryEditingFailed = errors.New("category editing failed") 22 | ErrCategoryDoesNotExist = errors.New("category name does not exist") 23 | ErrInvalidPriority = errors.New("priority is invalid or at least one id is not an integer") 24 | ErrTorrentNotFound = errors.New("torrent not found") 25 | ErrTorrentMetdataNotDownloadedYet = errors.New("torrent metadata hasn't downloaded yet or at least one file id was not found") 26 | ErrMissingNewPathParameter = errors.New("missing newPath parameter") 27 | ErrInvalidPathParameter = errors.New("invalid newPath or oldPath, or newPath already in use") 28 | ErrInvalidTorrentHash = errors.New("torrent hash is invalid") 29 | ErrEmptyTorrentName = errors.New("torrent name is empty") 30 | ErrAllURLsNotFound = errors.New("all urls were not found") 31 | ErrInvalidURL = errors.New("new url is not a valid URL") 32 | ErrTorrentQueueingNotEnabled = errors.New("torrent queueing is not enabled, could not set hashes to max priority") 33 | ErrInvalidShareLimit = errors.New("a share limit or at least one id is invalid") 34 | ErrInvalidCookies = errors.New("request was not a valid json array of cookie objects") 35 | ErrCannotGetTorrentPieceStates = errors.New("could not get torrent piece states") 36 | ErrInvalidPeers = errors.New("none of the supplied peers are valid") 37 | 38 | ErrReannounceTookTooLong = errors.New("reannounce took too long, deleted torrent") 39 | ErrUnsupportedVersion = errors.New("qBittorrent version too old, please upgrade to use this feature") 40 | ) 41 | 42 | type Torrent struct { 43 | AddedOn int64 `json:"added_on"` 44 | AmountLeft int64 `json:"amount_left"` 45 | AutoManaged bool `json:"auto_tmm"` 46 | Availability float64 `json:"availability"` 47 | Category string `json:"category"` 48 | Completed int64 `json:"completed"` 49 | CompletionOn int64 `json:"completion_on"` 50 | ContentPath string `json:"content_path"` 51 | DlLimit int64 `json:"dl_limit"` 52 | DlSpeed int64 `json:"dlspeed"` 53 | DownloadPath string `json:"download_path"` 54 | Downloaded int64 `json:"downloaded"` 55 | DownloadedSession int64 `json:"downloaded_session"` 56 | ETA int64 `json:"eta"` 57 | FirstLastPiecePrio bool `json:"f_l_piece_prio"` 58 | ForceStart bool `json:"force_start"` 59 | Hash string `json:"hash"` 60 | InfohashV1 string `json:"infohash_v1"` 61 | InfohashV2 string `json:"infohash_v2"` 62 | LastActivity int64 `json:"last_activity"` 63 | MagnetURI string `json:"magnet_uri"` 64 | MaxRatio float64 `json:"max_ratio"` 65 | MaxSeedingTime int64 `json:"max_seeding_time"` 66 | Name string `json:"name"` 67 | NumComplete int64 `json:"num_complete"` 68 | NumIncomplete int64 `json:"num_incomplete"` 69 | NumLeechs int64 `json:"num_leechs"` 70 | NumSeeds int64 `json:"num_seeds"` 71 | Priority int64 `json:"priority"` 72 | Progress float64 `json:"progress"` 73 | Ratio float64 `json:"ratio"` 74 | RatioLimit float64 `json:"ratio_limit"` 75 | SavePath string `json:"save_path"` 76 | SeedingTime int64 `json:"seeding_time"` 77 | SeedingTimeLimit int64 `json:"seeding_time_limit"` 78 | SeenComplete int64 `json:"seen_complete"` 79 | SequentialDownload bool `json:"seq_dl"` 80 | Size int64 `json:"size"` 81 | State TorrentState `json:"state"` 82 | SuperSeeding bool `json:"super_seeding"` 83 | Tags string `json:"tags"` 84 | TimeActive int64 `json:"time_active"` 85 | TotalSize int64 `json:"total_size"` 86 | Tracker string `json:"tracker"` 87 | TrackersCount int64 `json:"trackers_count"` 88 | UpLimit int64 `json:"up_limit"` 89 | Uploaded int64 `json:"uploaded"` 90 | UploadedSession int64 `json:"uploaded_session"` 91 | UpSpeed int64 `json:"upspeed"` 92 | Trackers []TorrentTracker `json:"trackers"` 93 | } 94 | 95 | type TorrentTrackersResponse struct { 96 | Trackers []TorrentTracker `json:"trackers"` 97 | } 98 | 99 | type TorrentTracker struct { 100 | // Tier int `json:"tier"` // can be both empty "" and int 101 | Url string `json:"url"` 102 | Status TrackerStatus `json:"status"` 103 | NumPeers int `json:"num_peers"` 104 | NumSeeds int `json:"num_seeds"` 105 | NumLeechers int `json:"num_leechers"` 106 | NumDownloaded int `json:"num_downloaded"` 107 | Message string `json:"msg"` 108 | } 109 | 110 | type TorrentFiles []struct { 111 | Availability float32 `json:"availability"` 112 | Index int `json:"index"` 113 | IsSeed bool `json:"is_seed,omitempty"` 114 | Name string `json:"name"` 115 | PieceRange []int `json:"piece_range"` 116 | Priority int `json:"priority"` 117 | Progress float32 `json:"progress"` 118 | Size int64 `json:"size"` 119 | } 120 | 121 | type Category struct { 122 | Name string `json:"name"` 123 | SavePath string `json:"savePath"` 124 | } 125 | 126 | type TorrentState string 127 | 128 | const ( 129 | // Some error occurred, applies to paused torrents 130 | TorrentStateError TorrentState = "error" 131 | 132 | // Torrent data files is missing 133 | TorrentStateMissingFiles TorrentState = "missingFiles" 134 | 135 | // Torrent is being seeded and data is being transferred 136 | TorrentStateUploading TorrentState = "uploading" 137 | 138 | // Torrent is paused and has finished downloading 139 | TorrentStatePausedUp TorrentState = "pausedUP" 140 | 141 | // Torrent is stopped and has finished downloading 142 | TorrentStateStoppedUp TorrentState = "stoppedUP" 143 | 144 | // Queuing is enabled and torrent is queued for upload 145 | TorrentStateQueuedUp TorrentState = "queuedUP" 146 | 147 | // Torrent is being seeded, but no connection were made 148 | TorrentStateStalledUp TorrentState = "stalledUP" 149 | 150 | // Torrent has finished downloading and is being checked 151 | TorrentStateCheckingUp TorrentState = "checkingUP" 152 | 153 | // Torrent is forced to uploading and ignore queue limit 154 | TorrentStateForcedUp TorrentState = "forcedUP" 155 | 156 | // Torrent is allocating disk space for download 157 | TorrentStateAllocating TorrentState = "allocating" 158 | 159 | // Torrent is being downloaded and data is being transferred 160 | TorrentStateDownloading TorrentState = "downloading" 161 | 162 | // Torrent has just started downloading and is fetching metadata 163 | TorrentStateMetaDl TorrentState = "metaDL" 164 | 165 | // Torrent is paused and has NOT finished downloading 166 | TorrentStatePausedDl TorrentState = "pausedDL" 167 | 168 | // Torrent is stopped and has NOT finished downloading 169 | TorrentStateStoppedDl TorrentState = "stoppedDL" 170 | 171 | // Queuing is enabled and torrent is queued for download 172 | TorrentStateQueuedDl TorrentState = "queuedDL" 173 | 174 | // Torrent is being downloaded, but no connection were made 175 | TorrentStateStalledDl TorrentState = "stalledDL" 176 | 177 | // Same as checkingUP, but torrent has NOT finished downloading 178 | TorrentStateCheckingDl TorrentState = "checkingDL" 179 | 180 | // Torrent is forced to downloading to ignore queue limit 181 | TorrentStateForcedDl TorrentState = "forcedDL" 182 | 183 | // Checking resume data on qBt startup 184 | TorrentStateCheckingResumeData TorrentState = "checkingResumeData" 185 | 186 | // Torrent is moving to another location 187 | TorrentStateMoving TorrentState = "moving" 188 | 189 | // Unknown status 190 | TorrentStateUnknown TorrentState = "unknown" 191 | ) 192 | 193 | type TorrentFilter string 194 | 195 | const ( 196 | // Torrent is paused 197 | TorrentFilterAll TorrentFilter = "all" 198 | 199 | // Torrent is active 200 | TorrentFilterActive TorrentFilter = "active" 201 | 202 | // Torrent is inactive 203 | TorrentFilterInactive TorrentFilter = "inactive" 204 | 205 | // Torrent is completed 206 | TorrentFilterCompleted TorrentFilter = "completed" 207 | 208 | // Torrent is resumed 209 | TorrentFilterResumed TorrentFilter = "resumed" 210 | 211 | // Torrent is paused 212 | TorrentFilterPaused TorrentFilter = "paused" 213 | 214 | // Torrent is stopped 215 | TorrentFilterStopped TorrentFilter = "stopped" 216 | 217 | // Torrent is stalled 218 | TorrentFilterStalled TorrentFilter = "stalled" 219 | 220 | // Torrent is being seeded and data is being transferred 221 | TorrentFilterUploading TorrentFilter = "uploading" 222 | 223 | // Torrent is being seeded, but no connection were made 224 | TorrentFilterStalledUploading TorrentFilter = "stalled_uploading" 225 | 226 | // Torrent is being downloaded and data is being transferred 227 | TorrentFilterDownloading TorrentFilter = "downloading" 228 | 229 | // Torrent is being downloaded, but no connection were made 230 | TorrentFilterStalledDownloading TorrentFilter = "stalled_downloading" 231 | 232 | // Torrent is errored 233 | TorrentFilterError TorrentFilter = "errored" 234 | ) 235 | 236 | // TrackerStatus https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-trackers 237 | type TrackerStatus int 238 | 239 | const ( 240 | // 0 Tracker is disabled (used for DHT, PeX, and LSD) 241 | TrackerStatusDisabled TrackerStatus = 0 242 | 243 | // 1 Tracker has not been contacted yet 244 | TrackerStatusNotContacted TrackerStatus = 1 245 | 246 | // 2 Tracker has been contacted and is working 247 | TrackerStatusOK TrackerStatus = 2 248 | 249 | // 3 Tracker is updating 250 | TrackerStatusUpdating TrackerStatus = 3 251 | 252 | // 4 Tracker has been contacted, but it is not working (or doesn't send proper replies) 253 | TrackerStatusNotWorking TrackerStatus = 4 254 | ) 255 | 256 | type ConnectionStatus string 257 | 258 | const ( 259 | ConnectionStatusConnected = "connected" 260 | ConnectionStatusFirewalled = "firewalled" 261 | ConnectionStatusDisconnected = "disconnected" 262 | ) 263 | 264 | // TransferInfo 265 | // 266 | // https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-global-transfer-info 267 | // 268 | // dl_info_speed integer Global download rate (bytes/s) 269 | // 270 | // dl_info_data integer Data downloaded this session (bytes) 271 | // 272 | // up_info_speed integer Global upload rate (bytes/s) 273 | // 274 | // up_info_data integer Data uploaded this session (bytes) 275 | // 276 | // dl_rate_limit integer Download rate limit (bytes/s) 277 | // 278 | // up_rate_limit integer Upload rate limit (bytes/s) 279 | // 280 | // dht_nodes integer DHT nodes connected to 281 | // 282 | // connection_status string Connection status. See possible values here below 283 | type TransferInfo struct { 284 | ConnectionStatus ConnectionStatus `json:"connection_status"` 285 | DHTNodes int64 `json:"dht_nodes"` 286 | DlInfoData int64 `json:"dl_info_data"` 287 | DlInfoSpeed int64 `json:"dl_info_speed"` 288 | DlRateLimit int64 `json:"dl_rate_limit"` 289 | UpInfoData int64 `json:"up_info_data"` 290 | UpInfoSpeed int64 `json:"up_info_speed"` 291 | UpRateLimit int64 `json:"up_rate_limit"` 292 | } 293 | 294 | type ContentLayout string 295 | 296 | const ( 297 | ContentLayoutOriginal ContentLayout = "Original" 298 | ContentLayoutSubfolderNone ContentLayout = "NoSubfolder" 299 | ContentLayoutSubfolderCreate ContentLayout = "Subfolder" 300 | ) 301 | 302 | type TorrentAddOptions struct { 303 | Stopped bool // introduced in Web API v2.11.0 (v5.0.0) 304 | Paused bool 305 | SkipHashCheck bool 306 | ContentLayout ContentLayout 307 | SavePath string 308 | AutoTMM bool 309 | Category string 310 | Tags string 311 | LimitUploadSpeed int64 312 | LimitDownloadSpeed int64 313 | LimitRatio float64 314 | LimitSeedTime int64 315 | Rename string 316 | FirstLastPiecePrio bool 317 | SequentialDownload bool 318 | } 319 | 320 | func (o *TorrentAddOptions) Prepare() map[string]string { 321 | options := map[string]string{} 322 | 323 | options["paused"] = "false" 324 | options["stopped"] = "false" 325 | if o.Paused { 326 | options["paused"] = "true" 327 | options["stopped"] = "true" 328 | } 329 | if o.Stopped { 330 | options["paused"] = "true" 331 | options["stopped"] = "true" 332 | } 333 | if o.SkipHashCheck { 334 | options["skip_checking"] = "true" 335 | } 336 | 337 | if o.ContentLayout == ContentLayoutSubfolderCreate { 338 | // pre qBittorrent version 4.3.2 339 | options["root_folder"] = "true" 340 | 341 | // post version 4.3.2 342 | options["contentLayout"] = string(ContentLayoutSubfolderCreate) 343 | 344 | } else if o.ContentLayout == ContentLayoutSubfolderNone { 345 | // pre qBittorrent version 4.3.2 346 | options["root_folder"] = "false" 347 | 348 | // post version 4.3.2 349 | options["contentLayout"] = string(ContentLayoutSubfolderNone) 350 | } 351 | // if ORIGINAL then leave empty 352 | 353 | if o.SavePath != "" { 354 | options["savepath"] = o.SavePath 355 | options["autoTMM"] = "false" 356 | } 357 | if o.Category != "" { 358 | options["category"] = o.Category 359 | } 360 | if o.Tags != "" { 361 | options["tags"] = o.Tags 362 | } 363 | if o.LimitUploadSpeed > 0 { 364 | options["upLimit"] = strconv.FormatInt(o.LimitUploadSpeed*1024, 10) 365 | } 366 | if o.LimitDownloadSpeed > 0 { 367 | options["dlLimit"] = strconv.FormatInt(o.LimitDownloadSpeed*1024, 10) 368 | } 369 | if o.LimitRatio > 0 { 370 | options["ratioLimit"] = strconv.FormatFloat(o.LimitRatio, 'f', 2, 64) 371 | } 372 | if o.LimitSeedTime > 0 { 373 | options["seedingTimeLimit"] = strconv.FormatInt(o.LimitSeedTime, 10) 374 | } 375 | 376 | if o.Rename != "" { 377 | options["rename"] = o.Rename 378 | } 379 | 380 | options["firstLastPiecePrio"] = strconv.FormatBool(o.FirstLastPiecePrio) 381 | 382 | if o.SequentialDownload { 383 | options["sequentialDownload"] = "true" 384 | } 385 | 386 | return options 387 | } 388 | 389 | type TorrentFilterOptions struct { 390 | Filter TorrentFilter 391 | Category string 392 | Tag string 393 | Sort string 394 | Reverse bool 395 | Limit int 396 | Offset int 397 | Hashes []string 398 | IncludeTrackers bool // qbit 5.1+ 399 | } 400 | 401 | type TorrentProperties struct { 402 | AdditionDate int `json:"addition_date"` 403 | Comment string `json:"comment"` 404 | CompletionDate int `json:"completion_date"` 405 | CreatedBy string `json:"created_by"` 406 | CreationDate int `json:"creation_date"` 407 | DlLimit int `json:"dl_limit"` 408 | DlSpeed int `json:"dl_speed"` 409 | DlSpeedAvg int `json:"dl_speed_avg"` 410 | DownloadPath string `json:"download_path"` 411 | Eta int `json:"eta"` 412 | Hash string `json:"hash"` 413 | InfohashV1 string `json:"infohash_v1"` 414 | InfohashV2 string `json:"infohash_v2"` 415 | IsPrivate bool `json:"is_private"` 416 | LastSeen int `json:"last_seen"` 417 | Name string `json:"name"` 418 | NbConnections int `json:"nb_connections"` 419 | NbConnectionsLimit int `json:"nb_connections_limit"` 420 | Peers int `json:"peers"` 421 | PeersTotal int `json:"peers_total"` 422 | PieceSize int `json:"piece_size"` 423 | PiecesHave int `json:"pieces_have"` 424 | PiecesNum int `json:"pieces_num"` 425 | Reannounce int `json:"reannounce"` 426 | SavePath string `json:"save_path"` 427 | SeedingTime int `json:"seeding_time"` 428 | Seeds int `json:"seeds"` 429 | SeedsTotal int `json:"seeds_total"` 430 | ShareRatio float64 `json:"share_ratio"` 431 | TimeElapsed int `json:"time_elapsed"` 432 | TotalDownloaded int64 `json:"total_downloaded"` 433 | TotalDownloadedSession int64 `json:"total_downloaded_session"` 434 | TotalSize int64 `json:"total_size"` 435 | TotalUploaded int64 `json:"total_uploaded"` 436 | TotalUploadedSession int64 `json:"total_uploaded_session"` 437 | TotalWasted int64 `json:"total_wasted"` 438 | UpLimit int `json:"up_limit"` 439 | UpSpeed int `json:"up_speed"` 440 | UpSpeedAvg int `json:"up_speed_avg"` 441 | } 442 | 443 | type AppPreferences struct { 444 | AddTrackers string `json:"add_trackers"` 445 | AddTrackersEnabled bool `json:"add_trackers_enabled"` 446 | AltDlLimit int `json:"alt_dl_limit"` 447 | AltUpLimit int `json:"alt_up_limit"` 448 | AlternativeWebuiEnabled bool `json:"alternative_webui_enabled"` 449 | AlternativeWebuiPath string `json:"alternative_webui_path"` 450 | AnnounceIP string `json:"announce_ip"` 451 | AnnounceToAllTiers bool `json:"announce_to_all_tiers"` 452 | AnnounceToAllTrackers bool `json:"announce_to_all_trackers"` 453 | AnonymousMode bool `json:"anonymous_mode"` 454 | AsyncIoThreads int `json:"async_io_threads"` 455 | AutoDeleteMode int `json:"auto_delete_mode"` 456 | AutoTmmEnabled bool `json:"auto_tmm_enabled"` 457 | AutorunEnabled bool `json:"autorun_enabled"` 458 | AutorunOnTorrentAddedEnabled bool `json:"autorun_on_torrent_added_enabled"` 459 | AutorunOnTorrentAddedProgram string `json:"autorun_on_torrent_added_program"` 460 | AutorunProgram string `json:"autorun_program"` 461 | BannedIPs string `json:"banned_IPs"` 462 | BittorrentProtocol int `json:"bittorrent_protocol"` 463 | BlockPeersOnPrivilegedPorts bool `json:"block_peers_on_privileged_ports"` 464 | BypassAuthSubnetWhitelist string `json:"bypass_auth_subnet_whitelist"` 465 | BypassAuthSubnetWhitelistEnabled bool `json:"bypass_auth_subnet_whitelist_enabled"` 466 | BypassLocalAuth bool `json:"bypass_local_auth"` 467 | CategoryChangedTmmEnabled bool `json:"category_changed_tmm_enabled"` 468 | CheckingMemoryUse int `json:"checking_memory_use"` 469 | ConnectionSpeed int `json:"connection_speed"` 470 | CurrentInterfaceAddress string `json:"current_interface_address"` 471 | CurrentNetworkInterface string `json:"current_network_interface"` 472 | Dht bool `json:"dht"` 473 | DiskCache int `json:"disk_cache"` 474 | DiskCacheTTL int `json:"disk_cache_ttl"` 475 | DiskIoReadMode int `json:"disk_io_read_mode"` 476 | DiskIoType int `json:"disk_io_type"` 477 | DiskIoWriteMode int `json:"disk_io_write_mode"` 478 | DiskQueueSize int `json:"disk_queue_size"` 479 | DlLimit int `json:"dl_limit"` 480 | DontCountSlowTorrents bool `json:"dont_count_slow_torrents"` 481 | DyndnsDomain string `json:"dyndns_domain"` 482 | DyndnsEnabled bool `json:"dyndns_enabled"` 483 | DyndnsPassword string `json:"dyndns_password"` 484 | DyndnsService int `json:"dyndns_service"` 485 | DyndnsUsername string `json:"dyndns_username"` 486 | EmbeddedTrackerPort int `json:"embedded_tracker_port"` 487 | EmbeddedTrackerPortForwarding bool `json:"embedded_tracker_port_forwarding"` 488 | EnableCoalesceReadWrite bool `json:"enable_coalesce_read_write"` 489 | EnableEmbeddedTracker bool `json:"enable_embedded_tracker"` 490 | EnableMultiConnectionsFromSameIP bool `json:"enable_multi_connections_from_same_ip"` 491 | EnablePieceExtentAffinity bool `json:"enable_piece_extent_affinity"` 492 | EnableUploadSuggestions bool `json:"enable_upload_suggestions"` 493 | Encryption int `json:"encryption"` 494 | ExcludedFileNames string `json:"excluded_file_names"` 495 | ExcludedFileNamesEnabled bool `json:"excluded_file_names_enabled"` 496 | ExportDir string `json:"export_dir"` 497 | ExportDirFin string `json:"export_dir_fin"` 498 | FilePoolSize int `json:"file_pool_size"` 499 | HashingThreads int `json:"hashing_threads"` 500 | IdnSupportEnabled bool `json:"idn_support_enabled"` 501 | IncompleteFilesExt bool `json:"incomplete_files_ext"` 502 | IPFilterEnabled bool `json:"ip_filter_enabled"` 503 | IPFilterPath string `json:"ip_filter_path"` 504 | IPFilterTrackers bool `json:"ip_filter_trackers"` 505 | LimitLanPeers bool `json:"limit_lan_peers"` 506 | LimitTCPOverhead bool `json:"limit_tcp_overhead"` 507 | LimitUtpRate bool `json:"limit_utp_rate"` 508 | ListenPort int `json:"listen_port"` 509 | Locale string `json:"locale"` 510 | Lsd bool `json:"lsd"` 511 | MailNotificationAuthEnabled bool `json:"mail_notification_auth_enabled"` 512 | MailNotificationEmail string `json:"mail_notification_email"` 513 | MailNotificationEnabled bool `json:"mail_notification_enabled"` 514 | MailNotificationPassword string `json:"mail_notification_password"` 515 | MailNotificationSender string `json:"mail_notification_sender"` 516 | MailNotificationSMTP string `json:"mail_notification_smtp"` 517 | MailNotificationSslEnabled bool `json:"mail_notification_ssl_enabled"` 518 | MailNotificationUsername string `json:"mail_notification_username"` 519 | MaxActiveCheckingTorrents int `json:"max_active_checking_torrents"` 520 | MaxActiveDownloads int `json:"max_active_downloads"` 521 | MaxActiveTorrents int `json:"max_active_torrents"` 522 | MaxActiveUploads int `json:"max_active_uploads"` 523 | MaxConcurrentHTTPAnnounces int `json:"max_concurrent_http_announces"` 524 | MaxConnec int `json:"max_connec"` 525 | MaxConnecPerTorrent int `json:"max_connec_per_torrent"` 526 | MaxRatio float64 `json:"max_ratio"` 527 | MaxRatioAct int `json:"max_ratio_act"` 528 | MaxRatioEnabled bool `json:"max_ratio_enabled"` 529 | MaxSeedingTime int `json:"max_seeding_time"` 530 | MaxSeedingTimeEnabled bool `json:"max_seeding_time_enabled"` 531 | MaxUploads int `json:"max_uploads"` 532 | MaxUploadsPerTorrent int `json:"max_uploads_per_torrent"` 533 | MemoryWorkingSetLimit int `json:"memory_working_set_limit"` 534 | OutgoingPortsMax int `json:"outgoing_ports_max"` 535 | OutgoingPortsMin int `json:"outgoing_ports_min"` 536 | PeerTos int `json:"peer_tos"` 537 | PeerTurnover int `json:"peer_turnover"` 538 | PeerTurnoverCutoff int `json:"peer_turnover_cutoff"` 539 | PeerTurnoverInterval int `json:"peer_turnover_interval"` 540 | PerformanceWarning bool `json:"performance_warning"` 541 | Pex bool `json:"pex"` 542 | PreallocateAll bool `json:"preallocate_all"` 543 | ProxyAuthEnabled bool `json:"proxy_auth_enabled"` 544 | ProxyHostnameLookup bool `json:"proxy_hostname_lookup"` 545 | ProxyIP string `json:"proxy_ip"` 546 | ProxyPassword string `json:"proxy_password"` 547 | ProxyPeerConnections bool `json:"proxy_peer_connections"` 548 | ProxyPort int `json:"proxy_port"` 549 | ProxyTorrentsOnly bool `json:"proxy_torrents_only"` 550 | ProxyType interface{} `json:"proxy_type"` // pre 4.5.x this is an int and post 4.6.x it's a string 551 | ProxyUsername string `json:"proxy_username"` 552 | QueueingEnabled bool `json:"queueing_enabled"` 553 | RandomPort bool `json:"random_port"` 554 | ReannounceWhenAddressChanged bool `json:"reannounce_when_address_changed"` 555 | RecheckCompletedTorrents bool `json:"recheck_completed_torrents"` 556 | RefreshInterval int `json:"refresh_interval"` 557 | RequestQueueSize int `json:"request_queue_size"` 558 | ResolvePeerCountries bool `json:"resolve_peer_countries"` 559 | ResumeDataStorageType string `json:"resume_data_storage_type"` 560 | RssAutoDownloadingEnabled bool `json:"rss_auto_downloading_enabled"` 561 | RssDownloadRepackProperEpisodes bool `json:"rss_download_repack_proper_episodes"` 562 | RssMaxArticlesPerFeed int `json:"rss_max_articles_per_feed"` 563 | RssProcessingEnabled bool `json:"rss_processing_enabled"` 564 | RssRefreshInterval int `json:"rss_refresh_interval"` 565 | RssSmartEpisodeFilters string `json:"rss_smart_episode_filters"` 566 | SavePath string `json:"save_path"` 567 | SavePathChangedTmmEnabled bool `json:"save_path_changed_tmm_enabled"` 568 | SaveResumeDataInterval int `json:"save_resume_data_interval"` 569 | ScanDirs struct { 570 | } `json:"scan_dirs"` 571 | ScheduleFromHour int `json:"schedule_from_hour"` 572 | ScheduleFromMin int `json:"schedule_from_min"` 573 | ScheduleToHour int `json:"schedule_to_hour"` 574 | ScheduleToMin int `json:"schedule_to_min"` 575 | SchedulerDays int `json:"scheduler_days"` 576 | SchedulerEnabled bool `json:"scheduler_enabled"` 577 | SendBufferLowWatermark int `json:"send_buffer_low_watermark"` 578 | SendBufferWatermark int `json:"send_buffer_watermark"` 579 | SendBufferWatermarkFactor int `json:"send_buffer_watermark_factor"` 580 | SlowTorrentDlRateThreshold int `json:"slow_torrent_dl_rate_threshold"` 581 | SlowTorrentInactiveTimer int `json:"slow_torrent_inactive_timer"` 582 | SlowTorrentUlRateThreshold int `json:"slow_torrent_ul_rate_threshold"` 583 | SocketBacklogSize int `json:"socket_backlog_size"` 584 | SsrfMitigation bool `json:"ssrf_mitigation"` 585 | StartPausedEnabled bool `json:"start_paused_enabled"` 586 | StopTrackerTimeout int `json:"stop_tracker_timeout"` 587 | TempPath string `json:"temp_path"` 588 | TempPathEnabled bool `json:"temp_path_enabled"` 589 | TorrentChangedTmmEnabled bool `json:"torrent_changed_tmm_enabled"` 590 | TorrentContentLayout string `json:"torrent_content_layout"` 591 | TorrentStopCondition string `json:"torrent_stop_condition"` 592 | UpLimit int `json:"up_limit"` 593 | UploadChokingAlgorithm int `json:"upload_choking_algorithm"` 594 | UploadSlotsBehavior int `json:"upload_slots_behavior"` 595 | Upnp bool `json:"upnp"` 596 | UpnpLeaseDuration int `json:"upnp_lease_duration"` 597 | UseCategoryPathsInManualMode bool `json:"use_category_paths_in_manual_mode"` 598 | UseHTTPS bool `json:"use_https"` 599 | UtpTCPMixedMode int `json:"utp_tcp_mixed_mode"` 600 | ValidateHTTPSTrackerCertificate bool `json:"validate_https_tracker_certificate"` 601 | WebUIAddress string `json:"web_ui_address"` 602 | WebUIBanDuration int `json:"web_ui_ban_duration"` 603 | WebUIClickjackingProtectionEnabled bool `json:"web_ui_clickjacking_protection_enabled"` 604 | WebUICsrfProtectionEnabled bool `json:"web_ui_csrf_protection_enabled"` 605 | WebUICustomHTTPHeaders string `json:"web_ui_custom_http_headers"` 606 | WebUIDomainList string `json:"web_ui_domain_list"` 607 | WebUIHostHeaderValidationEnabled bool `json:"web_ui_host_header_validation_enabled"` 608 | WebUIHTTPSCertPath string `json:"web_ui_https_cert_path"` 609 | WebUIHTTPSKeyPath string `json:"web_ui_https_key_path"` 610 | WebUIMaxAuthFailCount int `json:"web_ui_max_auth_fail_count"` 611 | WebUIPort int `json:"web_ui_port"` 612 | WebUIReverseProxiesList string `json:"web_ui_reverse_proxies_list"` 613 | WebUIReverseProxyEnabled bool `json:"web_ui_reverse_proxy_enabled"` 614 | WebUISecureCookieEnabled bool `json:"web_ui_secure_cookie_enabled"` 615 | WebUISessionTimeout int `json:"web_ui_session_timeout"` 616 | WebUIUpnp bool `json:"web_ui_upnp"` 617 | WebUIUseCustomHTTPHeadersEnabled bool `json:"web_ui_use_custom_http_headers_enabled"` 618 | WebUIUsername string `json:"web_ui_username"` 619 | } 620 | 621 | type MainData struct { 622 | Rid int64 `json:"rid"` 623 | FullUpdate bool `json:"full_update"` 624 | Torrents map[string]Torrent `json:"torrents"` 625 | TorrentsRemoved []string `json:"torrents_removed"` 626 | Categories map[string]Category `json:"categories"` 627 | CategoriesRemoved []string `json:"categories_removed"` 628 | Tags []string `json:"tags"` 629 | TagsRemoved []string `json:"tags_removed"` 630 | Trackers map[string][]string `json:"trackers"` 631 | ServerState ServerState `json:"server_state"` 632 | } 633 | 634 | type ServerState struct { 635 | AlltimeDl int64 `json:"alltime_dl"` 636 | AlltimeUl int64 `json:"alltime_ul"` 637 | AverageTimeQueue int64 `json:"average_time_queue"` 638 | ConnectionStatus string `json:"connection_status"` 639 | DhtNodes int64 `json:"dht_nodes"` 640 | DlInfoData int64 `json:"dl_info_data"` 641 | DlInfoSpeed int64 `json:"dl_info_speed"` 642 | DlRateLimit int64 `json:"dl_rate_limit"` 643 | FreeSpaceOnDisk int64 `json:"free_space_on_disk"` 644 | GlobalRatio string `json:"global_ratio"` 645 | QueuedIoJobs int64 `json:"queued_io_jobs"` 646 | Queueing bool `json:"queueing"` 647 | ReadCacheHits string `json:"read_cache_hits"` 648 | ReadCacheOverload string `json:"read_cache_overload"` 649 | RefreshInterval int64 `json:"refresh_interval"` 650 | TotalBuffersSize int64 `json:"total_buffers_size"` 651 | TotalPeerConnections int64 `json:"total_peer_connections"` 652 | TotalQueuedSize int64 `json:"total_queued_size"` 653 | TotalWastedSession int64 `json:"total_wasted_session"` 654 | UpInfoData int64 `json:"up_info_data"` 655 | UpInfoSpeed int64 `json:"up_info_speed"` 656 | UpRateLimit int64 `json:"up_rate_limit"` 657 | UseAltSpeedLimits bool `json:"use_alt_speed_limits"` 658 | WriteCacheOverload string `json:"write_cache_overload"` 659 | } 660 | 661 | // Log 662 | type Log struct { 663 | ID int64 `json:"id"` 664 | Message string `json:"message"` 665 | Timestamp int64 `json:"timestamp"` 666 | Type int64 `json:"type"` 667 | } 668 | 669 | // PeerLog 670 | type PeerLog struct { 671 | ID int64 `json:"id"` 672 | IP string `json:"ip"` 673 | Blocked bool `json:"blocked"` 674 | Timestamp int64 `json:"timestamp"` 675 | Reason string `json:"reason"` 676 | } 677 | 678 | type BuildInfo struct { 679 | Qt string `json:"qt"` // QT version 680 | Libtorrent string `json:"libtorrent"` // libtorrent version 681 | Boost string `json:"boost"` // Boost version 682 | Openssl string `json:"openssl"` // OpenSSL version 683 | Bitness int `json:"bitness"` // Application bitness (e.g.64-bit) 684 | } 685 | 686 | type Cookie struct { 687 | Name string `json:"name"` // Cookie name 688 | Domain string `json:"domain"` // Cookie domain 689 | Path string `json:"path"` // Cookie path 690 | Value string `json:"value"` // Cookie value 691 | ExpirationDate int64 `json:"expirationDate"` // Seconds since epoch 692 | } 693 | 694 | // PieceState represents download state of torrent pieces. 695 | type PieceState int 696 | 697 | const ( 698 | PieceStateNotDownloadYet = 0 699 | PieceStateNowDownloading = 1 700 | PieceStateAlreadyDownloaded = 2 701 | ) 702 | 703 | // silence unused variable warnings 704 | var _ = PieceStateNotDownloadYet 705 | var _ = PieceStateNowDownloading 706 | var _ = PieceStateAlreadyDownloaded 707 | 708 | type WebSeed struct { 709 | URL string `json:"url"` 710 | } 711 | -------------------------------------------------------------------------------- /domain_test.go: -------------------------------------------------------------------------------- 1 | package qbittorrent 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestTorrentAddOptions_Prepare(t *testing.T) { 10 | type fields struct { 11 | Paused bool 12 | SkipHashCheck bool 13 | ContentLayout ContentLayout 14 | SavePath string 15 | AutoTMM bool 16 | Category string 17 | Tags string 18 | LimitUploadSpeed int64 19 | LimitDownloadSpeed int64 20 | LimitRatio float64 21 | LimitSeedTime int64 22 | Rename string 23 | FirstLastPiecePrio bool 24 | } 25 | tests := []struct { 26 | name string 27 | fields fields 28 | want map[string]string 29 | }{ 30 | { 31 | name: "test_01", 32 | fields: fields{ 33 | Paused: false, 34 | SkipHashCheck: true, 35 | ContentLayout: "", 36 | SavePath: "/home/test/torrents", 37 | AutoTMM: false, 38 | Category: "test", 39 | Tags: "limited,slow", 40 | LimitUploadSpeed: 100000, 41 | LimitDownloadSpeed: 100000, 42 | LimitRatio: 2.0, 43 | LimitSeedTime: 100, 44 | }, 45 | want: map[string]string{ 46 | "paused": "false", 47 | "stopped": "false", 48 | "skip_checking": "true", 49 | "autoTMM": "false", 50 | "firstLastPiecePrio": "false", 51 | "ratioLimit": "2.00", 52 | "savepath": "/home/test/torrents", 53 | "seedingTimeLimit": "100", 54 | "category": "test", 55 | "tags": "limited,slow", 56 | "upLimit": "102400000", 57 | "dlLimit": "102400000", 58 | }, 59 | }, 60 | { 61 | name: "test_02", 62 | fields: fields{ 63 | Paused: false, 64 | SkipHashCheck: true, 65 | ContentLayout: ContentLayoutSubfolderCreate, 66 | SavePath: "/home/test/torrents", 67 | AutoTMM: false, 68 | Category: "test", 69 | Tags: "limited,slow", 70 | LimitUploadSpeed: 100000, 71 | LimitDownloadSpeed: 100000, 72 | LimitRatio: 2.0, 73 | LimitSeedTime: 100, 74 | }, 75 | want: map[string]string{ 76 | "paused": "false", 77 | "stopped": "false", 78 | "skip_checking": "true", 79 | "root_folder": "true", 80 | "contentLayout": "Subfolder", 81 | "autoTMM": "false", 82 | "firstLastPiecePrio": "false", 83 | "ratioLimit": "2.00", 84 | "savepath": "/home/test/torrents", 85 | "seedingTimeLimit": "100", 86 | "category": "test", 87 | "tags": "limited,slow", 88 | "upLimit": "102400000", 89 | "dlLimit": "102400000", 90 | }, 91 | }, 92 | { 93 | name: "test_03", 94 | fields: fields{ 95 | Paused: false, 96 | SkipHashCheck: true, 97 | ContentLayout: ContentLayoutSubfolderNone, 98 | SavePath: "/home/test/torrents", 99 | AutoTMM: false, 100 | Category: "test", 101 | Tags: "limited,slow", 102 | LimitUploadSpeed: 100000, 103 | LimitDownloadSpeed: 100000, 104 | LimitRatio: 2.0, 105 | LimitSeedTime: 100, 106 | }, 107 | want: map[string]string{ 108 | "paused": "false", 109 | "stopped": "false", 110 | "skip_checking": "true", 111 | "root_folder": "false", 112 | "contentLayout": "NoSubfolder", 113 | "autoTMM": "false", 114 | "firstLastPiecePrio": "false", 115 | "ratioLimit": "2.00", 116 | "savepath": "/home/test/torrents", 117 | "seedingTimeLimit": "100", 118 | "category": "test", 119 | "tags": "limited,slow", 120 | "upLimit": "102400000", 121 | "dlLimit": "102400000", 122 | }, 123 | }, 124 | { 125 | name: "test_04", 126 | fields: fields{ 127 | Paused: false, 128 | SkipHashCheck: true, 129 | ContentLayout: ContentLayoutOriginal, 130 | SavePath: "/home/test/torrents", 131 | AutoTMM: false, 132 | Category: "test", 133 | Tags: "limited,slow", 134 | LimitUploadSpeed: 100000, 135 | LimitDownloadSpeed: 100000, 136 | LimitRatio: 2.0, 137 | LimitSeedTime: 100, 138 | }, 139 | want: map[string]string{ 140 | "paused": "false", 141 | "stopped": "false", 142 | "skip_checking": "true", 143 | "autoTMM": "false", 144 | "firstLastPiecePrio": "false", 145 | "ratioLimit": "2.00", 146 | "savepath": "/home/test/torrents", 147 | "seedingTimeLimit": "100", 148 | "category": "test", 149 | "tags": "limited,slow", 150 | "upLimit": "102400000", 151 | "dlLimit": "102400000", 152 | }, 153 | }, 154 | { 155 | name: "test_05", 156 | fields: fields{ 157 | Paused: false, 158 | SkipHashCheck: true, 159 | ContentLayout: ContentLayoutOriginal, 160 | SavePath: "/home/test/torrents", 161 | AutoTMM: false, 162 | Category: "test", 163 | Tags: "limited,slow", 164 | LimitUploadSpeed: 100000, 165 | LimitDownloadSpeed: 100000, 166 | LimitRatio: 2.0, 167 | LimitSeedTime: 100, 168 | Rename: "test-torrent-rename", 169 | }, 170 | want: map[string]string{ 171 | "paused": "false", 172 | "stopped": "false", 173 | "skip_checking": "true", 174 | "autoTMM": "false", 175 | "firstLastPiecePrio": "false", 176 | "ratioLimit": "2.00", 177 | "savepath": "/home/test/torrents", 178 | "seedingTimeLimit": "100", 179 | "category": "test", 180 | "tags": "limited,slow", 181 | "upLimit": "102400000", 182 | "dlLimit": "102400000", 183 | "rename": "test-torrent-rename", 184 | }, 185 | }, 186 | { 187 | name: "test_06", 188 | fields: fields{ 189 | Paused: false, 190 | SkipHashCheck: true, 191 | ContentLayout: ContentLayoutOriginal, 192 | SavePath: "/home/test/torrents", 193 | AutoTMM: false, 194 | FirstLastPiecePrio: true, 195 | Category: "test", 196 | Tags: "limited,slow", 197 | LimitUploadSpeed: 100000, 198 | LimitDownloadSpeed: 100000, 199 | LimitRatio: 2.0, 200 | LimitSeedTime: 100, 201 | Rename: "test-torrent-rename", 202 | }, 203 | want: map[string]string{ 204 | "paused": "false", 205 | "stopped": "false", 206 | "skip_checking": "true", 207 | "autoTMM": "false", 208 | "firstLastPiecePrio": "true", 209 | "ratioLimit": "2.00", 210 | "savepath": "/home/test/torrents", 211 | "seedingTimeLimit": "100", 212 | "category": "test", 213 | "tags": "limited,slow", 214 | "upLimit": "102400000", 215 | "dlLimit": "102400000", 216 | "rename": "test-torrent-rename", 217 | }, 218 | }, 219 | } 220 | for _, tt := range tests { 221 | t.Run(tt.name, func(t *testing.T) { 222 | o := &TorrentAddOptions{ 223 | Paused: tt.fields.Paused, 224 | SkipHashCheck: tt.fields.SkipHashCheck, 225 | ContentLayout: tt.fields.ContentLayout, 226 | SavePath: tt.fields.SavePath, 227 | AutoTMM: tt.fields.AutoTMM, 228 | Category: tt.fields.Category, 229 | Tags: tt.fields.Tags, 230 | LimitUploadSpeed: tt.fields.LimitUploadSpeed, 231 | LimitDownloadSpeed: tt.fields.LimitDownloadSpeed, 232 | LimitRatio: tt.fields.LimitRatio, 233 | LimitSeedTime: tt.fields.LimitSeedTime, 234 | Rename: tt.fields.Rename, 235 | FirstLastPiecePrio: tt.fields.FirstLastPiecePrio, 236 | } 237 | 238 | got := o.Prepare() 239 | assert.Equal(t, tt.want, got) 240 | }) 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "runtime" 7 | "unsafe" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | // Export a number of functions or variables from pkg/errors. We want people to be able to 13 | // use them, if only via the entrypoints we've vetted in this file. 14 | var ( 15 | As = errors.As 16 | Is = errors.Is 17 | Cause = errors.Cause 18 | Unwrap = errors.Unwrap 19 | ) 20 | 21 | // StackTrace should be aliases rather than newtype'd, so it can work with any of the 22 | // functions we export from pkg/errors. 23 | type StackTrace = errors.StackTrace 24 | 25 | type StackTracer interface { 26 | StackTrace() errors.StackTrace 27 | } 28 | 29 | // Sentinel is used to create compile-time errors that are intended to be value only, with 30 | // no associated stack trace. 31 | func Sentinel(msg string, args ...interface{}) error { 32 | return fmt.Errorf(msg, args...) 33 | } 34 | 35 | // New acts as pkg/errors.New does, producing a stack traced error, but supports 36 | // interpolating of message parameters. Use this when you want the stack trace to start at 37 | // the place you create the error. 38 | func New(msg string, args ...interface{}) error { 39 | return PopStack(errors.New(fmt.Sprintf(msg, args...))) 40 | } 41 | 42 | // Wrap creates a new error from a cause, decorating the original error message with a 43 | // prefix. 44 | // 45 | // It differs from the pkg/errors Wrap/Wrapf by idempotently creating a stack trace, 46 | // meaning we won't create another stack trace when there is already a stack trace present 47 | // that matches our current program position. 48 | func Wrap(cause error, msg string, args ...interface{}) error { 49 | causeStackTracer := new(StackTracer) 50 | if errors.As(cause, causeStackTracer) { 51 | // If our cause has set a stack trace, and that trace is a child of our own function 52 | // as inferred by prefix matching our current program counter stack, then we only want 53 | // to decorate the error message rather than add a redundant stack trace. 54 | if ancestorOfCause(callers(1), (*causeStackTracer).StackTrace()) { 55 | return errors.WithMessagef(cause, msg, args...) // no stack added, no pop required 56 | } 57 | } 58 | 59 | // Otherwise we can't see a stack trace that represents ourselves, so let's add one. 60 | return PopStack(errors.Wrapf(cause, msg, args...)) 61 | } 62 | 63 | // ancestorOfCause returns true if the caller looks to be an ancestor of the given stack 64 | // trace. We check this by seeing whether our stack prefix-matches the cause stack, which 65 | // should imply the error was generated directly from our goroutine. 66 | func ancestorOfCause(ourStack []uintptr, causeStack errors.StackTrace) bool { 67 | // Stack traces are ordered such that the deepest frame is first. We'll want to check 68 | // for prefix matching in reverse. 69 | // 70 | // As an example, imagine we have a prefix-matching stack for ourselves: 71 | // [ 72 | // "github.com/onsi/ginkgo/internal/leafnodes.(*runner).runSync", 73 | // "github.com/incident-io/core/server/pkg/errors_test.TestSuite", 74 | // "testing.tRunner", 75 | // "runtime.goexit" 76 | // ] 77 | // 78 | // We'll want to compare this against an error cause that will have happened further 79 | // down the stack. An example stack trace from such an error might be: 80 | // [ 81 | // "github.com/incident-io/core/server/pkg/errors.New", 82 | // "github.com/incident-io/core/server/pkg/errors_test.glob..func1.2.2.2.1",, 83 | // "github.com/onsi/ginkgo/internal/leafnodes.(*runner).runSync", 84 | // "github.com/incident-io/core/server/pkg/errors_test.TestSuite", 85 | // "testing.tRunner", 86 | // "runtime.goexit" 87 | // ] 88 | // 89 | // They prefix match, but we'll have to handle the match carefully as we need to match 90 | // from back to forward. 91 | 92 | // We can't possibly prefix match if our stack is larger than the cause stack. 93 | if len(ourStack) > len(causeStack) { 94 | return false 95 | } 96 | 97 | // We know the sizes are compatible, so compare program counters from back to front. 98 | for idx := 0; idx < len(ourStack); idx++ { 99 | if ourStack[len(ourStack)-1] != (uintptr)(causeStack[len(causeStack)-1]) { 100 | return false 101 | } 102 | } 103 | 104 | // All comparisons checked out, these stacks match 105 | return true 106 | } 107 | 108 | func callers(skip int) []uintptr { 109 | pc := make([]uintptr, 32) // assume we'll have at most 32 frames 110 | n := runtime.Callers(skip+3, pc) // capture those frames, skipping runtime.Callers, ourself and the calling function 111 | 112 | return pc[:n] // return everything that we captured 113 | } 114 | 115 | // RecoverPanic turns a panic into an error, adjusting the stacktrace so it originates at 116 | // the line that caused it. 117 | // 118 | // Example: 119 | // 120 | // func Do() (err error) { 121 | // defer func() { 122 | // errors.RecoverPanic(recover(), &err) 123 | // }() 124 | // } 125 | func RecoverPanic(r interface{}, errPtr *error) { 126 | var err error 127 | if r != nil { 128 | if panicErr, ok := r.(error); ok { 129 | err = errors.Wrap(panicErr, "caught panic") 130 | } else { 131 | err = errors.New(fmt.Sprintf("caught panic: %v", r)) 132 | } 133 | } 134 | 135 | if err != nil { 136 | // Pop twice: once for the errors package, then again for the defer function we must 137 | // run this under. We want the stacktrace to originate at the source of the panic, not 138 | // in the infrastructure that catches it. 139 | err = PopStack(err) // errors.go 140 | err = PopStack(err) // defer 141 | 142 | *errPtr = err 143 | } 144 | } 145 | 146 | // PopStack removes the top of the stack from an errors stack trace. 147 | func PopStack(err error) error { 148 | if err == nil { 149 | return err 150 | } 151 | 152 | // We want to remove us, the internal/errors.New function, from the error stack we just 153 | // produced. There's no official way of reaching into the error and adjusting this, as 154 | // the stack is stored as a private field on an unexported struct. 155 | // 156 | // This does some unsafe badness to adjust that field, which should not be repeated 157 | // anywhere else. 158 | stackField := reflect.ValueOf(err).Elem().FieldByName("stack") 159 | if stackField.IsZero() { 160 | return err 161 | } 162 | stackFieldPtr := (**[]uintptr)(unsafe.Pointer(stackField.UnsafeAddr())) 163 | 164 | // Remove the first of the frames, dropping 'us' from the error stack trace. 165 | frames := (**stackFieldPtr)[1:] 166 | 167 | // Assign to the internal stack field 168 | *stackFieldPtr = &frames 169 | 170 | return err 171 | } 172 | -------------------------------------------------------------------------------- /errors/errors_test.go: -------------------------------------------------------------------------------- 1 | package errors_test 2 | 3 | //import ( 4 | // "fmt" 5 | // 6 | // "github.com/incident-io/core/server/pkg/errors" 7 | // 8 | // . "github.com/onsi/ginkgo" 9 | // . "github.com/onsi/gomega" 10 | //) 11 | // 12 | //func getStackTraces(err error) []errors.StackTrace { 13 | // traces := []errors.StackTrace{} 14 | // if err, ok := err.(errors.StackTracer); ok { 15 | // traces = append(traces, err.StackTrace()) 16 | // } 17 | // 18 | // if err := errors.Unwrap(err); err != nil { 19 | // traces = append(traces, getStackTraces(err)...) 20 | // } 21 | // 22 | // return traces 23 | //} 24 | // 25 | //var _ = Describe("errors", func() { 26 | // Describe("New", func() { 27 | // It("generates an error with a stack trace", func() { 28 | // err := errors.New("oops") 29 | // Expect(getStackTraces(err)).To(HaveLen(1)) 30 | // }) 31 | // }) 32 | // 33 | // Describe("Wrap", func() { 34 | // Context("when cause has no stack trace", func() { 35 | // It("wraps the error and takes stack trace", func() { 36 | // err := errors.Wrap(fmt.Errorf("cause"), "description") 37 | // Expect(err.Error()).To(Equal("description: cause")) 38 | // 39 | // cause := errors.Cause(err) 40 | // Expect(cause).To(MatchError("cause")) 41 | // 42 | // Expect(getStackTraces(err)).To(HaveLen(1)) 43 | // }) 44 | // }) 45 | // 46 | // Context("when cause has stack trace", func() { 47 | // Context("which is not an ancestor of our own", func() { 48 | // It("creates a new stack trace", func() { 49 | // errChan := make(chan error) 50 | // go func() { 51 | // errChan <- errors.New("unrelated") // created with a stack trace 52 | // }() 53 | // 54 | // err := errors.Wrap(<-errChan, "helpful description") 55 | // Expect(err.Error()).To(Equal("helpful description: unrelated")) 56 | // 57 | // Expect(getStackTraces(err)).To(HaveLen(2)) 58 | // }) 59 | // }) 60 | // 61 | // Context("with a frame from our current method", func() { 62 | // It("does not create new stack trace", func() { 63 | // err := errors.Wrap(errors.New("related"), "helpful description") 64 | // Expect(err.Error()).To(Equal("helpful description: related")) 65 | // 66 | // Expect(getStackTraces(err)).To(HaveLen(1)) 67 | // }) 68 | // }) 69 | // }) 70 | // }) 71 | //}) 72 | -------------------------------------------------------------------------------- /examples/basic/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "github.com/autobrr/go-qbittorrent" 8 | ) 9 | 10 | func main() { 11 | client := qbittorrent.NewClient(qbittorrent.Config{ 12 | Host: "http://localhost:8080", 13 | Username: "admin", 14 | Password: "adminadmin", 15 | }) 16 | 17 | ctx := context.Background() 18 | 19 | if err := client.LoginCtx(ctx); err != nil { 20 | log.Fatalf("could not log into client: %q", err) 21 | } 22 | 23 | torrents, err := client.GetTorrents(qbittorrent.TorrentFilterOptions{ 24 | Category: "test", 25 | }) 26 | if err != nil { 27 | log.Fatalf("could not get torrents from client: %q", err) 28 | } 29 | 30 | log.Printf("Found %d torrents", len(torrents)) 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/autobrr/go-qbittorrent 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/Masterminds/semver v1.5.0 9 | github.com/avast/retry-go v3.0.0+incompatible 10 | github.com/pkg/errors v0.9.1 11 | github.com/stretchr/testify v1.10.0 12 | golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f 13 | golang.org/x/net v0.40.0 14 | ) 15 | 16 | require ( 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/pmezard/go-difflib v1.0.0 // indirect 19 | gopkg.in/yaml.v3 v3.0.1 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= 2 | github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= 3 | github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= 4 | github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 8 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 12 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 13 | golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= 14 | golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= 15 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 16 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 17 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 19 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 20 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 21 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package qbittorrent 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "math/rand" 8 | "mime/multipart" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "strings" 13 | "time" 14 | 15 | "github.com/autobrr/go-qbittorrent/errors" 16 | "github.com/avast/retry-go" 17 | ) 18 | 19 | func (c *Client) getCtx(ctx context.Context, endpoint string, opts map[string]string) (*http.Response, error) { 20 | reqUrl := c.buildUrl(endpoint, opts) 21 | 22 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqUrl, nil) 23 | if err != nil { 24 | return nil, errors.Wrap(err, "could not build request") 25 | } 26 | 27 | if c.cfg.BasicUser != "" && c.cfg.BasicPass != "" { 28 | req.SetBasicAuth(c.cfg.BasicUser, c.cfg.BasicPass) 29 | } 30 | 31 | cookieURL, _ := url.Parse(c.buildUrl("/", nil)) 32 | 33 | if len(c.http.Jar.Cookies(cookieURL)) == 0 { 34 | if err := c.LoginCtx(ctx); err != nil { 35 | return nil, errors.Wrap(err, "qbit re-login failed") 36 | } 37 | } 38 | 39 | // try request and if fail run 10 retries 40 | resp, err := c.retryDo(ctx, req) 41 | if err != nil { 42 | return nil, errors.Wrap(err, "error making get request: %v", reqUrl) 43 | } 44 | 45 | return resp, nil 46 | } 47 | 48 | func (c *Client) postCtx(ctx context.Context, endpoint string, opts map[string]string) (*http.Response, error) { 49 | // add optional parameters that the user wants 50 | form := url.Values{} 51 | for k, v := range opts { 52 | form.Add(k, v) 53 | } 54 | 55 | reqUrl := c.buildUrl(endpoint, nil) 56 | 57 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl, strings.NewReader(form.Encode())) 58 | if err != nil { 59 | return nil, errors.Wrap(err, "could not build request") 60 | } 61 | 62 | if c.cfg.BasicUser != "" && c.cfg.BasicPass != "" { 63 | req.SetBasicAuth(c.cfg.BasicUser, c.cfg.BasicPass) 64 | } 65 | 66 | // add the content-type so qbittorrent knows what to expect 67 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 68 | 69 | cookieURL, _ := url.Parse(c.buildUrl("/", nil)) 70 | if len(c.http.Jar.Cookies(cookieURL)) == 0 { 71 | if err := c.LoginCtx(ctx); err != nil { 72 | return nil, errors.Wrap(err, "qbit re-login failed") 73 | } 74 | } 75 | 76 | // try request and if fail run 10 retries 77 | resp, err := c.retryDo(ctx, req) 78 | if err != nil { 79 | return nil, errors.Wrap(err, "error making post request: %v", reqUrl) 80 | } 81 | 82 | return resp, nil 83 | } 84 | 85 | func (c *Client) postBasicCtx(ctx context.Context, endpoint string, opts map[string]string) (*http.Response, error) { 86 | // add optional parameters that the user wants 87 | form := url.Values{} 88 | for k, v := range opts { 89 | form.Add(k, v) 90 | } 91 | 92 | var resp *http.Response 93 | 94 | reqUrl := c.buildUrl(endpoint, nil) 95 | 96 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl, strings.NewReader(form.Encode())) 97 | if err != nil { 98 | return nil, errors.Wrap(err, "could not build request") 99 | } 100 | 101 | if c.cfg.BasicUser != "" && c.cfg.BasicPass != "" { 102 | req.SetBasicAuth(c.cfg.BasicUser, c.cfg.BasicPass) 103 | } 104 | 105 | // add the content-type so qbittorrent knows what to expect 106 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 107 | 108 | resp, err = c.http.Do(req) 109 | if err != nil { 110 | return nil, errors.Wrap(err, "error making post request: %v", reqUrl) 111 | } 112 | 113 | return resp, nil 114 | } 115 | 116 | func (c *Client) postFileCtx(ctx context.Context, endpoint string, fileName string, opts map[string]string) (*http.Response, error) { 117 | b, err := os.ReadFile(fileName) 118 | if err != nil { 119 | return nil, errors.Wrap(err, "error reading file %v", fileName) 120 | } 121 | 122 | return c.postMemoryCtx(ctx, endpoint, b, opts) 123 | } 124 | 125 | func (c *Client) postMemoryCtx(ctx context.Context, endpoint string, buf []byte, opts map[string]string) (*http.Response, error) { 126 | // Buffer to store our request body as bytes 127 | var requestBody bytes.Buffer 128 | 129 | // Store a multipart writer 130 | multiPartWriter := multipart.NewWriter(&requestBody) 131 | torName := generateTorrentName() 132 | 133 | // Initialize file field 134 | fileWriter, err := multiPartWriter.CreateFormFile("torrents", torName) 135 | if err != nil { 136 | return nil, errors.Wrap(err, "error initializing file field") 137 | } 138 | 139 | // Copy the actual file content to the fields writer 140 | if _, err := io.Copy(fileWriter, bytes.NewBuffer(buf)); err != nil { 141 | return nil, errors.Wrap(err, "error copy file contents to writer") 142 | } 143 | 144 | // Populate other fields 145 | for key, val := range opts { 146 | fieldWriter, err := multiPartWriter.CreateFormField(key) 147 | if err != nil { 148 | return nil, errors.Wrap(err, "error creating form field %v with value %v", key, val) 149 | } 150 | 151 | if _, err := fieldWriter.Write([]byte(val)); err != nil { 152 | return nil, errors.Wrap(err, "error writing field %v with value %v", key, val) 153 | } 154 | } 155 | 156 | // Close multipart writer 157 | contentType := multiPartWriter.FormDataContentType() 158 | multiPartWriter.Close() 159 | 160 | reqUrl := c.buildUrl(endpoint, nil) 161 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl, &requestBody) 162 | if err != nil { 163 | return nil, errors.Wrap(err, "error creating request") 164 | } 165 | 166 | if c.cfg.BasicUser != "" && c.cfg.BasicPass != "" { 167 | req.SetBasicAuth(c.cfg.BasicUser, c.cfg.BasicPass) 168 | } 169 | 170 | // Set correct content type 171 | req.Header.Set("Content-Type", contentType) 172 | 173 | cookieURL, _ := url.Parse(c.buildUrl("/", nil)) 174 | if len(c.http.Jar.Cookies(cookieURL)) == 0 { 175 | if err := c.LoginCtx(ctx); err != nil { 176 | return nil, errors.Wrap(err, "qbit re-login failed") 177 | } 178 | } 179 | 180 | resp, err := c.retryDo(ctx, req) 181 | if err != nil { 182 | return nil, errors.Wrap(err, "error making post file request") 183 | } 184 | 185 | return resp, nil 186 | } 187 | 188 | func generateTorrentName() string { 189 | // A simple string generator for supplying multipart form fields 190 | // Presently with the API this does not matter, but may be used for internal context 191 | // if it ever becomes a problem, feel no qualms about removing it. 192 | z := []byte{'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'z', 'x', 'c', 'v', 'b', 'n', 'm', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '_'} 193 | s := make([]byte, 16) 194 | for i := 0; i < len(s); i++ { 195 | s[i] = z[rand.Intn(len(z)-1)] 196 | } 197 | 198 | return string(s) 199 | } 200 | 201 | func (c *Client) setCookies(cookies []*http.Cookie) { 202 | cookieURL, _ := url.Parse(c.buildUrl("/", nil)) 203 | 204 | c.http.Jar.SetCookies(cookieURL, cookies) 205 | } 206 | 207 | func (c *Client) buildUrl(endpoint string, params map[string]string) string { 208 | apiBase := "/api/v2/" 209 | 210 | // add query params 211 | queryParams := url.Values{} 212 | for key, value := range params { 213 | queryParams.Add(key, value) 214 | } 215 | 216 | joinedUrl, _ := url.JoinPath(c.cfg.Host, apiBase, endpoint) 217 | parsedUrl, _ := url.Parse(joinedUrl) 218 | parsedUrl.RawQuery = queryParams.Encode() 219 | 220 | // make into new string and return 221 | return parsedUrl.String() 222 | } 223 | 224 | func copyBody(src io.ReadCloser) ([]byte, error) { 225 | b, err := io.ReadAll(src) 226 | if err != nil { 227 | // ErrReadingRequestBody 228 | return nil, err 229 | } 230 | src.Close() 231 | return b, nil 232 | } 233 | 234 | func resetBody(request *http.Request, originalBody []byte) { 235 | request.Body = io.NopCloser(bytes.NewBuffer(originalBody)) 236 | request.GetBody = func() (io.ReadCloser, error) { 237 | return io.NopCloser(bytes.NewBuffer(originalBody)), nil 238 | } 239 | } 240 | 241 | func (c *Client) retryDo(ctx context.Context, req *http.Request) (*http.Response, error) { 242 | var ( 243 | originalBody []byte 244 | err error 245 | ) 246 | 247 | if req != nil && req.Body != nil { 248 | originalBody, err = copyBody(req.Body) 249 | } 250 | 251 | if err != nil { 252 | return nil, err 253 | } 254 | 255 | var resp *http.Response 256 | 257 | // try request and if fail run 10 retries 258 | err = retry.Do(func() error { 259 | if req != nil && req.Body != nil { 260 | resetBody(req, originalBody) 261 | } 262 | 263 | resp, err = c.http.Do(req) 264 | 265 | if err == nil { 266 | if resp.StatusCode == http.StatusForbidden { 267 | if err := c.LoginCtx(ctx); err != nil { 268 | return errors.Wrap(err, "qbit re-login failed") 269 | } 270 | 271 | retry.Delay(100 * time.Millisecond) 272 | 273 | return errors.New("qbit re-login") 274 | } else if resp.StatusCode < 500 { 275 | return err 276 | } else if resp.StatusCode >= 500 { 277 | return retry.Unrecoverable(errors.New("unrecoverable status: %v", resp.StatusCode)) 278 | } 279 | } 280 | 281 | retry.Delay(time.Second * 3) 282 | 283 | return err 284 | }, 285 | retry.OnRetry(func(n uint, err error) { c.log.Printf("%q: attempt %d - %v\n", err, n, req.URL.String()) }), 286 | //retry.Delay(time.Second*3), 287 | retry.Attempts(5), 288 | retry.MaxJitter(time.Second*1), 289 | ) 290 | 291 | if err != nil { 292 | return nil, errors.Wrap(err, "error making request") 293 | } 294 | 295 | return resp, nil 296 | } 297 | -------------------------------------------------------------------------------- /maindata.go: -------------------------------------------------------------------------------- 1 | package qbittorrent 2 | 3 | import ( 4 | "context" 5 | 6 | "golang.org/x/exp/slices" 7 | ) 8 | 9 | func (dest *MainData) Update(ctx context.Context, c *Client) error { 10 | source, err := c.SyncMainDataCtx(ctx, int64(dest.Rid)) 11 | if err != nil { 12 | return err 13 | } 14 | 15 | if source.FullUpdate { 16 | *dest = *source 17 | return nil 18 | } 19 | 20 | dest.Rid = source.Rid 21 | dest.ServerState = source.ServerState 22 | merge(source.Categories, &dest.Categories) 23 | merge(source.Torrents, &dest.Torrents) 24 | merge(source.Trackers, &dest.Trackers) 25 | remove(source.CategoriesRemoved, &dest.Categories) 26 | remove(source.TorrentsRemoved, &dest.Torrents) 27 | mergeSlice(source.Tags, &dest.Tags) 28 | removeSlice(source.TagsRemoved, &dest.Tags) 29 | return nil 30 | } 31 | 32 | func merge[T map[string]V, V any](s T, d *T) { 33 | for k, v := range s { 34 | (*d)[k] = v 35 | } 36 | } 37 | 38 | func remove[T map[string]V, V any](s []string, d *T) { 39 | for _, v := range s { 40 | delete(*d, v) 41 | } 42 | } 43 | 44 | func mergeSlice[T []string](s T, d *T) { 45 | *d = append(*d, s...) 46 | slices.Sort(*d) 47 | *d = slices.Compact(*d) 48 | } 49 | 50 | func removeSlice[T []string](s T, d *T) { 51 | for i := 0; i < len(*d); i++ { 52 | if k := (*d)[i]; len(k) != 0 { 53 | match := false 54 | for _, c := range s { 55 | if c == k { 56 | match = true 57 | break 58 | } 59 | } 60 | 61 | if !match { 62 | continue 63 | } 64 | } 65 | 66 | (*d)[i] = (*d)[len(*d)-1] 67 | (*d) = (*d)[:len(*d)-1] 68 | i-- 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /methods.go: -------------------------------------------------------------------------------- 1 | package qbittorrent 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "net/http/httputil" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/autobrr/go-qbittorrent/errors" 14 | 15 | "github.com/Masterminds/semver" 16 | ) 17 | 18 | // Login https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#authentication 19 | func (c *Client) Login() error { 20 | return c.LoginCtx(context.Background()) 21 | } 22 | 23 | func (c *Client) LoginCtx(ctx context.Context) error { 24 | if c.cfg.Username == "" && c.cfg.Password == "" { 25 | return nil 26 | } 27 | 28 | opts := map[string]string{ 29 | "username": c.cfg.Username, 30 | "password": c.cfg.Password, 31 | } 32 | 33 | resp, err := c.postBasicCtx(ctx, "auth/login", opts) 34 | if err != nil { 35 | return errors.Wrap(err, "login error") 36 | } 37 | 38 | defer resp.Body.Close() 39 | 40 | switch resp.StatusCode { 41 | case http.StatusForbidden: 42 | return ErrIPBanned 43 | case http.StatusOK: 44 | break 45 | default: 46 | return errors.Wrap(ErrUnexpectedStatus, "login error; status code: %d", resp.StatusCode) 47 | } 48 | 49 | bodyBytes, err := io.ReadAll(resp.Body) 50 | if err != nil { 51 | return err 52 | } 53 | bodyString := string(bodyBytes) 54 | 55 | // read output 56 | if bodyString == "Fails." { 57 | return ErrBadCredentials 58 | } 59 | 60 | // good response == "Ok." 61 | 62 | // place cookies in jar for future requests 63 | if cookies := resp.Cookies(); len(cookies) > 0 { 64 | c.setCookies(cookies) 65 | } else if bodyString != "Ok." { 66 | return ErrBadCredentials 67 | } 68 | 69 | c.log.Printf("logged into client: %v", c.cfg.Host) 70 | 71 | return nil 72 | } 73 | 74 | // GetBuildInfo get qBittorrent build information. 75 | func (c *Client) GetBuildInfo() (BuildInfo, error) { 76 | return c.GetBuildInfoCtx(context.Background()) 77 | } 78 | 79 | // GetBuildInfoCtx get qBittorrent build information. 80 | func (c *Client) GetBuildInfoCtx(ctx context.Context) (BuildInfo, error) { 81 | var bi BuildInfo 82 | resp, err := c.getCtx(ctx, "app/buildInfo", nil) 83 | if err != nil { 84 | return bi, errors.Wrap(err, "could not get app build info") 85 | } 86 | 87 | // prevent annoying unhandled error warning 88 | defer func(Body io.ReadCloser) { 89 | _ = Body.Close() 90 | }(resp.Body) 91 | 92 | if err = json.NewDecoder(resp.Body).Decode(&bi); err != nil { 93 | return bi, errors.Wrap(err, "could not unmarshal body") 94 | } 95 | 96 | return bi, nil 97 | } 98 | 99 | // Shutdown Shuts down the qBittorrent client 100 | func (c *Client) Shutdown() error { 101 | return c.ShutdownCtx(context.Background()) 102 | } 103 | 104 | func (c *Client) ShutdownCtx(ctx context.Context) error { 105 | resp, err := c.postCtx(ctx, "app/shutdown", nil) 106 | if err != nil { 107 | return errors.Wrap(err, "could not trigger shutdown") 108 | } 109 | defer resp.Body.Close() 110 | 111 | if resp.StatusCode != http.StatusOK { 112 | return errors.Wrap(ErrUnexpectedStatus, "could not trigger shutdown; status code: %d", resp.StatusCode) 113 | } 114 | 115 | return nil 116 | } 117 | 118 | func (c *Client) setApiVersion() error { 119 | versionString, err := c.GetWebAPIVersionCtx(context.Background()) 120 | if err != nil { 121 | return errors.Wrap(err, "could not get webapi version") 122 | } 123 | 124 | c.log.Printf("webapi version: %v", versionString) 125 | 126 | ver, err := semver.NewVersion(versionString) 127 | if err != nil { 128 | return errors.Wrap(err, "could not parse webapi version") 129 | } 130 | 131 | c.version = ver 132 | 133 | return nil 134 | } 135 | 136 | func (c *Client) getApiVersion() (*semver.Version, error) { 137 | if c.version == nil || (c.version.Major() == 0 && c.version.Minor() == 0 && c.version.Patch() == 0) { 138 | err := c.setApiVersion() 139 | if err != nil { 140 | return nil, err 141 | } 142 | } 143 | 144 | return c.version, nil 145 | } 146 | 147 | func (c *Client) GetAppPreferences() (AppPreferences, error) { 148 | return c.GetAppPreferencesCtx(context.Background()) 149 | } 150 | 151 | func (c *Client) GetAppPreferencesCtx(ctx context.Context) (AppPreferences, error) { 152 | var app AppPreferences 153 | resp, err := c.getCtx(ctx, "app/preferences", nil) 154 | if err != nil { 155 | return app, errors.Wrap(err, "could not get app preferences") 156 | } 157 | 158 | defer resp.Body.Close() 159 | 160 | body, err := io.ReadAll(resp.Body) 161 | if err != nil { 162 | return app, errors.Wrap(err, "could not read body") 163 | } 164 | 165 | if err := json.Unmarshal(body, &app); err != nil { 166 | return app, errors.Wrap(err, "could not unmarshal body") 167 | } 168 | 169 | return app, nil 170 | } 171 | 172 | func (c *Client) SetPreferences(prefs map[string]interface{}) error { 173 | return c.SetPreferencesCtx(context.Background(), prefs) 174 | } 175 | 176 | func (c *Client) SetPreferencesCtx(ctx context.Context, prefs map[string]interface{}) error { 177 | prefsJSON, err := json.Marshal(prefs) 178 | if err != nil { 179 | return errors.Wrap(err, "could not marshal preferences") 180 | } 181 | 182 | data := map[string]string{ 183 | "json": string(prefsJSON), 184 | } 185 | 186 | resp, err := c.postCtx(ctx, "app/setPreferences", data) 187 | if err != nil { 188 | return errors.Wrap(err, "could not set preferences") 189 | } 190 | defer resp.Body.Close() 191 | 192 | if resp.StatusCode != http.StatusOK { 193 | return errors.Wrap(ErrUnexpectedStatus, "could not set preferences; status code: %d", resp.StatusCode) 194 | } 195 | 196 | return nil 197 | } 198 | 199 | // GetDefaultSavePath get default save path. 200 | // e.g. C:/Users/Dayman/Downloads 201 | func (c *Client) GetDefaultSavePath() (string, error) { 202 | return c.GetDefaultSavePathCtx(context.Background()) 203 | } 204 | 205 | // GetDefaultSavePathCtx get default save path. 206 | // e.g. C:/Users/Dayman/Downloads 207 | func (c *Client) GetDefaultSavePathCtx(ctx context.Context) (string, error) { 208 | resp, err := c.getCtx(ctx, "app/defaultSavePath", nil) 209 | if err != nil { 210 | return "", errors.Wrap(err, "could not get default save path") 211 | } 212 | defer resp.Body.Close() 213 | 214 | if resp.StatusCode != http.StatusOK { 215 | return "", errors.Wrap(ErrUnexpectedStatus, "could not get default save path; status code: %d", resp.StatusCode) 216 | } 217 | 218 | respData, err := io.ReadAll(resp.Body) 219 | if err != nil { 220 | return "", errors.Wrap(err, "could not read body") 221 | } 222 | 223 | return string(respData), nil 224 | } 225 | 226 | func (c *Client) GetTorrents(o TorrentFilterOptions) ([]Torrent, error) { 227 | return c.GetTorrentsCtx(context.Background(), o) 228 | } 229 | 230 | func (c *Client) GetTorrentsCtx(ctx context.Context, o TorrentFilterOptions) ([]Torrent, error) { 231 | opts := map[string]string{} 232 | 233 | if o.Reverse { 234 | opts["reverse"] = strconv.FormatBool(o.Reverse) 235 | } 236 | 237 | if o.Limit > 0 { 238 | opts["limit"] = strconv.Itoa(o.Limit) 239 | } 240 | 241 | if o.Offset > 0 { 242 | opts["offset"] = strconv.Itoa(o.Offset) 243 | } 244 | 245 | if o.Sort != "" { 246 | opts["sort"] = o.Sort 247 | } 248 | 249 | if o.Filter != "" { 250 | opts["filter"] = string(o.Filter) 251 | } 252 | 253 | if o.Category != "" { 254 | opts["category"] = o.Category 255 | } 256 | 257 | if o.Tag != "" { 258 | opts["tag"] = o.Tag 259 | } 260 | 261 | if len(o.Hashes) > 0 { 262 | opts["hashes"] = strings.Join(o.Hashes, "|") 263 | } 264 | 265 | // qbit v5.1+ 266 | if o.IncludeTrackers { 267 | opts["includeTrackers"] = strconv.FormatBool(o.IncludeTrackers) 268 | } 269 | 270 | resp, err := c.getCtx(ctx, "torrents/info", opts) 271 | if err != nil { 272 | return nil, errors.Wrap(err, "get torrents error") 273 | } 274 | 275 | defer resp.Body.Close() 276 | 277 | body, err := io.ReadAll(resp.Body) 278 | if err != nil { 279 | return nil, errors.Wrap(err, "could not read body") 280 | } 281 | 282 | var torrents []Torrent 283 | if err := json.Unmarshal(body, &torrents); err != nil { 284 | return nil, errors.Wrap(err, "could not unmarshal body") 285 | } 286 | 287 | return torrents, nil 288 | } 289 | 290 | func (c *Client) GetTorrentsActiveDownloads() ([]Torrent, error) { 291 | return c.GetTorrentsActiveDownloadsCtx(context.Background()) 292 | } 293 | 294 | func (c *Client) GetTorrentsActiveDownloadsCtx(ctx context.Context) ([]Torrent, error) { 295 | torrents, err := c.GetTorrentsCtx(ctx, TorrentFilterOptions{Filter: TorrentFilterDownloading}) 296 | if err != nil { 297 | return nil, err 298 | } 299 | 300 | res := make([]Torrent, 0) 301 | for _, torrent := range torrents { 302 | // qbit counts paused torrents as downloading as well by default 303 | // so only add torrents with state downloading, and not pausedDl, stalledDl etc 304 | if torrent.State == TorrentStateDownloading || torrent.State == TorrentStateStalledDl { 305 | res = append(res, torrent) 306 | } 307 | } 308 | 309 | return res, nil 310 | } 311 | 312 | func (c *Client) GetTorrentProperties(hash string) (TorrentProperties, error) { 313 | return c.GetTorrentPropertiesCtx(context.Background(), hash) 314 | } 315 | 316 | func (c *Client) GetTorrentPropertiesCtx(ctx context.Context, hash string) (TorrentProperties, error) { 317 | opts := map[string]string{ 318 | "hash": hash, 319 | } 320 | 321 | var prop TorrentProperties 322 | resp, err := c.getCtx(ctx, "torrents/properties", opts) 323 | if err != nil { 324 | return prop, errors.Wrap(err, "could not get app preferences") 325 | } 326 | 327 | defer resp.Body.Close() 328 | 329 | body, err := io.ReadAll(resp.Body) 330 | if err != nil { 331 | return prop, errors.Wrap(err, "could not read body") 332 | } 333 | 334 | if err := json.Unmarshal(body, &prop); err != nil { 335 | return prop, errors.Wrap(err, "could not unmarshal body") 336 | } 337 | 338 | return prop, nil 339 | } 340 | 341 | func (c *Client) GetTorrentsRaw() (string, error) { 342 | return c.GetTorrentsRawCtx(context.Background()) 343 | } 344 | 345 | func (c *Client) GetTorrentsRawCtx(ctx context.Context) (string, error) { 346 | resp, err := c.getCtx(ctx, "torrents/info", nil) 347 | if err != nil { 348 | return "", errors.Wrap(err, "could not get torrents raw") 349 | } 350 | 351 | defer resp.Body.Close() 352 | 353 | data, err := io.ReadAll(resp.Body) 354 | if err != nil { 355 | return "", errors.Wrap(err, "could not get read body torrents raw") 356 | } 357 | 358 | return string(data), nil 359 | } 360 | 361 | func (c *Client) GetTorrentTrackers(hash string) ([]TorrentTracker, error) { 362 | return c.GetTorrentTrackersCtx(context.Background(), hash) 363 | } 364 | 365 | func (c *Client) GetTorrentTrackersCtx(ctx context.Context, hash string) ([]TorrentTracker, error) { 366 | opts := map[string]string{ 367 | "hash": hash, 368 | } 369 | 370 | resp, err := c.getCtx(ctx, "torrents/trackers", opts) 371 | if err != nil { 372 | return nil, errors.Wrap(err, "could not get torrent trackers for hash: %v", hash) 373 | } 374 | 375 | defer resp.Body.Close() 376 | 377 | dump, err := httputil.DumpResponse(resp, true) 378 | if err != nil { 379 | // c.log.Printf("get torrent trackers error dump response: %v\n", string(dump)) 380 | return nil, errors.Wrap(err, "could not dump response for hash: %v", hash) 381 | } 382 | 383 | c.log.Printf("get torrent trackers response dump: %q", dump) 384 | 385 | if resp.StatusCode == http.StatusNotFound { 386 | return nil, nil 387 | } else if resp.StatusCode == http.StatusForbidden { 388 | return nil, nil 389 | } 390 | 391 | body, err := io.ReadAll(resp.Body) 392 | if err != nil { 393 | return nil, errors.Wrap(err, "could not read body") 394 | } 395 | 396 | c.log.Printf("get torrent trackers body: %v\n", string(body)) 397 | 398 | var trackers []TorrentTracker 399 | if err := json.Unmarshal(body, &trackers); err != nil { 400 | return nil, errors.Wrap(err, "could not unmarshal body") 401 | } 402 | 403 | return trackers, nil 404 | } 405 | 406 | func (c *Client) AddTorrentFromMemory(buf []byte, options map[string]string) error { 407 | return c.AddTorrentFromMemoryCtx(context.Background(), buf, options) 408 | } 409 | 410 | func (c *Client) AddTorrentFromMemoryCtx(ctx context.Context, buf []byte, options map[string]string) error { 411 | 412 | res, err := c.postMemoryCtx(ctx, "torrents/add", buf, options) 413 | if err != nil { 414 | return errors.Wrap(err, "could not add torrent") 415 | } 416 | 417 | defer res.Body.Close() 418 | 419 | if res.StatusCode != http.StatusOK { 420 | return errors.Wrap(ErrUnexpectedStatus, "could not add torrent; status code: %d", res.StatusCode) 421 | } 422 | 423 | return nil 424 | } 425 | 426 | // AddTorrentFromFile add new torrent from torrent file 427 | func (c *Client) AddTorrentFromFile(filePath string, options map[string]string) error { 428 | return c.AddTorrentFromFileCtx(context.Background(), filePath, options) 429 | } 430 | 431 | func (c *Client) AddTorrentFromFileCtx(ctx context.Context, filePath string, options map[string]string) error { 432 | 433 | res, err := c.postFileCtx(ctx, "torrents/add", filePath, options) 434 | if err != nil { 435 | return errors.Wrap(err, "could not add torrent; filePath: %v", filePath) 436 | } 437 | 438 | defer res.Body.Close() 439 | 440 | if res.StatusCode != http.StatusOK { 441 | return errors.Wrap(ErrUnexpectedStatus, "could not add torrent; filePath: %v | status code: %d", filePath, res.StatusCode) 442 | } 443 | 444 | return nil 445 | } 446 | 447 | // AddTorrentFromUrl add new torrent from torrent file 448 | func (c *Client) AddTorrentFromUrl(url string, options map[string]string) error { 449 | return c.AddTorrentFromUrlCtx(context.Background(), url, options) 450 | } 451 | 452 | func (c *Client) AddTorrentFromUrlCtx(ctx context.Context, url string, options map[string]string) error { 453 | if url == "" { 454 | return ErrNoTorrentURLProvided 455 | } 456 | 457 | options["urls"] = url 458 | 459 | res, err := c.postCtx(ctx, "torrents/add", options) 460 | if err != nil { 461 | return errors.Wrap(err, "could not add torrent; url: %v", url) 462 | } 463 | 464 | defer res.Body.Close() 465 | 466 | if res.StatusCode != http.StatusOK { 467 | return errors.Wrap(ErrUnexpectedStatus, "could not add torrent: url: %v | status code: %d", url, res.StatusCode) 468 | } 469 | 470 | return nil 471 | } 472 | 473 | func (c *Client) DeleteTorrents(hashes []string, deleteFiles bool) error { 474 | return c.DeleteTorrentsCtx(context.Background(), hashes, deleteFiles) 475 | } 476 | 477 | func (c *Client) DeleteTorrentsCtx(ctx context.Context, hashes []string, deleteFiles bool) error { 478 | // Add hashes together with | separator 479 | hv := strings.Join(hashes, "|") 480 | 481 | opts := map[string]string{ 482 | "hashes": hv, 483 | "deleteFiles": strconv.FormatBool(deleteFiles), 484 | } 485 | 486 | resp, err := c.postCtx(ctx, "torrents/delete", opts) 487 | if err != nil { 488 | return errors.Wrap(err, "could not delete torrents; hashes: %v", hashes) 489 | } 490 | 491 | defer resp.Body.Close() 492 | 493 | if resp.StatusCode != http.StatusOK { 494 | return errors.Wrap(ErrUnexpectedStatus, "could not delete torrents; hashes: %v | status code: %d", hashes, resp.StatusCode) 495 | } 496 | 497 | return nil 498 | } 499 | 500 | func (c *Client) ReAnnounceTorrents(hashes []string) error { 501 | return c.ReAnnounceTorrentsCtx(context.Background(), hashes) 502 | } 503 | 504 | func (c *Client) ReAnnounceTorrentsCtx(ctx context.Context, hashes []string) error { 505 | // Add hashes together with | separator 506 | hv := strings.Join(hashes, "|") 507 | opts := map[string]string{ 508 | "hashes": hv, 509 | } 510 | 511 | resp, err := c.postCtx(ctx, "torrents/reannounce", opts) 512 | if err != nil { 513 | return errors.Wrap(err, "could not re-announce torrents; hashes: %v", hashes) 514 | } 515 | 516 | defer resp.Body.Close() 517 | 518 | if resp.StatusCode != http.StatusOK { 519 | return errors.Wrap(ErrUnexpectedStatus, "could not re-announce torrents; hashes: %v | status code: %d", hashes, resp.StatusCode) 520 | } 521 | 522 | return nil 523 | } 524 | 525 | func (c *Client) GetTransferInfo() (*TransferInfo, error) { 526 | return c.GetTransferInfoCtx(context.Background()) 527 | } 528 | 529 | func (c *Client) GetTransferInfoCtx(ctx context.Context) (*TransferInfo, error) { 530 | resp, err := c.getCtx(ctx, "transfer/info", nil) 531 | if err != nil { 532 | return nil, errors.Wrap(err, "could not get transfer info") 533 | } 534 | 535 | defer resp.Body.Close() 536 | 537 | body, err := io.ReadAll(resp.Body) 538 | if err != nil { 539 | return nil, errors.Wrap(err, "could not read body") 540 | } 541 | 542 | var info TransferInfo 543 | if err := json.Unmarshal(body, &info); err != nil { 544 | return nil, errors.Wrap(err, "could not unmarshal body") 545 | } 546 | 547 | return &info, nil 548 | } 549 | 550 | // BanPeers bans peers. 551 | // Each peer is a colon-separated host:port pair 552 | func (c *Client) BanPeers(peers []string) error { 553 | return c.BanPeersCtx(context.Background(), peers) 554 | } 555 | 556 | // BanPeersCtx bans peers. 557 | // Each peer is a colon-separated host:port pair 558 | func (c *Client) BanPeersCtx(ctx context.Context, peers []string) error { 559 | data := map[string]string{ 560 | "peers": strings.Join(peers, "|"), 561 | } 562 | 563 | resp, err := c.postCtx(ctx, "transfer/banPeers", data) 564 | if err != nil { 565 | return errors.Wrap(err, "could not ban peers; peers: %v", peers) 566 | } 567 | defer resp.Body.Close() 568 | 569 | if resp.StatusCode != http.StatusOK { 570 | return errors.Wrap(ErrUnexpectedStatus, "could not ban peers; peers: %v | status code: %d", peers, resp.StatusCode) 571 | } 572 | 573 | return nil 574 | } 575 | 576 | // SyncMainDataCtx Sync API implements requests for obtaining changes since the last request. 577 | // Response ID. If not provided, rid=0 will be assumed. If the given rid is different from the one of last server reply, full_update will be true (see the server reply details for more info) 578 | func (c *Client) SyncMainDataCtx(ctx context.Context, rid int64) (*MainData, error) { 579 | opts := map[string]string{ 580 | "rid": strconv.FormatInt(rid, 10), 581 | } 582 | 583 | resp, err := c.getCtx(ctx, "/sync/maindata", opts) 584 | if err != nil { 585 | return nil, errors.Wrap(err, "could not get main data") 586 | } 587 | 588 | defer resp.Body.Close() 589 | 590 | var info MainData 591 | if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { 592 | return nil, errors.Wrap(err, "could not unmarshal body") 593 | } 594 | 595 | return &info, nil 596 | 597 | } 598 | 599 | func (c *Client) Pause(hashes []string) error { 600 | return c.PauseCtx(context.Background(), hashes) 601 | } 602 | 603 | func (c *Client) Stop(hashes []string) error { 604 | return c.PauseCtx(context.Background(), hashes) 605 | } 606 | 607 | func (c *Client) StopCtx(ctx context.Context, hashes []string) error { 608 | return c.PauseCtx(ctx, hashes) 609 | } 610 | 611 | func (c *Client) PauseCtx(ctx context.Context, hashes []string) error { 612 | // Add hashes together with | separator 613 | hv := strings.Join(hashes, "|") 614 | opts := map[string]string{ 615 | "hashes": hv, 616 | } 617 | 618 | endpoint := "torrents/stop" 619 | 620 | // Qbt WebAPI 2.11 changed pause with stop 621 | version, err := c.getApiVersion() 622 | if err != nil { 623 | return errors.Wrap(err, "could not get api version") 624 | } 625 | 626 | if version.Major() == 2 && version.Minor() < 11 { 627 | endpoint = "torrents/pause" 628 | } 629 | 630 | resp, err := c.postCtx(ctx, endpoint, opts) 631 | if err != nil { 632 | return errors.Wrap(err, "could not pause torrents; hashes: %v", hashes) 633 | } 634 | 635 | defer resp.Body.Close() 636 | 637 | if resp.StatusCode != http.StatusOK { 638 | return errors.Wrap(ErrUnexpectedStatus, "could not pause torrents; hashes: %v | status code: %d", hashes, resp.StatusCode) 639 | } 640 | 641 | return nil 642 | } 643 | 644 | func (c *Client) Resume(hashes []string) error { 645 | return c.ResumeCtx(context.Background(), hashes) 646 | } 647 | 648 | func (c *Client) Start(hashes []string) error { 649 | return c.ResumeCtx(context.Background(), hashes) 650 | } 651 | 652 | func (c *Client) StartCtx(ctx context.Context, hashes []string) error { 653 | return c.ResumeCtx(ctx, hashes) 654 | } 655 | 656 | func (c *Client) ResumeCtx(ctx context.Context, hashes []string) error { 657 | // Add hashes together with | separator 658 | hv := strings.Join(hashes, "|") 659 | opts := map[string]string{ 660 | "hashes": hv, 661 | } 662 | 663 | endpoint := "torrents/start" 664 | 665 | // Qbt WebAPI 2.11 changed resume with start 666 | version, err := c.getApiVersion() 667 | 668 | if err != nil { 669 | return errors.Wrap(err, "could not get api version") 670 | } 671 | 672 | if version.Major() == 2 && version.Minor() < 11 { 673 | endpoint = "torrents/resume" 674 | } 675 | 676 | resp, err := c.postCtx(ctx, endpoint, opts) 677 | if err != nil { 678 | return errors.Wrap(err, "could not resume torrents; hashes: %v", hashes) 679 | } 680 | 681 | defer resp.Body.Close() 682 | 683 | if resp.StatusCode != http.StatusOK { 684 | return errors.Wrap(ErrUnexpectedStatus, "could not resume torrents; hashes: %v | status code: %d", hashes, resp.StatusCode) 685 | } 686 | 687 | return nil 688 | } 689 | 690 | func (c *Client) SetForceStart(hashes []string, value bool) error { 691 | return c.SetForceStartCtx(context.Background(), hashes, value) 692 | } 693 | 694 | func (c *Client) SetForceStartCtx(ctx context.Context, hashes []string, value bool) error { 695 | // Add hashes together with | separator 696 | hv := strings.Join(hashes, "|") 697 | opts := map[string]string{ 698 | "hashes": hv, 699 | "value": strconv.FormatBool(value), 700 | } 701 | 702 | resp, err := c.postCtx(ctx, "torrents/setForceStart", opts) 703 | if err != nil { 704 | return errors.Wrap(err, "could not set force start torrents; hashes: %v", hashes) 705 | } 706 | 707 | defer resp.Body.Close() 708 | 709 | if resp.StatusCode != http.StatusOK { 710 | return errors.Wrap(ErrUnexpectedStatus, "could not set force start torrents; hashes: %v | status code: %d", hashes, resp.StatusCode) 711 | } 712 | 713 | return nil 714 | } 715 | 716 | func (c *Client) Recheck(hashes []string) error { 717 | return c.RecheckCtx(context.Background(), hashes) 718 | } 719 | 720 | func (c *Client) RecheckCtx(ctx context.Context, hashes []string) error { 721 | // Add hashes together with | separator 722 | hv := strings.Join(hashes, "|") 723 | opts := map[string]string{ 724 | "hashes": hv, 725 | } 726 | 727 | resp, err := c.postCtx(ctx, "torrents/recheck", opts) 728 | if err != nil { 729 | return errors.Wrap(err, "could not recheck torrents; hashes: %v", hashes) 730 | } 731 | 732 | defer resp.Body.Close() 733 | 734 | if resp.StatusCode != http.StatusOK { 735 | return errors.Wrap(ErrUnexpectedStatus, "could not recheck torrents; hashes: %v | status code: %d", hashes, resp.StatusCode) 736 | } 737 | 738 | return nil 739 | } 740 | 741 | func (c *Client) SetAutoManagement(hashes []string, enable bool) error { 742 | return c.SetAutoManagementCtx(context.Background(), hashes, enable) 743 | } 744 | 745 | func (c *Client) SetAutoManagementCtx(ctx context.Context, hashes []string, enable bool) error { 746 | // Add hashes together with | separator 747 | hv := strings.Join(hashes, "|") 748 | opts := map[string]string{ 749 | "hashes": hv, 750 | "enable": strconv.FormatBool(enable), 751 | } 752 | 753 | resp, err := c.postCtx(ctx, "torrents/setAutoManagement", opts) 754 | if err != nil { 755 | return errors.Wrap(err, "could not set auto management; hashes: %v", hashes) 756 | } 757 | 758 | defer resp.Body.Close() 759 | 760 | if resp.StatusCode != http.StatusOK { 761 | return errors.Wrap(ErrUnexpectedStatus, "could not set auto management; hashes: %v | status code: %d", hashes, resp.StatusCode) 762 | } 763 | 764 | return nil 765 | } 766 | 767 | func (c *Client) SetLocation(hashes []string, location string) error { 768 | return c.SetLocationCtx(context.Background(), hashes, location) 769 | } 770 | 771 | func (c *Client) SetLocationCtx(ctx context.Context, hashes []string, location string) error { 772 | // Add hashes together with | separator 773 | hv := strings.Join(hashes, "|") 774 | opts := map[string]string{ 775 | "hashes": hv, 776 | "location": location, 777 | } 778 | 779 | resp, err := c.postCtx(ctx, "torrents/setLocation", opts) 780 | if err != nil { 781 | return errors.Wrap(err, "could not set location; hashes: %v | location: %s", hashes, location) 782 | } 783 | 784 | defer resp.Body.Close() 785 | 786 | /* 787 | HTTP Status Code Scenario 788 | 400 Save path is empty 789 | 403 User does not have write access to directory 790 | 409 Unable to create save path directory 791 | 200 All other scenarios 792 | */ 793 | switch sc := resp.StatusCode; sc { 794 | case http.StatusOK: 795 | return nil 796 | case http.StatusBadRequest: 797 | return errors.Wrap(ErrEmptySavePath, "save path: %s", location) 798 | case http.StatusForbidden: 799 | return ErrNoWriteAccessToPath 800 | case http.StatusConflict: 801 | return ErrCannotCreateSavePath 802 | default: 803 | return errors.Wrap(ErrUnexpectedStatus, "could not set location; hashes: %v | location: %v | status code: %d", hashes, location, resp.StatusCode) 804 | } 805 | } 806 | 807 | func (c *Client) CreateCategory(category string, path string) error { 808 | return c.CreateCategoryCtx(context.Background(), category, path) 809 | } 810 | 811 | func (c *Client) CreateCategoryCtx(ctx context.Context, category string, path string) error { 812 | opts := map[string]string{ 813 | "category": category, 814 | "savePath": path, 815 | } 816 | 817 | resp, err := c.postCtx(ctx, "torrents/createCategory", opts) 818 | if err != nil { 819 | return errors.Wrap(err, "could not create category; category: %v", category) 820 | } 821 | 822 | defer resp.Body.Close() 823 | 824 | /* 825 | HTTP Status Code Scenario 826 | 400 Category name is empty 827 | 409 Category name is invalid 828 | 200 All other scenarios 829 | */ 830 | switch resp.StatusCode { 831 | case http.StatusOK: 832 | return nil 833 | case http.StatusBadRequest: 834 | return errors.Wrap(ErrEmptyCategoryName, "category name: %s", category) 835 | case http.StatusConflict: 836 | return errors.Wrap(ErrInvalidCategoryName, "category name: %s", category) 837 | default: 838 | return errors.Wrap(ErrUnexpectedStatus, "could not create category; category: %v | status code: %d", category, resp.StatusCode) 839 | } 840 | } 841 | 842 | func (c *Client) EditCategory(category string, path string) error { 843 | return c.EditCategoryCtx(context.Background(), category, path) 844 | } 845 | 846 | func (c *Client) EditCategoryCtx(ctx context.Context, category string, path string) error { 847 | opts := map[string]string{ 848 | "category": category, 849 | "savePath": path, 850 | } 851 | 852 | resp, err := c.postCtx(ctx, "torrents/editCategory", opts) 853 | if err != nil { 854 | return errors.Wrap(err, "could not edit category; category: %v", category) 855 | } 856 | 857 | defer resp.Body.Close() 858 | 859 | /* 860 | HTTP Status Code Scenario 861 | 400 Category name is empty 862 | 409 Category editing failed 863 | 200 All other scenarios 864 | */ 865 | switch resp.StatusCode { 866 | case http.StatusOK: 867 | return nil 868 | case http.StatusBadRequest: 869 | return errors.Wrap(ErrEmptyCategoryName, "category name: %s", category) 870 | case http.StatusConflict: 871 | return ErrCategoryEditingFailed 872 | default: 873 | return errors.Wrap(ErrUnexpectedStatus, "could not edit category; category %v | status code: %d", category, resp.StatusCode) 874 | } 875 | } 876 | 877 | func (c *Client) RemoveCategories(categories []string) error { 878 | return c.RemoveCategoriesCtx(context.Background(), categories) 879 | } 880 | 881 | func (c *Client) RemoveCategoriesCtx(ctx context.Context, categories []string) error { 882 | opts := map[string]string{ 883 | "categories": strings.Join(categories, "\n"), 884 | } 885 | 886 | resp, err := c.postCtx(ctx, "torrents/removeCategories", opts) 887 | if err != nil { 888 | return errors.Wrap(err, "could not remove categories; categories: %v", opts["categories"]) 889 | } 890 | 891 | defer resp.Body.Close() 892 | 893 | if resp.StatusCode != http.StatusOK { 894 | return errors.Wrap(ErrUnexpectedStatus, "could not remove categories; categories: %v | status code: %d", opts["categories"], resp.StatusCode) 895 | } 896 | 897 | return nil 898 | } 899 | 900 | func (c *Client) SetCategory(hashes []string, category string) error { 901 | return c.SetCategoryCtx(context.Background(), hashes, category) 902 | } 903 | 904 | func (c *Client) SetCategoryCtx(ctx context.Context, hashes []string, category string) error { 905 | // Add hashes together with | separator 906 | hv := strings.Join(hashes, "|") 907 | opts := map[string]string{ 908 | "hashes": hv, 909 | "category": category, 910 | } 911 | 912 | resp, err := c.postCtx(ctx, "torrents/setCategory", opts) 913 | if err != nil { 914 | return errors.Wrap(err, "could not set category; hashes: %v | category: %s", hashes, category) 915 | } 916 | 917 | defer resp.Body.Close() 918 | 919 | /* 920 | HTTP Status Code Scenario 921 | 409 Category name does not exist 922 | 200 All other scenarios 923 | */ 924 | switch resp.StatusCode { 925 | case http.StatusOK: 926 | return nil 927 | case http.StatusConflict: 928 | return errors.Wrap(ErrCategoryDoesNotExist, "category name: %s", category) 929 | default: 930 | return errors.Wrap(ErrUnexpectedStatus, "could not set category; hashes: %v | cateogry: %s | status code: %d", hashes, category, resp.StatusCode) 931 | } 932 | } 933 | 934 | func (c *Client) GetCategories() (map[string]Category, error) { 935 | return c.GetCategoriesCtx(context.Background()) 936 | } 937 | 938 | func (c *Client) GetCategoriesCtx(ctx context.Context) (map[string]Category, error) { 939 | resp, err := c.getCtx(ctx, "torrents/categories", nil) 940 | if err != nil { 941 | return nil, errors.Wrap(err, "could not get files info") 942 | } 943 | 944 | defer resp.Body.Close() 945 | 946 | body, err := io.ReadAll(resp.Body) 947 | if err != nil { 948 | return nil, errors.Wrap(err, "could not read body") 949 | } 950 | 951 | m := make(map[string]Category) 952 | if err := json.Unmarshal(body, &m); err != nil { 953 | return nil, errors.Wrap(err, "could not unmarshal body") 954 | } 955 | 956 | return m, nil 957 | } 958 | 959 | func (c *Client) GetFilesInformation(hash string) (*TorrentFiles, error) { 960 | return c.GetFilesInformationCtx(context.Background(), hash) 961 | } 962 | 963 | func (c *Client) GetFilesInformationCtx(ctx context.Context, hash string) (*TorrentFiles, error) { 964 | opts := map[string]string{ 965 | "hash": hash, 966 | } 967 | 968 | resp, err := c.getCtx(ctx, "torrents/files", opts) 969 | if err != nil { 970 | return nil, errors.Wrap(err, "could not get files info") 971 | } 972 | 973 | defer resp.Body.Close() 974 | 975 | body, err := io.ReadAll(resp.Body) 976 | if err != nil { 977 | return nil, errors.Wrap(err, "could not read body") 978 | } 979 | 980 | var info TorrentFiles 981 | if err := json.Unmarshal(body, &info); err != nil { 982 | return nil, errors.Wrap(err, "could not unmarshal body") 983 | } 984 | 985 | return &info, nil 986 | } 987 | 988 | // SetFilePriority Set file priority 989 | func (c *Client) SetFilePriority(hash string, IDs string, priority int) error { 990 | return c.SetFilePriorityCtx(context.Background(), hash, IDs, priority) 991 | } 992 | 993 | // SetFilePriorityCtx Set file priority 994 | func (c *Client) SetFilePriorityCtx(ctx context.Context, hash string, IDs string, priority int) error { 995 | opts := map[string]string{ 996 | "hash": hash, 997 | "id": IDs, 998 | "priority": strconv.Itoa(priority), 999 | } 1000 | 1001 | resp, err := c.postCtx(ctx, "torrents/filePrio", opts) 1002 | if err != nil { 1003 | return errors.Wrap(err, "could not set file priority; hash: %s | priority: %d", hash, priority) 1004 | } 1005 | 1006 | defer resp.Body.Close() 1007 | 1008 | /* 1009 | HTTP Status Code Scenario 1010 | 400 Priority is invalid 1011 | 400 At least one file id is not a valid integer 1012 | 404 Torrent hash was not found 1013 | 409 Torrent metadata hasn't downloaded yet 1014 | 409 At least one file id was not found 1015 | 200 All other scenarios 1016 | */ 1017 | switch resp.StatusCode { 1018 | case http.StatusBadRequest: 1019 | return ErrInvalidPriority 1020 | case http.StatusNotFound: 1021 | return errors.Wrap(ErrTorrentNotFound, "hash: %s", hash) 1022 | case http.StatusConflict: 1023 | return ErrTorrentMetdataNotDownloadedYet 1024 | case http.StatusOK: 1025 | return nil 1026 | default: 1027 | return errors.Wrap(ErrUnexpectedStatus, "could not set file priority; hash: %v | priority: %d | status code: %d", hash, priority, resp.StatusCode) 1028 | } 1029 | } 1030 | 1031 | func (c *Client) ExportTorrent(hash string) ([]byte, error) { 1032 | return c.ExportTorrentCtx(context.Background(), hash) 1033 | } 1034 | 1035 | func (c *Client) ExportTorrentCtx(ctx context.Context, hash string) ([]byte, error) { 1036 | opts := map[string]string{ 1037 | "hash": hash, 1038 | } 1039 | 1040 | resp, err := c.getCtx(ctx, "torrents/export", opts) 1041 | if err != nil { 1042 | return nil, errors.Wrap(err, "could not get export") 1043 | } 1044 | 1045 | defer resp.Body.Close() 1046 | 1047 | return io.ReadAll(resp.Body) 1048 | } 1049 | 1050 | func (c *Client) RenameFile(hash, oldPath, newPath string) error { 1051 | return c.RenameFileCtx(context.Background(), hash, oldPath, newPath) 1052 | } 1053 | 1054 | func (c *Client) RenameFileCtx(ctx context.Context, hash, oldPath, newPath string) error { 1055 | opts := map[string]string{ 1056 | "hash": hash, 1057 | "oldPath": oldPath, 1058 | "newPath": newPath, 1059 | } 1060 | 1061 | resp, err := c.postCtx(ctx, "torrents/renameFile", opts) 1062 | if err != nil { 1063 | return errors.Wrap(err, "could not rename file; hash: %v | oldPath: %v | newPath: %v", hash, oldPath, newPath) 1064 | } 1065 | 1066 | defer resp.Body.Close() 1067 | 1068 | /* 1069 | HTTP Status Code Scenario 1070 | 400 Missing newPath parameter 1071 | 409 Invalid newPath or oldPath, or newPath already in use 1072 | 200 All other scenarios 1073 | */ 1074 | switch resp.StatusCode { 1075 | case http.StatusBadRequest: 1076 | return errors.Wrap(ErrMissingNewPathParameter, "newPath: %v", newPath) 1077 | case http.StatusConflict: 1078 | return errors.Wrap(ErrInvalidPathParameter, "oldPath: %v | newPath: %v", oldPath, newPath) 1079 | case http.StatusOK: 1080 | return nil 1081 | default: 1082 | return errors.Wrap(ErrUnexpectedStatus, "could not rename file; hash %v | oldPath: %v | newPath: %v | status code: %d", hash, oldPath, newPath, resp.StatusCode) 1083 | } 1084 | } 1085 | 1086 | // RenameFolder Rename folder in torrent 1087 | func (c *Client) RenameFolder(hash, oldPath, newPath string) error { 1088 | return c.RenameFolderCtx(context.Background(), hash, oldPath, newPath) 1089 | } 1090 | 1091 | // RenameFolderCtx Rename folder in torrent 1092 | func (c *Client) RenameFolderCtx(ctx context.Context, hash, oldPath, newPath string) error { 1093 | opts := map[string]string{ 1094 | "hash": hash, 1095 | "oldPath": oldPath, 1096 | "newPath": newPath, 1097 | } 1098 | 1099 | resp, err := c.postCtx(ctx, "torrents/renameFolder", opts) 1100 | if err != nil { 1101 | return errors.Wrap(err, "could not rename folder; hash: %v | oldPath: %v | newPath: %v", hash, oldPath, newPath) 1102 | } 1103 | 1104 | defer func(Body io.ReadCloser) { 1105 | _ = Body.Close() 1106 | }(resp.Body) 1107 | 1108 | switch resp.StatusCode { 1109 | case http.StatusBadRequest: 1110 | return errors.Wrap(ErrMissingNewPathParameter, "newPath: %v", newPath) 1111 | case http.StatusConflict: 1112 | return errors.Wrap(ErrInvalidPathParameter, "oldPath: %v | newPath: %v", oldPath, newPath) 1113 | case http.StatusOK: 1114 | return nil 1115 | default: 1116 | return errors.Wrap(ErrUnexpectedStatus, "could not rename folder; hash %v | oldPath: %v | newPath: %v | status code: %d", hash, oldPath, newPath, resp.StatusCode) 1117 | } 1118 | } 1119 | 1120 | // SetTorrentName set name for torrent specified by hash 1121 | func (c *Client) SetTorrentName(hash string, name string) error { 1122 | return c.SetTorrentNameCtx(context.Background(), hash, name) 1123 | } 1124 | 1125 | // SetTorrentNameCtx set name for torrent specified by hash 1126 | func (c *Client) SetTorrentNameCtx(ctx context.Context, hash string, name string) error { 1127 | opts := map[string]string{ 1128 | "hash": hash, 1129 | "name": name, 1130 | } 1131 | 1132 | resp, err := c.postCtx(ctx, "torrents/rename", opts) 1133 | if err != nil { 1134 | return errors.Wrap(err, "could not rename torrent; hash: %v | name: %v", hash, name) 1135 | } 1136 | 1137 | defer resp.Body.Close() 1138 | 1139 | switch sc := resp.StatusCode; sc { 1140 | case http.StatusOK: 1141 | return nil 1142 | case http.StatusNotFound: 1143 | return errors.Wrap(ErrInvalidTorrentHash, "torrent hash: %v", hash) 1144 | case http.StatusConflict: 1145 | return errors.Wrap(ErrEmptyTorrentName, "torrent name: %v", name) 1146 | default: 1147 | return errors.Wrap(ErrUnexpectedStatus, "could not rename torrent; hash: %v | name: %s |status code: %d", hash, name, resp.StatusCode) 1148 | } 1149 | } 1150 | 1151 | func (c *Client) GetTags() ([]string, error) { 1152 | return c.GetTagsCtx(context.Background()) 1153 | } 1154 | 1155 | func (c *Client) GetTagsCtx(ctx context.Context) ([]string, error) { 1156 | resp, err := c.getCtx(ctx, "torrents/tags", nil) 1157 | if err != nil { 1158 | return nil, errors.Wrap(err, "could not get tags") 1159 | } 1160 | 1161 | defer resp.Body.Close() 1162 | 1163 | body, err := io.ReadAll(resp.Body) 1164 | if err != nil { 1165 | return nil, errors.Wrap(err, "could not read body") 1166 | } 1167 | 1168 | m := make([]string, 0) 1169 | if err := json.Unmarshal(body, &m); err != nil { 1170 | return nil, errors.Wrap(err, "could not unmarshal body") 1171 | } 1172 | 1173 | return m, nil 1174 | } 1175 | 1176 | func (c *Client) CreateTags(tags []string) error { 1177 | return c.CreateTagsCtx(context.Background(), tags) 1178 | } 1179 | 1180 | func (c *Client) CreateTagsCtx(ctx context.Context, tags []string) error { 1181 | t := strings.Join(tags, ",") 1182 | 1183 | opts := map[string]string{ 1184 | "tags": t, 1185 | } 1186 | 1187 | resp, err := c.postCtx(ctx, "torrents/createTags", opts) 1188 | if err != nil { 1189 | return errors.Wrap(err, "could not create tags; tags: %v", t) 1190 | } 1191 | 1192 | defer resp.Body.Close() 1193 | 1194 | if resp.StatusCode != http.StatusOK { 1195 | return errors.Wrap(ErrUnexpectedStatus, "could not create tags; tags: %v | status code: %d", t, resp.StatusCode) 1196 | } 1197 | 1198 | return nil 1199 | } 1200 | 1201 | func (c *Client) AddTags(hashes []string, tags string) error { 1202 | return c.AddTagsCtx(context.Background(), hashes, tags) 1203 | } 1204 | 1205 | func (c *Client) AddTagsCtx(ctx context.Context, hashes []string, tags string) error { 1206 | // Add hashes together with | separator 1207 | hv := strings.Join(hashes, "|") 1208 | opts := map[string]string{ 1209 | "hashes": hv, 1210 | "tags": tags, 1211 | } 1212 | 1213 | resp, err := c.postCtx(ctx, "torrents/addTags", opts) 1214 | if err != nil { 1215 | return errors.Wrap(err, "could not add tags; hashes: %v | tags: %v", hashes, tags) 1216 | } 1217 | 1218 | defer resp.Body.Close() 1219 | 1220 | if resp.StatusCode != http.StatusOK { 1221 | return errors.Wrap(ErrUnexpectedStatus, "could not add tags; hashes: %v | tags: %v | status code: %d", hashes, tags, resp.StatusCode) 1222 | } 1223 | 1224 | return nil 1225 | } 1226 | 1227 | // SetTags is a new method in qBittorrent 5.1 WebAPI 2.11.4 that allows for upserting tags in one go, instead of having to remove and add tags in different calls. 1228 | // For client instances with a lot of torrents, this will benefit a lot. 1229 | // It checks for the required min version, and if it's less than the required version, it will error, and then the caller can handle it how they want. 1230 | func (c *Client) SetTags(ctx context.Context, hashes []string, tags string) error { 1231 | if ok, err := c.RequiresMinVersion(semver.MustParse("2.11.4")); !ok { 1232 | return errors.Wrap(err, "SetTags requires qBittorrent 5.1 and WebAPI >= 2.11.4") 1233 | } 1234 | 1235 | // Add hashes together with | separator 1236 | hv := strings.Join(hashes, "|") 1237 | opts := map[string]string{ 1238 | "hashes": hv, 1239 | "tags": tags, 1240 | } 1241 | 1242 | resp, err := c.postCtx(ctx, "torrents/setTags", opts) 1243 | if err != nil { 1244 | return errors.Wrap(err, "could not set tags; hashes: %v", hashes) 1245 | } 1246 | 1247 | defer resp.Body.Close() 1248 | 1249 | if resp.StatusCode != http.StatusOK { 1250 | return errors.Wrap(ErrUnexpectedStatus, "could not set tags; hashes: %v | status code: %d", hashes, resp.StatusCode) 1251 | } 1252 | 1253 | return nil 1254 | } 1255 | 1256 | // DeleteTags delete tags from qBittorrent 1257 | func (c *Client) DeleteTags(tags []string) error { 1258 | return c.DeleteTagsCtx(context.Background(), tags) 1259 | } 1260 | 1261 | // DeleteTagsCtx delete tags from qBittorrent 1262 | func (c *Client) DeleteTagsCtx(ctx context.Context, tags []string) error { 1263 | t := strings.Join(tags, ",") 1264 | 1265 | opts := map[string]string{ 1266 | "tags": t, 1267 | } 1268 | 1269 | resp, err := c.postCtx(ctx, "torrents/deleteTags", opts) 1270 | if err != nil { 1271 | return errors.Wrap(err, "could not delete tags; tags: %s", t) 1272 | } 1273 | 1274 | defer resp.Body.Close() 1275 | 1276 | if resp.StatusCode != http.StatusOK { 1277 | return errors.Wrap(ErrUnexpectedStatus, "could not delete tags; tags: %s | status code: %d", t, resp.StatusCode) 1278 | } 1279 | 1280 | return nil 1281 | } 1282 | 1283 | // RemoveTags remove tags from torrents specified by hashes 1284 | func (c *Client) RemoveTags(hashes []string, tags string) error { 1285 | return c.RemoveTagsCtx(context.Background(), hashes, tags) 1286 | } 1287 | 1288 | // RemoveTagsCtx remove tags from torrents specified by hashes 1289 | func (c *Client) RemoveTagsCtx(ctx context.Context, hashes []string, tags string) error { 1290 | // Add hashes together with | separator 1291 | hv := strings.Join(hashes, "|") 1292 | 1293 | opts := map[string]string{ 1294 | "hashes": hv, 1295 | } 1296 | 1297 | if len(tags) != 0 { 1298 | opts["tags"] = tags 1299 | } 1300 | 1301 | resp, err := c.postCtx(ctx, "torrents/removeTags", opts) 1302 | if err != nil { 1303 | return errors.Wrap(err, "could not remove tags; hashes: %v | tags %s", hashes, tags) 1304 | } 1305 | 1306 | defer resp.Body.Close() 1307 | 1308 | if resp.StatusCode != http.StatusOK { 1309 | return errors.Wrap(ErrUnexpectedStatus, "could not remove tags; hashes: %v | tags: %s | status code: %d", hashes, tags, resp.StatusCode) 1310 | } 1311 | 1312 | return nil 1313 | } 1314 | 1315 | // RemoveTracker remove trackers of torrent 1316 | func (c *Client) RemoveTrackers(hash string, urls string) error { 1317 | return c.RemoveTrackersCtx(context.Background(), hash, urls) 1318 | } 1319 | 1320 | // RemoveTrackersCtx remove trackers of torrent 1321 | func (c *Client) RemoveTrackersCtx(ctx context.Context, hash string, urls string) error { 1322 | opts := map[string]string{ 1323 | "hash": hash, 1324 | "urls": urls, 1325 | } 1326 | 1327 | resp, err := c.postCtx(ctx, "torrents/removeTrackers", opts) 1328 | if err != nil { 1329 | return errors.Wrap(err, "could not remove trackers; hash: %s | urls: %s", hash, urls) 1330 | } 1331 | 1332 | defer resp.Body.Close() 1333 | 1334 | /* 1335 | HTTP Status Code Scenario 1336 | 404 Torrent hash was not found 1337 | 409 All URLs were not found 1338 | 200 All other scenarios 1339 | */ 1340 | switch resp.StatusCode { 1341 | case http.StatusNotFound: 1342 | return errors.Wrap(ErrTorrentNotFound, "torrent hash: %v", hash) 1343 | case http.StatusConflict: 1344 | return errors.Wrap(ErrAllURLsNotFound, "urls: %v", urls) 1345 | case http.StatusOK: 1346 | return nil 1347 | default: 1348 | return errors.Wrap(ErrUnexpectedStatus, "could not remove trackers; hash: %s | urls: %s | status code: %d", hash, urls, resp.StatusCode) 1349 | } 1350 | } 1351 | 1352 | // EditTracker edit tracker of torrent 1353 | func (c *Client) EditTracker(hash string, old, new string) error { 1354 | return c.EditTrackerCtx(context.Background(), hash, old, new) 1355 | } 1356 | 1357 | // EditTrackerCtx edit tracker of torrent 1358 | func (c *Client) EditTrackerCtx(ctx context.Context, hash string, old, new string) error { 1359 | opts := map[string]string{ 1360 | "hash": hash, 1361 | "origUrl": old, 1362 | "newUrl": new, 1363 | } 1364 | 1365 | resp, err := c.postCtx(ctx, "torrents/editTracker", opts) 1366 | if err != nil { 1367 | return errors.Wrap(err, "could not edit tracker; hash: %s | old: %s | new: %s", hash, old, new) 1368 | } 1369 | 1370 | defer resp.Body.Close() 1371 | 1372 | /* 1373 | HTTP Status Code Scenario 1374 | 400 newUrl is not a valid URL 1375 | 404 Torrent hash was not found 1376 | 409 newUrl already exists for the torrent 1377 | 409 origUrl was not found 1378 | 200 All other scenarios 1379 | */ 1380 | switch resp.StatusCode { 1381 | case http.StatusBadRequest: 1382 | return errors.Wrap(ErrInvalidURL, "new url: %v", new) 1383 | case http.StatusNotFound: 1384 | return errors.Wrap(ErrTorrentNotFound, "torrent hash: %v", hash) 1385 | case http.StatusConflict: 1386 | return nil 1387 | case http.StatusOK: 1388 | return nil 1389 | default: 1390 | return errors.Wrap(ErrUnexpectedStatus, "could not edit tracker; hash: %s | old: %s | new: %s | status code: %d", hash, old, new, resp.StatusCode) 1391 | } 1392 | } 1393 | 1394 | // AddTrackers add trackers of torrent 1395 | func (c *Client) AddTrackers(hash string, urls string) error { 1396 | return c.AddTrackersCtx(context.Background(), hash, urls) 1397 | } 1398 | 1399 | // AddTrackersCtx add trackers of torrent 1400 | func (c *Client) AddTrackersCtx(ctx context.Context, hash string, urls string) error { 1401 | opts := map[string]string{ 1402 | "hash": hash, 1403 | "urls": urls, 1404 | } 1405 | 1406 | resp, err := c.postCtx(ctx, "torrents/addTrackers", opts) 1407 | if err != nil { 1408 | return errors.Wrap(err, "could not add trackers; hash: %s | urls: %s", hash, urls) 1409 | } 1410 | 1411 | defer resp.Body.Close() 1412 | 1413 | /* 1414 | HTTP Status Code Scenario 1415 | 404 Torrent hash was not found 1416 | 200 All other scenarios 1417 | */ 1418 | switch resp.StatusCode { 1419 | case http.StatusNotFound: 1420 | return errors.Wrap(ErrTorrentNotFound, "torrent hash: %v", hash) 1421 | case http.StatusOK: 1422 | return nil 1423 | default: 1424 | return errors.Wrap(ErrUnexpectedStatus, "could not add trackers; hash: %s | urls: %s | status code: %d", hash, urls, resp.StatusCode) 1425 | } 1426 | } 1427 | 1428 | // SetPreferencesQueueingEnabled enable/disable torrent queueing 1429 | func (c *Client) SetPreferencesQueueingEnabled(enabled bool) error { 1430 | return c.SetPreferences(map[string]interface{}{"queueing_enabled": enabled}) 1431 | } 1432 | 1433 | // SetPreferencesMaxActiveDownloads set max active downloads 1434 | func (c *Client) SetPreferencesMaxActiveDownloads(max int) error { 1435 | return c.SetPreferences(map[string]interface{}{"max_active_downloads": max}) 1436 | } 1437 | 1438 | // SetPreferencesMaxActiveTorrents set max active torrents 1439 | func (c *Client) SetPreferencesMaxActiveTorrents(max int) error { 1440 | return c.SetPreferences(map[string]interface{}{"max_active_torrents": max}) 1441 | } 1442 | 1443 | // SetPreferencesMaxActiveUploads set max active uploads 1444 | func (c *Client) SetPreferencesMaxActiveUploads(max int) error { 1445 | return c.SetPreferences(map[string]interface{}{"max_active_uploads": max}) 1446 | } 1447 | 1448 | // SetMaxPriority set torrents to max priority specified by hashes 1449 | func (c *Client) SetMaxPriority(hashes []string) error { 1450 | return c.SetMaxPriorityCtx(context.Background(), hashes) 1451 | } 1452 | 1453 | // SetMaxPriorityCtx set torrents to max priority specified by hashes 1454 | func (c *Client) SetMaxPriorityCtx(ctx context.Context, hashes []string) error { 1455 | // Add hashes together with | separator 1456 | hv := strings.Join(hashes, "|") 1457 | 1458 | opts := map[string]string{ 1459 | "hashes": hv, 1460 | } 1461 | 1462 | resp, err := c.postCtx(ctx, "torrents/topPrio", opts) 1463 | if err != nil { 1464 | return errors.Wrap(err, "could not set maximum priority; hashes: %v", hashes) 1465 | } 1466 | 1467 | defer resp.Body.Close() 1468 | 1469 | if resp.StatusCode == http.StatusConflict { 1470 | return errors.Wrap(ErrTorrentQueueingNotEnabled, "hashes: %v", hashes) 1471 | } else if resp.StatusCode != http.StatusOK { 1472 | return errors.Wrap(ErrUnexpectedStatus, "could not set maximum priority; hashes: %v | status code: %d", hashes, resp.StatusCode) 1473 | } 1474 | 1475 | return nil 1476 | } 1477 | 1478 | // SetMinPriority set torrents to min priority specified by hashes 1479 | func (c *Client) SetMinPriority(hashes []string) error { 1480 | return c.SetMinPriorityCtx(context.Background(), hashes) 1481 | } 1482 | 1483 | // SetMinPriorityCtx set torrents to min priority specified by hashes 1484 | func (c *Client) SetMinPriorityCtx(ctx context.Context, hashes []string) error { 1485 | // Add hashes together with | separator 1486 | hv := strings.Join(hashes, "|") 1487 | 1488 | opts := map[string]string{ 1489 | "hashes": hv, 1490 | } 1491 | 1492 | resp, err := c.postCtx(ctx, "torrents/bottomPrio", opts) 1493 | if err != nil { 1494 | return errors.Wrap(err, "could not set minimum priority; hashes: %v", hashes) 1495 | } 1496 | 1497 | defer resp.Body.Close() 1498 | 1499 | if resp.StatusCode == http.StatusConflict { 1500 | return errors.Wrap(ErrTorrentQueueingNotEnabled, "hashes: %v", hashes) 1501 | } else if resp.StatusCode != http.StatusOK { 1502 | return errors.Wrap(ErrUnexpectedStatus, "could not set minimum priority; hashes: %v | status code: %d", hashes, resp.StatusCode) 1503 | } 1504 | 1505 | return nil 1506 | } 1507 | 1508 | // DecreasePriority decrease priority for torrents specified by hashes 1509 | func (c *Client) DecreasePriority(hashes []string) error { 1510 | return c.DecreasePriorityCtx(context.Background(), hashes) 1511 | } 1512 | 1513 | // DecreasePriorityCtx decrease priority for torrents specified by hashes 1514 | func (c *Client) DecreasePriorityCtx(ctx context.Context, hashes []string) error { 1515 | // Add hashes together with | separator 1516 | hv := strings.Join(hashes, "|") 1517 | 1518 | opts := map[string]string{ 1519 | "hashes": hv, 1520 | } 1521 | 1522 | resp, err := c.postCtx(ctx, "torrents/decreasePrio", opts) 1523 | if err != nil { 1524 | return errors.Wrap(err, "could not decrease priority; hashes: %v", hashes) 1525 | } 1526 | 1527 | defer resp.Body.Close() 1528 | 1529 | if resp.StatusCode == http.StatusConflict { 1530 | return errors.Wrap(ErrTorrentQueueingNotEnabled, "hashes: %v", hashes) 1531 | } else if resp.StatusCode != http.StatusOK { 1532 | return errors.Wrap(ErrUnexpectedStatus, "could not decrease priority; hashes: %v | status code: %d", hashes, resp.StatusCode) 1533 | } 1534 | 1535 | return nil 1536 | } 1537 | 1538 | // IncreasePriority increase priority for torrents specified by hashes 1539 | func (c *Client) IncreasePriority(hashes []string) error { 1540 | return c.IncreasePriorityCtx(context.Background(), hashes) 1541 | } 1542 | 1543 | // IncreasePriorityCtx increase priority for torrents specified by hashes 1544 | func (c *Client) IncreasePriorityCtx(ctx context.Context, hashes []string) error { 1545 | // Add hashes together with | separator 1546 | hv := strings.Join(hashes, "|") 1547 | 1548 | opts := map[string]string{ 1549 | "hashes": hv, 1550 | } 1551 | 1552 | resp, err := c.postCtx(ctx, "torrents/increasePrio", opts) 1553 | if err != nil { 1554 | return errors.Wrap(err, "could not increase torrent priority; hashes: %v", hashes) 1555 | } 1556 | 1557 | defer resp.Body.Close() 1558 | 1559 | if resp.StatusCode == http.StatusConflict { 1560 | return errors.Wrap(ErrTorrentQueueingNotEnabled, "hashes: %v", hashes) 1561 | } else if resp.StatusCode != http.StatusOK { 1562 | return errors.Wrap(ErrUnexpectedStatus, "could not increase priority; hashes: %v | status code: %d", hashes, resp.StatusCode) 1563 | } 1564 | 1565 | return nil 1566 | } 1567 | 1568 | // ToggleFirstLastPiecePrio toggles the priority of the first and last pieces of torrents specified by hashes 1569 | func (c *Client) ToggleFirstLastPiecePrio(hashes []string) error { 1570 | return c.ToggleFirstLastPiecePrioCtx(context.Background(), hashes) 1571 | } 1572 | 1573 | // ToggleFirstLastPiecePrioCtx toggles the priority of the first and last pieces of torrents specified by hashes 1574 | func (c *Client) ToggleFirstLastPiecePrioCtx(ctx context.Context, hashes []string) error { 1575 | hv := strings.Join(hashes, "|") 1576 | 1577 | opts := map[string]string{ 1578 | "hashes": hv, 1579 | } 1580 | 1581 | resp, err := c.postCtx(ctx, "torrents/toggleFirstLastPiecePrio", opts) 1582 | if err != nil { 1583 | return errors.Wrap(err, "could not toggle first/last piece priority; hashes: %v", hashes) 1584 | } 1585 | 1586 | defer resp.Body.Close() 1587 | 1588 | if resp.StatusCode != http.StatusOK { 1589 | return errors.Wrap(ErrUnexpectedStatus, "could not toggle first/last piece priority; hashes: %v | status code: %d", hashes, resp.StatusCode) 1590 | } 1591 | 1592 | return nil 1593 | } 1594 | 1595 | // ToggleAlternativeSpeedLimits toggle alternative speed limits globally 1596 | func (c *Client) ToggleAlternativeSpeedLimits() error { 1597 | return c.ToggleAlternativeSpeedLimitsCtx(context.Background()) 1598 | } 1599 | 1600 | // ToggleAlternativeSpeedLimitsCtx toggle alternative speed limits globally 1601 | func (c *Client) ToggleAlternativeSpeedLimitsCtx(ctx context.Context) error { 1602 | resp, err := c.postCtx(ctx, "transfer/toggleSpeedLimitsMode", nil) 1603 | if err != nil { 1604 | return errors.Wrap(err, "could not toggle alternative speed limits") 1605 | } 1606 | 1607 | defer resp.Body.Close() 1608 | 1609 | if resp.StatusCode != http.StatusOK { 1610 | return errors.Wrap(ErrUnexpectedStatus, "could not stoggle alternative speed limits; status code: %d", resp.StatusCode) 1611 | } 1612 | 1613 | return nil 1614 | } 1615 | 1616 | // GetAlternativeSpeedLimitsMode get alternative speed limits mode 1617 | func (c *Client) GetAlternativeSpeedLimitsMode() (bool, error) { 1618 | return c.GetAlternativeSpeedLimitsModeCtx(context.Background()) 1619 | } 1620 | 1621 | // GetAlternativeSpeedLimitsModeCtx get alternative speed limits mode 1622 | func (c *Client) GetAlternativeSpeedLimitsModeCtx(ctx context.Context) (bool, error) { 1623 | var m bool 1624 | resp, err := c.getCtx(ctx, "transfer/speedLimitsMode", nil) 1625 | if err != nil { 1626 | return m, errors.Wrap(err, "could not get alternative speed limits mode") 1627 | } 1628 | 1629 | defer resp.Body.Close() 1630 | 1631 | body, err := io.ReadAll(resp.Body) 1632 | if err != nil { 1633 | return m, errors.Wrap(err, "could not read body") 1634 | } 1635 | var d int64 1636 | if err := json.Unmarshal(body, &d); err != nil { 1637 | return m, errors.Wrap(err, "could not unmarshal body") 1638 | } 1639 | m = d == 1 1640 | return m, nil 1641 | } 1642 | 1643 | // SetGlobalDownloadLimit set download limit globally 1644 | func (c *Client) SetGlobalDownloadLimit(limit int64) error { 1645 | return c.SetGlobalDownloadLimitCtx(context.Background(), limit) 1646 | } 1647 | 1648 | // SetGlobalDownloadLimitCtx set download limit globally 1649 | func (c *Client) SetGlobalDownloadLimitCtx(ctx context.Context, limit int64) error { 1650 | opts := map[string]string{ 1651 | "limit": strconv.FormatInt(limit, 10), 1652 | } 1653 | 1654 | resp, err := c.postCtx(ctx, "transfer/setDownloadLimit", opts) 1655 | if err != nil { 1656 | return errors.Wrap(err, "could not set global download limit; limit: %d", limit) 1657 | } 1658 | 1659 | defer resp.Body.Close() 1660 | 1661 | if resp.StatusCode != http.StatusOK { 1662 | return errors.Wrap(ErrUnexpectedStatus, "could not set global download limit; limit: %d | status code: %d", limit, resp.StatusCode) 1663 | } 1664 | 1665 | return nil 1666 | } 1667 | 1668 | // GetGlobalDownloadLimit get global upload limit 1669 | func (c *Client) GetGlobalDownloadLimit() (int64, error) { 1670 | return c.GetGlobalDownloadLimitCtx(context.Background()) 1671 | } 1672 | 1673 | // GetGlobalDownloadLimitCtx get global upload limit 1674 | func (c *Client) GetGlobalDownloadLimitCtx(ctx context.Context) (int64, error) { 1675 | var m int64 1676 | resp, err := c.getCtx(ctx, "transfer/downloadLimit", nil) 1677 | if err != nil { 1678 | return m, errors.Wrap(err, "could not get global download limit") 1679 | } 1680 | 1681 | defer resp.Body.Close() 1682 | 1683 | body, err := io.ReadAll(resp.Body) 1684 | if err != nil { 1685 | return m, errors.Wrap(err, "could not read body") 1686 | } 1687 | 1688 | if err := json.Unmarshal(body, &m); err != nil { 1689 | return m, errors.Wrap(err, "could not unmarshal body") 1690 | } 1691 | 1692 | return m, nil 1693 | } 1694 | 1695 | // SetGlobalUploadLimit set upload limit globally 1696 | func (c *Client) SetGlobalUploadLimit(limit int64) error { 1697 | return c.SetGlobalUploadLimitCtx(context.Background(), limit) 1698 | } 1699 | 1700 | // SetGlobalUploadLimitCtx set upload limit globally 1701 | func (c *Client) SetGlobalUploadLimitCtx(ctx context.Context, limit int64) error { 1702 | opts := map[string]string{ 1703 | "limit": strconv.FormatInt(limit, 10), 1704 | } 1705 | 1706 | resp, err := c.postCtx(ctx, "transfer/setUploadLimit", opts) 1707 | if err != nil { 1708 | return errors.Wrap(err, "could not set global upload limit; limit %d", limit) 1709 | } 1710 | 1711 | defer resp.Body.Close() 1712 | 1713 | if resp.StatusCode != http.StatusOK { 1714 | return errors.Wrap(ErrUnexpectedStatus, "could not set global upload limit; limit %d | status code: %d", limit, resp.StatusCode) 1715 | } 1716 | 1717 | return nil 1718 | } 1719 | 1720 | // GetGlobalUploadLimit get global upload limit 1721 | func (c *Client) GetGlobalUploadLimit() (int64, error) { 1722 | return c.GetGlobalUploadLimitCtx(context.Background()) 1723 | } 1724 | 1725 | // GetGlobalUploadLimitCtx get global upload limit 1726 | func (c *Client) GetGlobalUploadLimitCtx(ctx context.Context) (int64, error) { 1727 | var m int64 1728 | resp, err := c.getCtx(ctx, "transfer/uploadLimit", nil) 1729 | if err != nil { 1730 | return m, errors.Wrap(err, "could not get global upload limit") 1731 | } 1732 | 1733 | defer resp.Body.Close() 1734 | 1735 | body, err := io.ReadAll(resp.Body) 1736 | if err != nil { 1737 | return m, errors.Wrap(err, "could not read body") 1738 | } 1739 | 1740 | if err := json.Unmarshal(body, &m); err != nil { 1741 | return m, errors.Wrap(err, "could not unmarshal body") 1742 | } 1743 | 1744 | return m, nil 1745 | } 1746 | 1747 | // GetTorrentUploadLimit get upload speed limit for torrents specified by hashes. 1748 | // 1749 | // example response: 1750 | // 1751 | // { 1752 | // "8c212779b4abde7c6bc608063a0d008b7e40ce32":338944, 1753 | // "284b83c9c7935002391129fd97f43db5d7cc2ba0":123 1754 | // } 1755 | // 1756 | // 8c212779b4abde7c6bc608063a0d008b7e40ce32 is the hash of the torrent and 1757 | // 338944 its upload speed limit in bytes per second; 1758 | // this value will be zero if no limit is applied. 1759 | func (c *Client) GetTorrentUploadLimit(hashes []string) (map[string]int64, error) { 1760 | return c.GetTorrentUploadLimitCtx(context.Background(), hashes) 1761 | } 1762 | 1763 | // GetTorrentUploadLimitCtx get upload speed limit for torrents specified by hashes. 1764 | // 1765 | // example response: 1766 | // 1767 | // { 1768 | // "8c212779b4abde7c6bc608063a0d008b7e40ce32":338944, 1769 | // "284b83c9c7935002391129fd97f43db5d7cc2ba0":123 1770 | // } 1771 | // 1772 | // 8c212779b4abde7c6bc608063a0d008b7e40ce32 is the hash of the torrent and 1773 | // 338944 its upload speed limit in bytes per second; 1774 | // this value will be zero if no limit is applied. 1775 | func (c *Client) GetTorrentUploadLimitCtx(ctx context.Context, hashes []string) (map[string]int64, error) { 1776 | opts := map[string]string{ 1777 | "hashes": strings.Join(hashes, "|"), 1778 | } 1779 | 1780 | resp, err := c.postCtx(ctx, "torrents/uploadLimit", opts) 1781 | if err != nil { 1782 | return nil, errors.Wrap(err, "could not get upload speed limit; hashes: %v", hashes) 1783 | } 1784 | 1785 | defer func(Body io.ReadCloser) { 1786 | _ = Body.Close() 1787 | }(resp.Body) 1788 | 1789 | if resp.StatusCode != http.StatusOK { 1790 | return nil, errors.Wrap(ErrUnexpectedStatus, "could not get upload speed limit; hashes: %v | status code: %d", hashes, resp.StatusCode) 1791 | } 1792 | 1793 | ret := make(map[string]int64) 1794 | if err = json.NewDecoder(resp.Body).Decode(&ret); err != nil { 1795 | return nil, errors.Wrap(err, "could not decode response body") 1796 | } 1797 | 1798 | return ret, nil 1799 | } 1800 | 1801 | // GetTorrentDownloadLimit get download limit for torrents specified by hashes. 1802 | // 1803 | // example response: 1804 | // 1805 | // { 1806 | // "8c212779b4abde7c6bc608063a0d008b7e40ce32":338944, 1807 | // "284b83c9c7935002391129fd97f43db5d7cc2ba0":123 1808 | // } 1809 | // 1810 | // 8c212779b4abde7c6bc608063a0d008b7e40ce32 is the hash of the torrent and 1811 | // 338944 its download speed limit in bytes per second; 1812 | // this value will be zero if no limit is applied. 1813 | func (c *Client) GetTorrentDownloadLimit(hashes []string) (map[string]int64, error) { 1814 | return c.GetTorrentDownloadLimitCtx(context.Background(), hashes) 1815 | } 1816 | 1817 | // GetTorrentDownloadLimitCtx get download limit for torrents specified by hashes. 1818 | // 1819 | // example response: 1820 | // 1821 | // { 1822 | // "8c212779b4abde7c6bc608063a0d008b7e40ce32":338944, 1823 | // "284b83c9c7935002391129fd97f43db5d7cc2ba0":123 1824 | // } 1825 | // 1826 | // 8c212779b4abde7c6bc608063a0d008b7e40ce32 is the hash of the torrent and 1827 | // 338944 its download speed limit in bytes per second; 1828 | // this value will be zero if no limit is applied. 1829 | func (c *Client) GetTorrentDownloadLimitCtx(ctx context.Context, hashes []string) (map[string]int64, error) { 1830 | opts := map[string]string{ 1831 | "hashes": strings.Join(hashes, "|"), 1832 | } 1833 | 1834 | resp, err := c.postCtx(ctx, "torrents/downloadLimit", opts) 1835 | if err != nil { 1836 | return nil, errors.Wrap(err, "could not get download limit; hashes: %v", hashes) 1837 | } 1838 | 1839 | defer func(Body io.ReadCloser) { 1840 | _ = Body.Close() 1841 | }(resp.Body) 1842 | 1843 | if resp.StatusCode != http.StatusOK { 1844 | return nil, errors.Wrap(ErrUnexpectedStatus, "could not get download limit; hashes: %v | status code: %d", hashes, resp.StatusCode) 1845 | } 1846 | 1847 | ret := make(map[string]int64) 1848 | if err = json.NewDecoder(resp.Body).Decode(&ret); err != nil { 1849 | return nil, errors.Wrap(err, "could not decode response body") 1850 | } 1851 | 1852 | return ret, nil 1853 | } 1854 | 1855 | // SetTorrentDownloadLimit set download limit for torrents specified by hashes 1856 | func (c *Client) SetTorrentDownloadLimit(hashes []string, limit int64) error { 1857 | return c.SetTorrentDownloadLimitCtx(context.Background(), hashes, limit) 1858 | } 1859 | 1860 | // SetTorrentDownloadLimitCtx set download limit for torrents specified by hashes 1861 | func (c *Client) SetTorrentDownloadLimitCtx(ctx context.Context, hashes []string, limit int64) error { 1862 | opts := map[string]string{ 1863 | "hashes": strings.Join(hashes, "|"), 1864 | "limit": strconv.FormatInt(limit, 10), 1865 | } 1866 | 1867 | resp, err := c.postCtx(ctx, "torrents/setDownloadLimit", opts) 1868 | if err != nil { 1869 | return errors.Wrap(err, "could not set download limit; hashes: %v", hashes) 1870 | } 1871 | 1872 | defer resp.Body.Close() 1873 | 1874 | if resp.StatusCode != http.StatusOK { 1875 | return errors.Wrap(ErrUnexpectedStatus, "could not set download limit; hashes: %v | status code: %d", hashes, resp.StatusCode) 1876 | } 1877 | 1878 | return nil 1879 | } 1880 | 1881 | // ToggleTorrentSequentialDownload toggles sequential download mode for torrents specified by hashes. 1882 | // 1883 | // hashes contains the hashes of the torrents to toggle sequential download mode for. 1884 | // or you can set to "all" to toggle sequential download mode for all torrents. 1885 | func (c *Client) ToggleTorrentSequentialDownload(hashes []string) error { 1886 | return c.ToggleTorrentSequentialDownloadCtx(context.Background(), hashes) 1887 | } 1888 | 1889 | // ToggleTorrentSequentialDownloadCtx toggles sequential download mode for torrents specified by hashes. 1890 | // 1891 | // hashes contains the hashes of the torrents to toggle sequential download mode for. 1892 | // or you can set to "all" to toggle sequential download mode for all torrents. 1893 | func (c *Client) ToggleTorrentSequentialDownloadCtx(ctx context.Context, hashes []string) error { 1894 | opts := map[string]string{ 1895 | "hashes": strings.Join(hashes, "|"), 1896 | } 1897 | 1898 | resp, err := c.postCtx(ctx, "torrents/toggleSequentialDownload", opts) 1899 | if err != nil { 1900 | return errors.Wrap(err, "could not toggle sequential download mode; hashes: %v", hashes) 1901 | } 1902 | 1903 | defer func(Body io.ReadCloser) { 1904 | _ = Body.Close() 1905 | }(resp.Body) 1906 | 1907 | if resp.StatusCode != http.StatusOK { 1908 | return errors.Wrap(ErrUnexpectedStatus, "could not toggle sequential download mode; hashes: %v | status code: %d", hashes, resp.StatusCode) 1909 | } 1910 | 1911 | return nil 1912 | } 1913 | 1914 | // SetTorrentSuperSeeding set super speeding mode for torrents specified by hashes. 1915 | // 1916 | // hashes contains the hashes of the torrents to set super seeding mode for. 1917 | // or you can set to "all" to set super seeding mode for all torrents. 1918 | func (c *Client) SetTorrentSuperSeeding(hashes []string, on bool) error { 1919 | return c.SetTorrentSuperSeedingCtx(context.Background(), hashes, on) 1920 | } 1921 | 1922 | // SetTorrentSuperSeedingCtx set super seeding mode for torrents specified by hashes. 1923 | // 1924 | // hashes contains the hashes of the torrents to set super seeding mode for. 1925 | // or you can set to "all" to set super seeding mode for all torrents. 1926 | func (c *Client) SetTorrentSuperSeedingCtx(ctx context.Context, hashes []string, on bool) error { 1927 | value := "false" 1928 | if on { 1929 | value = "true" 1930 | } 1931 | opts := map[string]string{ 1932 | "hashes": strings.Join(hashes, "|"), 1933 | "value": value, 1934 | } 1935 | 1936 | resp, err := c.postCtx(ctx, "torrents/setSuperSeeding", opts) 1937 | if err != nil { 1938 | return errors.Wrap(err, "could not set super seeding mode; hashes: %v", hashes) 1939 | } 1940 | 1941 | defer func(Body io.ReadCloser) { 1942 | _ = Body.Close() 1943 | }(resp.Body) 1944 | 1945 | if resp.StatusCode != http.StatusOK { 1946 | return errors.Wrap(ErrUnexpectedStatus, "could not set super seeding mode; hashes: %v | status code: %d", hashes, resp.StatusCode) 1947 | } 1948 | 1949 | return nil 1950 | } 1951 | 1952 | // SetTorrentShareLimit set share limits for torrents specified by hashes 1953 | func (c *Client) SetTorrentShareLimit(hashes []string, ratioLimit float64, seedingTimeLimit int64, inactiveSeedingTimeLimit int64) error { 1954 | return c.SetTorrentShareLimitCtx(context.Background(), hashes, ratioLimit, seedingTimeLimit, inactiveSeedingTimeLimit) 1955 | } 1956 | 1957 | // SetTorrentShareLimitCtx set share limits for torrents specified by hashes 1958 | func (c *Client) SetTorrentShareLimitCtx(ctx context.Context, hashes []string, ratioLimit float64, seedingTimeLimit int64, inactiveSeedingTimeLimit int64) error { 1959 | opts := map[string]string{ 1960 | "hashes": strings.Join(hashes, "|"), 1961 | "ratioLimit": strconv.FormatFloat(ratioLimit, 'f', 2, 64), 1962 | "seedingTimeLimit": strconv.FormatInt(seedingTimeLimit, 10), 1963 | "inactiveSeedingTimeLimit": strconv.FormatInt(inactiveSeedingTimeLimit, 10), 1964 | } 1965 | 1966 | resp, err := c.postCtx(ctx, "torrents/setShareLimits", opts) 1967 | if err != nil { 1968 | return errors.Wrap(err, "could not set share limits; hashes: %v | ratioLimit: %v | seedingTimeLimit: %v | inactiveSeedingTimeLimit %v", hashes, ratioLimit, seedingTimeLimit, inactiveSeedingTimeLimit) 1969 | } 1970 | 1971 | defer resp.Body.Close() 1972 | 1973 | /* 1974 | HTTP Status Code Scenario 1975 | 400 Share limit or at least one id is invalid 1976 | 200 All other scenarios 1977 | */ 1978 | switch sc := resp.StatusCode; sc { 1979 | case http.StatusOK: 1980 | return nil 1981 | case http.StatusBadRequest: 1982 | return ErrInvalidShareLimit 1983 | default: 1984 | errors.Wrap(ErrUnexpectedStatus, "could not set share limits; hashes: %v | ratioLimit: %v | seedingTimeLimit: %v | inactiveSeedingTimeLimit %v | status code: %d", hashes, ratioLimit, seedingTimeLimit, inactiveSeedingTimeLimit, resp.StatusCode) 1985 | } 1986 | 1987 | return nil 1988 | } 1989 | 1990 | // SetTorrentUploadLimit set upload limit for torrent specified by hashes 1991 | func (c *Client) SetTorrentUploadLimit(hashes []string, limit int64) error { 1992 | return c.SetTorrentUploadLimitCtx(context.Background(), hashes, limit) 1993 | } 1994 | 1995 | // SetTorrentUploadLimitCtx set upload limit for torrent specified by hashes 1996 | func (c *Client) SetTorrentUploadLimitCtx(ctx context.Context, hashes []string, limit int64) error { 1997 | opts := map[string]string{ 1998 | "hashes": strings.Join(hashes, "|"), 1999 | "limit": strconv.FormatInt(limit, 10), 2000 | } 2001 | 2002 | resp, err := c.postCtx(ctx, "torrents/setUploadLimit", opts) 2003 | if err != nil { 2004 | return errors.Wrap(err, "could not set upload limit; hashes: %v", hashes) 2005 | } 2006 | 2007 | defer resp.Body.Close() 2008 | 2009 | if resp.StatusCode != http.StatusOK { 2010 | return errors.Wrap(ErrUnexpectedStatus, "could not set upload limit; hahses: %v | status code: %d", hashes, resp.StatusCode) 2011 | } 2012 | 2013 | return nil 2014 | } 2015 | 2016 | func (c *Client) GetAppVersion() (string, error) { 2017 | return c.GetAppVersionCtx(context.Background()) 2018 | } 2019 | 2020 | func (c *Client) GetAppVersionCtx(ctx context.Context) (string, error) { 2021 | resp, err := c.getCtx(ctx, "app/version", nil) 2022 | if err != nil { 2023 | return "", errors.Wrap(err, "could not get app version") 2024 | } 2025 | 2026 | defer resp.Body.Close() 2027 | 2028 | body, err := io.ReadAll(resp.Body) 2029 | if err != nil { 2030 | return "", errors.Wrap(err, "could not read body") 2031 | } 2032 | 2033 | return string(body), nil 2034 | } 2035 | 2036 | // GetAppCookies get app cookies. 2037 | // Cookies are used for downloading torrents. 2038 | func (c *Client) GetAppCookies() ([]Cookie, error) { 2039 | return c.GetAppCookiesCtx(context.Background()) 2040 | } 2041 | 2042 | // GetAppCookiesCtx get app cookies. 2043 | // Cookies are used for downloading torrents. 2044 | func (c *Client) GetAppCookiesCtx(ctx context.Context) ([]Cookie, error) { 2045 | resp, err := c.getCtx(ctx, "app/cookies", nil) 2046 | if err != nil { 2047 | return nil, errors.Wrap(err, "could not get app cookies") 2048 | } 2049 | 2050 | defer func(Body io.ReadCloser) { 2051 | _ = Body.Close() 2052 | }(resp.Body) 2053 | 2054 | if resp.StatusCode != http.StatusOK { 2055 | return nil, errors.Wrap(ErrUnexpectedStatus, "could not get app cookies; status code: %d", resp.StatusCode) 2056 | } 2057 | 2058 | var cookies []Cookie 2059 | if err = json.NewDecoder(resp.Body).Decode(&cookies); err != nil { 2060 | return nil, errors.Wrap(err, "could not decode response body") 2061 | } 2062 | 2063 | return cookies, nil 2064 | } 2065 | 2066 | // SetAppCookies get app cookies. 2067 | // Cookies are used for downloading torrents. 2068 | func (c *Client) SetAppCookies(cookies []Cookie) error { 2069 | return c.SetAppCookiesCtx(context.Background(), cookies) 2070 | } 2071 | 2072 | // SetAppCookiesCtx get app cookies. 2073 | // Cookies are used for downloading torrents. 2074 | func (c *Client) SetAppCookiesCtx(ctx context.Context, cookies []Cookie) error { 2075 | marshaled, err := json.Marshal(cookies) 2076 | if err != nil { 2077 | return errors.Wrap(err, "could not marshal cookies") 2078 | } 2079 | 2080 | opts := map[string]string{ 2081 | "cookies": string(marshaled), 2082 | } 2083 | resp, err := c.postCtx(ctx, "app/setCookies", opts) 2084 | if err != nil { 2085 | return errors.Wrap(err, "could not set app cookies") 2086 | } 2087 | 2088 | defer func(Body io.ReadCloser) { 2089 | _ = Body.Close() 2090 | }(resp.Body) 2091 | 2092 | switch resp.StatusCode { 2093 | case http.StatusBadRequest: 2094 | data, _ := io.ReadAll(resp.Body) 2095 | _ = data 2096 | return ErrInvalidCookies 2097 | case http.StatusOK: 2098 | return nil 2099 | default: 2100 | return errors.Wrap(ErrUnexpectedStatus, "could not set app cookies; status code: %d", resp.StatusCode) 2101 | } 2102 | } 2103 | 2104 | // GetTorrentPieceStates returns an array of states (integers) of all pieces (in order) of a specific torrent. 2105 | func (c *Client) GetTorrentPieceStates(hash string) ([]PieceState, error) { 2106 | return c.GetTorrentPieceStatesCtx(context.Background(), hash) 2107 | } 2108 | 2109 | // GetTorrentPieceStatesCtx returns an array of states (integers) of all pieces (in order) of a specific torrent. 2110 | func (c *Client) GetTorrentPieceStatesCtx(ctx context.Context, hash string) ([]PieceState, error) { 2111 | opts := map[string]string{ 2112 | "hash": hash, 2113 | } 2114 | resp, err := c.getCtx(ctx, "torrents/pieceStates", opts) 2115 | if err != nil { 2116 | return nil, errors.Wrap(err, "could not get torrent piece states") 2117 | } 2118 | 2119 | defer func(Body io.ReadCloser) { 2120 | _ = Body.Close() 2121 | }(resp.Body) 2122 | 2123 | if resp.StatusCode != http.StatusOK { 2124 | return nil, errors.Wrap(ErrCannotGetTorrentPieceStates, "torrent hash %v, unexpected status: %v", hash, resp.StatusCode) 2125 | } 2126 | 2127 | var result []PieceState 2128 | if err = json.NewDecoder(resp.Body).Decode(&result); err != nil { 2129 | return nil, errors.Wrap(err, "could not decode response body") 2130 | } 2131 | 2132 | return result, nil 2133 | } 2134 | 2135 | // GetTorrentPieceHashes returns an array of hashes (in order) of all pieces (in order) of a specific torrent. 2136 | func (c *Client) GetTorrentPieceHashes(hash string) ([]string, error) { 2137 | return c.GetTorrentPieceHashesCtx(context.Background(), hash) 2138 | } 2139 | 2140 | // GetTorrentPieceHashesCtx returns an array of hashes (in order) of all pieces (in order) of a specific torrent. 2141 | func (c *Client) GetTorrentPieceHashesCtx(ctx context.Context, hash string) ([]string, error) { 2142 | opts := map[string]string{ 2143 | "hash": hash, 2144 | } 2145 | resp, err := c.getCtx(ctx, "torrents/pieceHashes", opts) 2146 | if err != nil { 2147 | return nil, errors.Wrap(err, "could not get torrent piece hashes: hashes %v", hash) 2148 | } 2149 | 2150 | defer func(Body io.ReadCloser) { 2151 | _ = Body.Close() 2152 | }(resp.Body) 2153 | 2154 | switch resp.StatusCode { 2155 | case http.StatusNotFound: 2156 | return nil, errors.Wrap(ErrTorrentNotFound, "torrent hash %v", hash) 2157 | case http.StatusOK: 2158 | break 2159 | default: 2160 | return nil, errors.Wrap(ErrUnexpectedStatus, "could not get torrent piece hashes; hash: %v, status code: %d", hash, resp.StatusCode) 2161 | } 2162 | 2163 | var result []string 2164 | if err = json.NewDecoder(resp.Body).Decode(&result); err != nil { 2165 | return nil, errors.Wrap(err, "could not decode response body") 2166 | } 2167 | 2168 | return result, nil 2169 | } 2170 | 2171 | // AddPeersForTorrents adds peers to torrents. 2172 | // hashes is a list of torrent hashes. 2173 | // peers is a list of peers. Each of peers list is a string in the form of `:`. 2174 | func (c *Client) AddPeersForTorrents(hashes, peers []string) error { 2175 | return c.AddPeersForTorrentsCtx(context.Background(), hashes, peers) 2176 | } 2177 | 2178 | // AddPeersForTorrentsCtx adds peers to torrents. 2179 | // hashes is a list of torrent hashes. 2180 | // peers is a list of peers. Each of peers list is a string in the form of `:`. 2181 | func (c *Client) AddPeersForTorrentsCtx(ctx context.Context, hashes, peers []string) error { 2182 | opts := map[string]string{ 2183 | "hashes": strings.Join(hashes, "|"), 2184 | "peers": strings.Join(peers, "|"), 2185 | } 2186 | resp, err := c.postCtx(ctx, "torrents/addPeers", opts) 2187 | if err != nil { 2188 | return errors.Wrap(err, "could not add peers; hashes: %v | peers: %v", hashes, peers) 2189 | } 2190 | 2191 | defer func(Body io.ReadCloser) { 2192 | _ = Body.Close() 2193 | }(resp.Body) 2194 | 2195 | switch resp.StatusCode { 2196 | case http.StatusBadRequest: 2197 | return errors.Wrap(ErrInvalidPeers, "hashes: %v, peers: %v", hashes, peers) 2198 | case http.StatusOK: 2199 | return nil 2200 | default: 2201 | return errors.Wrap(ErrUnexpectedStatus, "could not add peers; hashes %v | peers: %v | status code: %d", hashes, peers, resp.StatusCode) 2202 | } 2203 | } 2204 | 2205 | func (c *Client) GetWebAPIVersion() (string, error) { 2206 | return c.GetWebAPIVersionCtx(context.Background()) 2207 | } 2208 | 2209 | func (c *Client) GetWebAPIVersionCtx(ctx context.Context) (string, error) { 2210 | resp, err := c.getCtx(ctx, "app/webapiVersion", nil) 2211 | if err != nil { 2212 | return "", errors.Wrap(err, "could not get webapi version") 2213 | } 2214 | 2215 | defer resp.Body.Close() 2216 | 2217 | body, err := io.ReadAll(resp.Body) 2218 | if err != nil { 2219 | return "", errors.Wrap(err, "could not read body") 2220 | } 2221 | 2222 | return string(body), nil 2223 | } 2224 | 2225 | // GetLogs get main client logs 2226 | func (c *Client) GetLogs() ([]Log, error) { 2227 | return c.GetLogsCtx(context.Background()) 2228 | } 2229 | 2230 | // GetLogsCtx get main client logs 2231 | func (c *Client) GetLogsCtx(ctx context.Context) ([]Log, error) { 2232 | resp, err := c.getCtx(ctx, "log/main", nil) 2233 | if err != nil { 2234 | return nil, errors.Wrap(err, "could not get main client logs") 2235 | } 2236 | 2237 | defer resp.Body.Close() 2238 | 2239 | body, err := io.ReadAll(resp.Body) 2240 | if err != nil { 2241 | return nil, errors.Wrap(err, "could not read body") 2242 | } 2243 | 2244 | m := make([]Log, 0) 2245 | if err := json.Unmarshal(body, &m); err != nil { 2246 | return nil, errors.Wrap(err, "could not unmarshal body") 2247 | } 2248 | 2249 | return m, nil 2250 | } 2251 | 2252 | // GetPeerLogs get peer logs 2253 | func (c *Client) GetPeerLogs() ([]PeerLog, error) { 2254 | return c.GetPeerLogsCtx(context.Background()) 2255 | } 2256 | 2257 | // GetPeerLogsCtx get peer logs 2258 | func (c *Client) GetPeerLogsCtx(ctx context.Context) ([]PeerLog, error) { 2259 | resp, err := c.getCtx(ctx, "log/main", nil) 2260 | if err != nil { 2261 | return nil, errors.Wrap(err, "could not get peer logs") 2262 | } 2263 | 2264 | defer resp.Body.Close() 2265 | 2266 | body, err := io.ReadAll(resp.Body) 2267 | if err != nil { 2268 | return nil, errors.Wrap(err, "could not read body") 2269 | } 2270 | 2271 | m := make([]PeerLog, 0) 2272 | if err := json.Unmarshal(body, &m); err != nil { 2273 | return nil, errors.Wrap(err, "could not unmarshal body") 2274 | } 2275 | 2276 | return m, nil 2277 | } 2278 | 2279 | func (c *Client) GetFreeSpaceOnDisk() (int64, error) { 2280 | return c.GetFreeSpaceOnDiskCtx(context.Background()) 2281 | } 2282 | 2283 | // GetFreeSpaceOnDiskCtx get free space on disk for default download dir. Expensive call 2284 | func (c *Client) GetFreeSpaceOnDiskCtx(ctx context.Context) (int64, error) { 2285 | info, err := c.SyncMainDataCtx(ctx, 0) 2286 | if err != nil { 2287 | return 0, errors.Wrap(err, "could not get maindata") 2288 | } 2289 | 2290 | return info.ServerState.FreeSpaceOnDisk, nil 2291 | } 2292 | 2293 | // RequiresMinVersion checks the current version against version X and errors if the current version is older than X 2294 | func (c *Client) RequiresMinVersion(minVersion *semver.Version) (bool, error) { 2295 | version, err := c.getApiVersion() 2296 | if err != nil { 2297 | return false, errors.Wrap(err, "could not get api version") 2298 | } 2299 | 2300 | if version.LessThan(minVersion) { 2301 | return false, errors.Wrap(ErrUnsupportedVersion, "qBittorrent WebAPI version %s is older than required %s", version.String(), minVersion.String()) 2302 | } 2303 | 2304 | return true, nil 2305 | } 2306 | 2307 | const ( 2308 | ReannounceMaxAttempts = 50 2309 | ReannounceInterval = 7 // interval in seconds 2310 | ) 2311 | 2312 | type ReannounceOptions struct { 2313 | Interval int 2314 | MaxAttempts int 2315 | DeleteOnFailure bool 2316 | } 2317 | 2318 | func (c *Client) ReannounceTorrentWithRetry(ctx context.Context, hash string, opts *ReannounceOptions) error { 2319 | interval := ReannounceInterval 2320 | maxAttempts := ReannounceMaxAttempts 2321 | deleteOnFailure := false 2322 | 2323 | if opts != nil { 2324 | if opts.Interval > 0 { 2325 | interval = opts.Interval 2326 | } 2327 | 2328 | if opts.MaxAttempts > 0 { 2329 | maxAttempts = opts.MaxAttempts 2330 | } 2331 | 2332 | if opts.DeleteOnFailure { 2333 | deleteOnFailure = opts.DeleteOnFailure 2334 | } 2335 | } 2336 | 2337 | attempts := 0 2338 | 2339 | for attempts < maxAttempts { 2340 | c.log.Printf("re-announce %s attempt: %d", hash, attempts) 2341 | 2342 | // add delay for next run 2343 | time.Sleep(time.Duration(interval) * time.Second) 2344 | 2345 | trackers, err := c.GetTorrentTrackersCtx(ctx, hash) 2346 | if err != nil { 2347 | return errors.Wrap(err, "could not get trackers for torrent with hash: %s", hash) 2348 | } 2349 | 2350 | if trackers == nil { 2351 | attempts++ 2352 | continue 2353 | } 2354 | 2355 | c.log.Printf("re-announce %s attempt: %d trackers (%+v)", hash, attempts, trackers) 2356 | 2357 | // check if status not working or something else 2358 | if isTrackerStatusOK(trackers) { 2359 | c.log.Printf("re-announce for %v OK", hash) 2360 | 2361 | // if working lets return 2362 | return nil 2363 | } 2364 | 2365 | c.log.Printf("not working yet, lets re-announce %s attempt: %d", hash, attempts) 2366 | 2367 | if err = c.ReAnnounceTorrentsCtx(ctx, []string{hash}); err != nil { 2368 | return errors.Wrap(err, "could not re-announce torrent with hash: %s", hash) 2369 | } 2370 | 2371 | attempts++ 2372 | } 2373 | 2374 | // delete on failure to reannounce 2375 | if deleteOnFailure { 2376 | c.log.Printf("re-announce for %s took too long, deleting torrent", hash) 2377 | 2378 | if err := c.DeleteTorrentsCtx(ctx, []string{hash}, false); err != nil { 2379 | return errors.Wrap(err, "could not delete torrent with hash: %s", hash) 2380 | } 2381 | 2382 | return ErrReannounceTookTooLong 2383 | } 2384 | 2385 | return nil 2386 | } 2387 | 2388 | func (c *Client) GetTorrentsWebSeeds(hash string) ([]WebSeed, error) { 2389 | return c.GetTorrentsWebSeedsCtx(context.Background(), hash) 2390 | } 2391 | 2392 | func (c *Client) GetTorrentsWebSeedsCtx(ctx context.Context, hash string) ([]WebSeed, error) { 2393 | opts := map[string]string{ 2394 | "hash": hash, 2395 | } 2396 | resp, err := c.getCtx(ctx, "torrents/webseeds", opts) 2397 | if err != nil { 2398 | return nil, errors.Wrap(err, "could not get webseeds for torrent; hash: %s", hash) 2399 | } 2400 | defer func(Body io.ReadCloser) { 2401 | _ = Body.Close() 2402 | }(resp.Body) 2403 | 2404 | switch resp.StatusCode { 2405 | case http.StatusNotFound: 2406 | return nil, errors.Wrap(ErrTorrentNotFound, "hash: %s", hash) 2407 | case http.StatusOK: 2408 | break 2409 | default: 2410 | return nil, errors.Wrap(ErrUnexpectedStatus, "could not get webseeds for torrent; hash: %v, status code: %d", hash, resp.StatusCode) 2411 | } 2412 | 2413 | var m []WebSeed 2414 | if err = json.NewDecoder(resp.Body).Decode(&m); err != nil { 2415 | return nil, errors.Wrap(err, "could not decode response") 2416 | } 2417 | 2418 | return m, nil 2419 | } 2420 | 2421 | // Check if status not working or something else 2422 | // https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-trackers 2423 | // 2424 | // 0 Tracker is disabled (used for DHT, PeX, and LSD) 2425 | // 1 Tracker has not been contacted yet 2426 | // 2 Tracker has been contacted and is working 2427 | // 3 Tracker is updating 2428 | // 4 Tracker has been contacted, but it is not working (or doesn't send proper replies) 2429 | func isTrackerStatusOK(trackers []TorrentTracker) bool { 2430 | for _, tracker := range trackers { 2431 | if tracker.Status == TrackerStatusDisabled { 2432 | continue 2433 | } 2434 | 2435 | // check for certain messages before the tracker status to catch ok status with unreg msg 2436 | if isUnregistered(tracker.Message) { 2437 | return false 2438 | } 2439 | 2440 | if tracker.Status == TrackerStatusOK { 2441 | return true 2442 | } 2443 | } 2444 | 2445 | return false 2446 | } 2447 | 2448 | func isUnregistered(msg string) bool { 2449 | words := []string{"unregistered", "not registered", "not found", "not exist"} 2450 | 2451 | msg = strings.ToLower(msg) 2452 | 2453 | for _, v := range words { 2454 | if strings.Contains(msg, v) { 2455 | return true 2456 | } 2457 | } 2458 | 2459 | return false 2460 | } 2461 | -------------------------------------------------------------------------------- /methods_test.go: -------------------------------------------------------------------------------- 1 | //go:build !ci 2 | // +build !ci 3 | 4 | package qbittorrent_test 5 | 6 | import ( 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | 13 | "github.com/autobrr/go-qbittorrent" 14 | ) 15 | 16 | const ( 17 | // a sample torrent that only contains one folder "untitled" and one file "untitled.txt". 18 | sampleTorrent = "d10:created by18:qBittorrent v5.1.013:creation datei1747004328e4:infod5:filesld6:lengthi21e4:pathl12:untitled.txteee4:name8:untitled12:piece lengthi16384e6:pieces20:\xb5|\x901\xce\xa3\xdb @$\xce\xbd\xd3\xb0\x0e\xd3\xba\xc0\xcc\xbd7:privatei1eee" 19 | sampleInfoHash = "ead9241e611e9712f28b20b151f1a3ecd4a6178a" 20 | ) 21 | 22 | var ( 23 | qBittorrentBaseURL string 24 | qBittorrentUsername string 25 | qBittorrentPassword string 26 | ) 27 | 28 | func init() { 29 | qBittorrentBaseURL = "http://127.0.0.1:8080/" 30 | if val := os.Getenv("QBIT_BASE_URL"); val != "" { 31 | qBittorrentBaseURL = val 32 | } 33 | qBittorrentUsername = "admin" 34 | if val := os.Getenv("QBIT_USERNAME"); val != "" { 35 | qBittorrentUsername = val 36 | } 37 | qBittorrentPassword = "password" // must be at least 6 characters 38 | if val := os.Getenv("QBIT_PASSWORD"); val != "" { 39 | qBittorrentPassword = val 40 | } 41 | } 42 | 43 | func TestClient_GetDefaultSavePath(t *testing.T) { 44 | client := qbittorrent.NewClient(qbittorrent.Config{ 45 | Host: qBittorrentBaseURL, 46 | Username: qBittorrentUsername, 47 | Password: qBittorrentPassword, 48 | }) 49 | 50 | _, err := client.GetDefaultSavePath() 51 | assert.NoError(t, err) 52 | } 53 | 54 | func TestClient_GetAppCookies(t *testing.T) { 55 | client := qbittorrent.NewClient(qbittorrent.Config{ 56 | Host: qBittorrentBaseURL, 57 | Username: qBittorrentUsername, 58 | Password: qBittorrentPassword, 59 | }) 60 | 61 | _, err := client.GetAppCookies() 62 | assert.NoError(t, err) 63 | } 64 | 65 | func TestClient_SetAppCookies(t *testing.T) { 66 | client := qbittorrent.NewClient(qbittorrent.Config{ 67 | Host: qBittorrentBaseURL, 68 | Username: qBittorrentUsername, 69 | Password: qBittorrentPassword, 70 | }) 71 | 72 | var err error 73 | var cookies = []qbittorrent.Cookie{ 74 | { 75 | Name: "test", 76 | Domain: "example.com", 77 | Path: "/", 78 | Value: "test", 79 | ExpirationDate: time.Now().Add(time.Hour).Unix(), 80 | }, 81 | } 82 | err = client.SetAppCookies(cookies) 83 | assert.NoError(t, err) 84 | 85 | resp, err := client.GetAppCookies() 86 | assert.NoError(t, err) 87 | assert.NotEmpty(t, cookies) 88 | assert.Equal(t, cookies, resp) 89 | } 90 | 91 | func TestClient_BanPeers(t *testing.T) { 92 | client := qbittorrent.NewClient(qbittorrent.Config{ 93 | Host: qBittorrentBaseURL, 94 | Username: qBittorrentUsername, 95 | Password: qBittorrentPassword, 96 | }) 97 | 98 | err := client.BanPeers([]string{"127.0.0.1:80"}) 99 | assert.NoError(t, err) 100 | } 101 | 102 | func TestClient_GetBuildInfo(t *testing.T) { 103 | client := qbittorrent.NewClient(qbittorrent.Config{ 104 | Host: qBittorrentBaseURL, 105 | Username: qBittorrentUsername, 106 | Password: qBittorrentPassword, 107 | }) 108 | 109 | bi, err := client.GetBuildInfo() 110 | assert.NoError(t, err) 111 | assert.NotEmpty(t, bi.Qt) 112 | assert.NotEmpty(t, bi.Libtorrent) 113 | assert.NotEmpty(t, bi.Boost) 114 | assert.NotEmpty(t, bi.Openssl) 115 | assert.NotEmpty(t, bi.Bitness) 116 | } 117 | 118 | func TestClient_GetTorrentDownloadLimit(t *testing.T) { 119 | client := qbittorrent.NewClient(qbittorrent.Config{ 120 | Host: qBittorrentBaseURL, 121 | Username: qBittorrentUsername, 122 | Password: qBittorrentPassword, 123 | }) 124 | 125 | data, err := client.GetTorrents(qbittorrent.TorrentFilterOptions{}) 126 | assert.NoError(t, err) 127 | var hashes []string 128 | for _, torrent := range data { 129 | hashes = append(hashes, torrent.Hash) 130 | } 131 | 132 | limits, err := client.GetTorrentDownloadLimit(hashes) 133 | assert.NoError(t, err) 134 | assert.Equal(t, len(hashes), len(limits)) 135 | 136 | // FIXME: The following assertion will fail. 137 | // Neither "hashes=all" nor "all" is working. 138 | // I have no idea. Maybe the document is lying? 139 | // 140 | // limits, err = client.GetTorrentDownloadLimit([]string{"all"}) 141 | // assert.NoError(t, err) 142 | // assert.Equal(t, len(hashes), len(limits)) 143 | } 144 | 145 | func TestClient_GetTorrentUploadLimit(t *testing.T) { 146 | client := qbittorrent.NewClient(qbittorrent.Config{ 147 | Host: qBittorrentBaseURL, 148 | Username: qBittorrentUsername, 149 | Password: qBittorrentPassword, 150 | }) 151 | 152 | data, err := client.GetTorrents(qbittorrent.TorrentFilterOptions{}) 153 | assert.NoError(t, err) 154 | var hashes []string 155 | for _, torrent := range data { 156 | hashes = append(hashes, torrent.Hash) 157 | } 158 | 159 | limits, err := client.GetTorrentUploadLimit(hashes) 160 | assert.NoError(t, err) 161 | assert.Equal(t, len(hashes), len(limits)) 162 | 163 | // FIXME: The following assertion will fail. 164 | // Neither "hashes=all" nor "all" is working. 165 | // I have no idea. Maybe the document is lying? 166 | // Just as same as Client.GetTorrentDownloadLimit. 167 | // 168 | // limits, err = client.GetTorrentDownloadLimit([]string{"all"}) 169 | // assert.NoError(t, err) 170 | // assert.Equal(t, len(hashes), len(limits)) 171 | } 172 | 173 | func TestClient_ToggleTorrentSequentialDownload(t *testing.T) { 174 | client := qbittorrent.NewClient(qbittorrent.Config{ 175 | Host: qBittorrentBaseURL, 176 | Username: qBittorrentUsername, 177 | Password: qBittorrentPassword, 178 | }) 179 | 180 | var err error 181 | 182 | data, err := client.GetTorrents(qbittorrent.TorrentFilterOptions{}) 183 | assert.NoError(t, err) 184 | var hashes []string 185 | for _, torrent := range data { 186 | hashes = append(hashes, torrent.Hash) 187 | } 188 | 189 | err = client.ToggleTorrentSequentialDownload(hashes) 190 | assert.NoError(t, err) 191 | 192 | // No idea why this is working but downloadLimit/uploadLimit are not. 193 | err = client.ToggleTorrentSequentialDownload([]string{"all"}) 194 | assert.NoError(t, err) 195 | } 196 | 197 | func TestClient_SetTorrentSuperSeeding(t *testing.T) { 198 | client := qbittorrent.NewClient(qbittorrent.Config{ 199 | Host: qBittorrentBaseURL, 200 | Username: qBittorrentUsername, 201 | Password: qBittorrentPassword, 202 | }) 203 | 204 | var err error 205 | 206 | data, err := client.GetTorrents(qbittorrent.TorrentFilterOptions{}) 207 | assert.NoError(t, err) 208 | var hashes []string 209 | for _, torrent := range data { 210 | hashes = append(hashes, torrent.Hash) 211 | } 212 | 213 | err = client.SetTorrentSuperSeeding(hashes, true) 214 | assert.NoError(t, err) 215 | 216 | // FIXME: following test not fail but has no effect. 217 | // qBittorrent doesn't return any error but super seeding status is not changed. 218 | // I tried specify hashes as "all" but it's not working too. 219 | err = client.SetTorrentSuperSeeding([]string{"all"}, false) 220 | assert.NoError(t, err) 221 | } 222 | 223 | func TestClient_GetTorrentPieceStates(t *testing.T) { 224 | client := qbittorrent.NewClient(qbittorrent.Config{ 225 | Host: qBittorrentBaseURL, 226 | Username: qBittorrentUsername, 227 | Password: qBittorrentPassword, 228 | }) 229 | 230 | data, err := client.GetTorrents(qbittorrent.TorrentFilterOptions{}) 231 | assert.NoError(t, err) 232 | assert.NotEmpty(t, data) 233 | 234 | hash := data[0].Hash 235 | states, err := client.GetTorrentPieceStates(hash) 236 | assert.NoError(t, err) 237 | assert.NotEmpty(t, states) 238 | } 239 | 240 | func TestClient_GetTorrentPieceHashes(t *testing.T) { 241 | client := qbittorrent.NewClient(qbittorrent.Config{ 242 | Host: qBittorrentBaseURL, 243 | Username: qBittorrentUsername, 244 | Password: qBittorrentPassword, 245 | }) 246 | 247 | data, err := client.GetTorrents(qbittorrent.TorrentFilterOptions{}) 248 | assert.NoError(t, err) 249 | assert.NotEmpty(t, data) 250 | 251 | hash := data[0].Hash 252 | states, err := client.GetTorrentPieceHashes(hash) 253 | assert.NoError(t, err) 254 | assert.NotEmpty(t, states) 255 | } 256 | 257 | func TestClient_AddPeersForTorrents(t *testing.T) { 258 | client := qbittorrent.NewClient(qbittorrent.Config{ 259 | Host: qBittorrentBaseURL, 260 | Username: qBittorrentUsername, 261 | Password: qBittorrentPassword, 262 | }) 263 | 264 | data, err := client.GetTorrents(qbittorrent.TorrentFilterOptions{}) 265 | assert.NoError(t, err) 266 | assert.NotEmpty(t, data) 267 | 268 | hashes := []string{data[0].Hash} 269 | peers := []string{"127.0.0.1:12345"} 270 | err = client.AddPeersForTorrents(hashes, peers) 271 | // It seems qBittorrent doesn't actually check whether given peers are available. 272 | assert.NoError(t, err) 273 | } 274 | 275 | func TestClient_RenameFile(t *testing.T) { 276 | client := qbittorrent.NewClient(qbittorrent.Config{ 277 | Host: qBittorrentBaseURL, 278 | Username: qBittorrentUsername, 279 | Password: qBittorrentPassword, 280 | }) 281 | 282 | err := client.AddTorrentFromMemory([]byte(sampleTorrent), nil) 283 | assert.NoError(t, err) 284 | defer func(client *qbittorrent.Client) { 285 | _ = client.DeleteTorrents([]string{sampleInfoHash}, false) 286 | }(client) 287 | 288 | err = client.RenameFile(sampleInfoHash, "untitled/untitled.txt", "untitled/renamed.txt") 289 | assert.NoError(t, err) 290 | } 291 | 292 | func TestClient_RenameFolder(t *testing.T) { 293 | client := qbittorrent.NewClient(qbittorrent.Config{ 294 | Host: qBittorrentBaseURL, 295 | Username: qBittorrentUsername, 296 | Password: qBittorrentPassword, 297 | }) 298 | 299 | err := client.AddTorrentFromMemory([]byte(sampleTorrent), nil) 300 | assert.NoError(t, err) 301 | defer func(client *qbittorrent.Client) { 302 | _ = client.DeleteTorrents([]string{sampleInfoHash}, false) 303 | }(client) 304 | 305 | err = client.RenameFolder(sampleInfoHash, "untitled", "renamed") 306 | assert.NoError(t, err) 307 | } 308 | 309 | func TestClient_GetTorrentsWebSeeds(t *testing.T) { 310 | client := qbittorrent.NewClient(qbittorrent.Config{ 311 | Host: qBittorrentBaseURL, 312 | Username: qBittorrentUsername, 313 | Password: qBittorrentPassword, 314 | }) 315 | 316 | data, err := client.GetTorrents(qbittorrent.TorrentFilterOptions{}) 317 | assert.NoError(t, err) 318 | assert.NotEmpty(t, data) 319 | 320 | hash := data[0].Hash 321 | _, err = client.GetTorrentsWebSeeds(hash) 322 | assert.NoError(t, err) 323 | } 324 | -------------------------------------------------------------------------------- /qbittorrent.go: -------------------------------------------------------------------------------- 1 | package qbittorrent 2 | 3 | import ( 4 | "crypto/tls" 5 | "io" 6 | "log" 7 | "net" 8 | "net/http" 9 | "net/http/cookiejar" 10 | "time" 11 | 12 | "github.com/Masterminds/semver" 13 | "golang.org/x/net/publicsuffix" 14 | ) 15 | 16 | var ( 17 | DefaultTimeout = 60 * time.Second 18 | ) 19 | 20 | type Client struct { 21 | cfg Config 22 | 23 | http *http.Client 24 | timeout time.Duration 25 | 26 | log *log.Logger 27 | 28 | version *semver.Version 29 | } 30 | 31 | type Config struct { 32 | Host string 33 | Username string 34 | Password string 35 | 36 | // TLS skip cert validation 37 | TLSSkipVerify bool 38 | 39 | // HTTP Basic auth username 40 | BasicUser string 41 | 42 | // HTTP Basic auth password 43 | BasicPass string 44 | 45 | Timeout int 46 | Log *log.Logger 47 | } 48 | 49 | func NewClient(cfg Config) *Client { 50 | c := &Client{ 51 | cfg: cfg, 52 | log: log.New(io.Discard, "", log.LstdFlags), 53 | timeout: DefaultTimeout, 54 | } 55 | 56 | // override logger if we pass one 57 | if cfg.Log != nil { 58 | c.log = cfg.Log 59 | } 60 | 61 | if cfg.Timeout > 0 { 62 | c.timeout = time.Duration(cfg.Timeout) * time.Second 63 | } 64 | 65 | //store cookies in jar 66 | jarOptions := &cookiejar.Options{PublicSuffixList: publicsuffix.List} 67 | jar, err := cookiejar.New(jarOptions) 68 | if err != nil { 69 | c.log.Println("new client cookie error") 70 | } 71 | 72 | customTransport := &http.Transport{ 73 | Proxy: http.ProxyFromEnvironment, 74 | DialContext: (&net.Dialer{ 75 | Timeout: 30 * time.Second, // default transport value 76 | KeepAlive: 30 * time.Second, // default transport value 77 | }).DialContext, 78 | ForceAttemptHTTP2: true, // default is true; since HTTP/2 multiplexes a single TCP connection. we'd want to use HTTP/1, which would use multiple TCP connections. 79 | MaxIdleConns: 100, // default transport value 80 | MaxIdleConnsPerHost: 10, // default is 2, so we want to increase the number to use establish more connections. 81 | IdleConnTimeout: 90 * time.Second, // default transport value 82 | TLSHandshakeTimeout: 10 * time.Second, // default transport value 83 | ExpectContinueTimeout: 1 * time.Second, // default transport value 84 | ReadBufferSize: 65536, 85 | WriteBufferSize: 65536, 86 | TLSClientConfig: &tls.Config{ 87 | InsecureSkipVerify: cfg.TLSSkipVerify, 88 | }, 89 | } 90 | 91 | c.http = &http.Client{ 92 | Jar: jar, 93 | Timeout: c.timeout, 94 | Transport: customTransport, 95 | } 96 | 97 | return c 98 | } 99 | 100 | // WithHTTPClient allows you to a provide a custom [http.Client]. 101 | func (c *Client) WithHTTPClient(client *http.Client) *Client { 102 | client.Jar = c.http.Jar 103 | c.http = client 104 | return c 105 | } 106 | --------------------------------------------------------------------------------