package qbittorrent import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http" "net/url" "strconv" "strings" "golang.org/x/exp/constraints" ) type Torrent interface { // GetTorrents get torrent list GetTorrents(ctx context.Context, opt *TorrentOption) ([]*TorrentInfo, error) // GetProperties get torrent generic properties GetProperties(ctx context.Context, hash string) (*TorrentProperties, error) // GetTrackers get torrent trackers GetTrackers(ctx context.Context, hash string) ([]*TorrentTracker, error) // GetWebSeeds get torrent web seeds GetWebSeeds(ctx context.Context, hash string) ([]*TorrentWebSeed, error) // GetContents get torrent contents, indexes(optional) of the files you want to retrieve GetContents(ctx context.Context, hash string, indexes ...int) ([]*TorrentContent, error) // GetPiecesStates get torrent pieces states GetPiecesStates(ctx context.Context, hash string) ([]int, error) // GetPiecesHashes get torrent pieces hashes GetPiecesHashes(ctx context.Context, hash string) ([]string, error) // PauseTorrents the hashes of the torrents you want to pause PauseTorrents(ctx context.Context, hashes []string) error // ResumeTorrents the hashes of the torrents you want to resume ResumeTorrents(ctx context.Context, hashes []string) error // DeleteTorrents the hashes of the torrents you want to delete, if set deleteFile to true, // the downloaded data will also be deleted, otherwise has no effect. DeleteTorrents(ctx context.Context, hashes []string, deleteFile bool) error // RecheckTorrents the hashes of the torrents you want to recheck RecheckTorrents(ctx context.Context, hashes []string) error // ReAnnounceTorrents the hashes of the torrents you want to reannounce ReAnnounceTorrents(ctx context.Context, hashes []string) error // AddNewTorrent add torrents from server local file or from URLs. http://, https://, // magnet: and bc://bt/ links are supported, but only one onetime AddNewTorrent(ctx context.Context, opt *TorrentAddOption) error // AddTrackers add trackers to torrent AddTrackers(ctx context.Context, hash string, urls []string) error // EditTrackers edit trackers EditTrackers(ctx context.Context, hash, origUrl, newUrl string) error // RemoveTrackers remove trackers RemoveTrackers(ctx context.Context, hash string, urls []string) error // AddPeers add peers for torrent, each peer is host:port AddPeers(ctx context.Context, hashes []string, peers []string) error // IncreasePriority increase torrent priority IncreasePriority(ctx context.Context, hashes []string) error // DecreasePriority decrease torrent priority DecreasePriority(ctx context.Context, hashes []string) error // MaxPriority maximal torrent priority MaxPriority(ctx context.Context, hashes []string) error // MinPriority minimal torrent priority MinPriority(ctx context.Context, hashes []string) error // SetFilePriority set file priority SetFilePriority(ctx context.Context, hash string, id int, priority Priority) error // GetDownloadLimit get torrent download limit GetDownloadLimit(ctx context.Context, hashes []string) (map[string]int, error) // SetDownloadLimit set torrent download limit, limit in bytes per second, if no limit please set value zero SetDownloadLimit(ctx context.Context, hashes []string, limit int) error // SetShareLimit set torrent share limit, ratioLimit: the maximum seeding ratio for the torrent, -2 means the // global limit should be used, -1 means no limit; seedingTimeLimit: the maximum seeding time (minutes) for the // torrent, -2 means the global limit should be used, -1 means no limit; inactiveSeedingTimeLimit: the maximum // amount of time (minutes) the torrent is allowed to seed while being inactive, -2 means the global limit should // be used, -1 means no limit. SetShareLimit(ctx context.Context, hashes []string, ratioLimit float64, seedingTimeLimit, inactiveSeedingTimeLimit int) error // GetUploadLimit get torrent upload limit GetUploadLimit(ctx context.Context, hashes []string) (map[string]int, error) // SetUploadLimit set torrent upload limit SetUploadLimit(ctx context.Context, hashes []string, limit int) error // SetLocation set torrent location SetLocation(ctx context.Context, hashes []string, location string) error // SetName set torrent name SetName(ctx context.Context, hash string, name string) error // SetCategory set torrent category SetCategory(ctx context.Context, hashes []string, category string) error // GetCategories get all categories GetCategories(ctx context.Context) (map[string]*TorrentCategory, error) // AddNewCategory add new category AddNewCategory(ctx context.Context, category, savePath string) error // EditCategory edit category EditCategory(ctx context.Context, category, savePath string) error // RemoveCategories remove categories RemoveCategories(ctx context.Context, categories []string) error // AddTags add torrent tags AddTags(ctx context.Context, hashes []string, tags []string) error // RemoveTags remove torrent tags RemoveTags(ctx context.Context, hashes []string, tags []string) error // GetTags get all tags GetTags(ctx context.Context) ([]string, error) // CreateTags create tags CreateTags(ctx context.Context, tags []string) error // DeleteTags delete tags DeleteTags(ctx context.Context, tags []string) error // SetAutomaticManagement set automatic torrent management SetAutomaticManagement(ctx context.Context, hashes []string, enable bool) error // ToggleSequentialDownload toggle sequential download ToggleSequentialDownload(ctx context.Context, hashes []string) error // SetFirstLastPiecePriority set first/last piece priority SetFirstLastPiecePriority(ctx context.Context, hashes []string) error // SetForceStart set force start SetForceStart(ctx context.Context, hashes []string, force bool) error // SetSuperSeeding set super seeding SetSuperSeeding(ctx context.Context, hashes []string, enable bool) error // RenameFile rename file RenameFile(ctx context.Context, hash, oldPath, newPath string) error // RenameFolder rename folder RenameFolder(ctx context.Context, hash, oldPath, newPath string) error } type TorrentOption struct { // Filter torrent list by state. Allowed state filters: all,downloading,seeding,completed,paused, // active,inactive,resumed,stalled,stalled_uploading,stalled_downloading,errored Filter string `schema:"filter,omitempty"` // Category get torrents with the given category, empty string means "without category"; no "category" // parameter means "any category" Category string `schema:"category,omitempty"` // Tag get torrents with the given tag, empty string means "without tag"; no "tag" parameter means "any tag" Tag string `schema:"tag,omitempty"` // Sort torrents by given key, they can be sorted using any field of the response's JSON array (which are documented below) as the sort key. Sort string `schema:"sort,omitempty"` // Reverse enable reverse sorting. Defaults to false Reverse bool `schema:"reverse,omitempty"` // Limit the number of torrents returned Limit int `schema:"limit,omitempty"` // Offset set offset (if less than 0, offset from end) Offset int `schema:"offset,omitempty"` // Hashes filter by hashes Hashes []string `schema:"-"` } type TorrentState string const ( TorrentStateError TorrentState = "error" TorrentStateMissingFiles TorrentState = "missingFiles" TorrentStateUploading TorrentState = "uploading" TorrentStatePausedUP TorrentState = "pausedUP" TorrentStateQueuedUP TorrentState = "queuedUP" TorrentStateStalledUP TorrentState = "stalledUP" TorrentStateCheckingUP TorrentState = "checkingUP" TorrentStateForcedUP TorrentState = "forcedUP" TorrentStateAllocating TorrentState = "allocating" TorrentStateDownloading TorrentState = "downloading" TorrentStateMetaDL TorrentState = "metaDL" TorrentStatePausedDL TorrentState = "pausedDL" TorrentStateQueuedDL TorrentState = "queuedDL" TorrentStateStalledDL TorrentState = "stalledDL" TorrentStateCheckingDL TorrentState = "checkingDL" TorrentStateForcedDL TorrentState = "forcedDL" TorrentStateCheckingResumeData TorrentState = "checkingResumeData" TorrentStateMoving TorrentState = "moving" TorrentStateUnknown TorrentState = "unknown" ) type TorrentInfo struct { AddedOn int `json:"added_on"` AmountLeft int `json:"amount_left"` AutoTmm bool `json:"auto_tmm"` Availability float64 `json:"availability"` Category string `json:"category"` Completed int `json:"completed"` CompletionOn int `json:"completion_on"` ContentPath string `json:"content_path"` DlLimit int `json:"dl_limit"` Dlspeed int `json:"dlspeed"` DownloadPath string `json:"download_path"` Downloaded int `json:"downloaded"` DownloadedSession int `json:"downloaded_session"` Eta int `json:"eta"` FLPiecePrio bool `json:"f_l_piece_prio"` ForceStart bool `json:"force_start"` Hash string `json:"hash"` InactiveSeedingTimeLimit int `json:"inactive_seeding_time_limit"` InfohashV1 string `json:"infohash_v1"` InfohashV2 string `json:"infohash_v2"` LastActivity int `json:"last_activity"` MagnetURI string `json:"magnet_uri"` MaxInactiveSeedingTime int `json:"max_inactive_seeding_time"` MaxRatio int `json:"max_ratio"` MaxSeedingTime int `json:"max_seeding_time"` Name string `json:"name"` NumComplete int `json:"num_complete"` NumIncomplete int `json:"num_incomplete"` NumLeechs int `json:"num_leechs"` NumSeeds int `json:"num_seeds"` Priority int `json:"priority"` Progress float64 `json:"progress"` Ratio float64 `json:"ratio"` RatioLimit int `json:"ratio_limit"` SavePath string `json:"save_path"` SeedingTime int `json:"seeding_time"` SeedingTimeLimit int `json:"seeding_time_limit"` SeenComplete int `json:"seen_complete"` SeqDl bool `json:"seq_dl"` Size int `json:"size"` State TorrentState `json:"state"` SuperSeeding bool `json:"super_seeding"` Tags string `json:"tags"` TimeActive int `json:"time_active"` TotalSize int `json:"total_size"` Tracker string `json:"tracker"` TrackersCount int `json:"trackers_count"` UpLimit int `json:"up_limit"` Uploaded int `json:"uploaded"` UploadedSession int `json:"uploaded_session"` Upspeed int `json:"upspeed"` } type TorrentProperties struct { AdditionDate int `json:"addition_date,omitempty"` Comment string `json:"comment,omitempty"` CompletionDate int `json:"completion_date,omitempty"` CreatedBy string `json:"created_by,omitempty"` CreationDate int `json:"creation_date,omitempty"` DlLimit int `json:"dl_limit,omitempty"` DlSpeed int `json:"dl_speed,omitempty"` DlSpeedAvg int `json:"dl_speed_avg,omitempty"` DownloadPath string `json:"download_path,omitempty"` Eta int `json:"eta,omitempty"` Hash string `json:"hash,omitempty"` InfohashV1 string `json:"infohash_v1,omitempty"` InfohashV2 string `json:"infohash_v2,omitempty"` IsPrivate bool `json:"is_private,omitempty"` LastSeen int `json:"last_seen,omitempty"` Name string `json:"name,omitempty"` NbConnections int `json:"nb_connections,omitempty"` NbConnectionsLimit int `json:"nb_connections_limit,omitempty"` Peers int `json:"peers,omitempty"` PeersTotal int `json:"peers_total,omitempty"` PieceSize int `json:"piece_size,omitempty"` PiecesHave int `json:"pieces_have,omitempty"` PiecesNum int `json:"pieces_num,omitempty"` Reannounce int `json:"reannounce,omitempty"` SavePath string `json:"save_path,omitempty"` SeedingTime int `json:"seeding_time,omitempty"` Seeds int `json:"seeds,omitempty"` SeedsTotal int `json:"seeds_total,omitempty"` ShareRatio float64 `json:"share_ratio,omitempty"` TimeElapsed int `json:"time_elapsed,omitempty"` TotalDownloaded int64 `json:"total_downloaded,omitempty"` TotalDownloadedSession int64 `json:"total_downloaded_session,omitempty"` TotalSize int64 `json:"total_size,omitempty"` TotalUploaded int64 `json:"total_uploaded,omitempty"` TotalUploadedSession int64 `json:"total_uploaded_session,omitempty"` TotalWasted int `json:"total_wasted,omitempty"` UpLimit int `json:"up_limit,omitempty"` UpSpeed int `json:"up_speed,omitempty"` UpSpeedAvg int `json:"up_speed_avg,omitempty"` } type TorrentTracker struct { Msg string `json:"msg,omitempty"` NumDownloaded int `json:"num_downloaded,omitempty"` NumLeeches int `json:"num_leeches,omitempty"` NumPeers int `json:"num_peers,omitempty"` NumSeeds int `json:"num_seeds,omitempty"` Status int `json:"status,omitempty"` Tier int `json:"tier,omitempty"` URL string `json:"url,omitempty"` } type TorrentWebSeed struct { URL string `json:"url"` } type Priority int const ( PriorityDoNotDownload Priority = 0 PriorityNormal Priority = 1 PriorityHigh Priority = 6 PriorityMax Priority = 7 ) type TorrentContent struct { Availability float64 `json:"availability,omitempty"` Index int `json:"index,omitempty"` IsSeed bool `json:"is_seed,omitempty"` Name string `json:"name,omitempty"` PieceRange []int `json:"piece_range,omitempty"` Priority Priority `json:"priority,omitempty"` Progress float64 `json:"progress,omitempty"` Size int64 `json:"size,omitempty"` } type TorrentAddFileMetadata struct { // Filename only used to distinguish two files in form-data, does not work on the server side, // for different files, please give different identification names Filename string // Data read torrent file content and set to here Data []byte } type TorrentAddOption struct { URLs []string `schema:"-"` // torrents url Torrents []*TorrentAddFileMetadata `schema:"-"` // raw data of torrent file SavePath string `schema:"savepath,omitempty"` // download folder, optional Cookies string `schema:"cookie,omitempty"` // cookie sent to download torrent file, optional Category string `schema:"category,omitempty"` // category for the torrent, optional Tags []string `schema:"-"` // tags for the torrent, optional SkipChecking bool `schema:"skip_checking,omitempty"` // skip hash checking, optional Paused bool `schema:"paused,omitempty"` // add torrent in the pause state, optional RootFolder bool `schema:"root_folder,omitempty"` // create the root folder, optional Rename string `schema:"rename,omitempty"` // rename torrent, optional UpLimit int `schema:"upLimit,omitempty"` // set torrent upload speed, Unit in bytes/second, optional DlLimit int `schema:"dlLimit,omitempty"` // set torrent download speed, Unit in bytes/second, optional RatioLimit float64 `schema:"ratioLimit,omitempty"` // set torrent share ratio limit, optional SeedingTimeLimit int `schema:"seedingTimeLimit,omitempty"` // set torrent seeding torrent limit, Unit in minutes, optional AutoTMM bool `schema:"autoTMM,omitempty"` // whether Automatic Torrent Management should be used, optional SequentialDownload string `schema:"sequentialDownload,omitempty"` // enable sequential download, optional FirstLastPiecePrio string `schema:"firstLastPiecePrio,omitempty"` // prioritize download first last piece, optional } type TorrentCategory struct { Name string `json:"name,omitempty"` SavePath string `json:"savePath,omitempty"` } func (c *client) GetTorrents(ctx context.Context, opt *TorrentOption) ([]*TorrentInfo, error) { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.GetTorrents") defer span.End() var formData = url.Values{} err := encoder.Encode(opt, formData) if err != nil { return nil, err } if len(opt.Hashes) != 0 { formData.Add("hashes", strings.Join(opt.Hashes, "|")) } apiUrl := fmt.Sprintf("%s/api/v2/torrents/info?%s", c.config.Address, formData.Encode()) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, }) if err != nil { return nil, err } if result.code != 200 { return nil, errors.New("get torrents failed: " + string(result.body)) } var mainData []*TorrentInfo if err := json.Unmarshal(result.body, &mainData); err != nil { return nil, err } return mainData, nil } func (c *client) GetProperties(ctx context.Context, hash string) (*TorrentProperties, error) { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.GetProperties") defer span.End() apiUrl := fmt.Sprintf("%s/api/v2/torrents/properties?hash=%s", c.config.Address, hash) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, }) if err != nil { return nil, err } if result.code != 200 { return nil, errors.New("get torrent properties failed: " + string(result.body)) } var mainData = new(TorrentProperties) if err := json.Unmarshal(result.body, mainData); err != nil { return nil, err } return mainData, nil } func (c *client) GetTrackers(ctx context.Context, hash string) ([]*TorrentTracker, error) { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.GetTrackers") defer span.End() apiUrl := fmt.Sprintf("%s/api/v2/torrents/trackers?hash=%s", c.config.Address, hash) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, }) if err != nil { return nil, err } if result.code != 200 { return nil, errors.New("get torrent trackers failed: " + string(result.body)) } var mainData []*TorrentTracker if err := json.Unmarshal(result.body, &mainData); err != nil { return nil, err } return mainData, nil } func (c *client) GetWebSeeds(ctx context.Context, hash string) ([]*TorrentWebSeed, error) { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.GetWebSeeds") defer span.End() apiUrl := fmt.Sprintf("%s/api/v2/torrents/webseeds?hash=%s", c.config.Address, hash) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, }) if err != nil { return nil, err } if result.code != 200 { return nil, errors.New("get torrent web seeds failed: " + string(result.body)) } var mainData []*TorrentWebSeed if err := json.Unmarshal(result.body, &mainData); err != nil { return nil, err } return mainData, nil } func sliceItoa[E constraints.Integer](in []E) []string { out := make([]string, 0, len(in)) for _, v := range in { out = append(out, strconv.FormatInt(int64(v), 10)) } return out } func (c *client) GetContents(ctx context.Context, hash string, indexes ...int) ([]*TorrentContent, error) { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.GetContents") defer span.End() var apiUrl string if len(indexes) != 0 { apiUrl = fmt.Sprintf("%s/api/v2/torrents/files?hash=%s&indexes=%s", c.config.Address, hash, strings.Join(sliceItoa(indexes), "|")) } else { apiUrl = fmt.Sprintf("%s/api/v2/torrents/files?hash=%s", c.config.Address, hash) } result, err := c.doRequest(ctx, &requestData{ url: apiUrl, }) if err != nil { return nil, err } if result.code != 200 { return nil, errors.New("get torrent web seeds failed: " + string(result.body)) } var mainData []*TorrentContent if err := json.Unmarshal(result.body, &mainData); err != nil { return nil, err } return mainData, nil } func (c *client) GetPiecesStates(ctx context.Context, hash string) ([]int, error) { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.GetPiecesStates") defer span.End() var apiUrl = fmt.Sprintf("%s/api/v2/torrents/pieceStates?hash=%s", c.config.Address, hash) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, }) if err != nil { return nil, err } if result.code != 200 { return nil, errors.New("get torrent pieces states failed: " + string(result.body)) } var mainData []int if err := json.Unmarshal(result.body, &mainData); err != nil { return nil, err } return mainData, nil } func (c *client) GetPiecesHashes(ctx context.Context, hash string) ([]string, error) { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.GetPiecesHashes") defer span.End() var apiUrl = fmt.Sprintf("%s/api/v2/torrents/pieceHashes?hash=%s", c.config.Address, hash) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, }) if err != nil { return nil, err } if result.code != 200 { return nil, errors.New("get torrent pieces states failed: " + string(result.body)) } var mainData []string if err := json.Unmarshal(result.body, &mainData); err != nil { return nil, err } return mainData, nil } func (c *client) PauseTorrents(ctx context.Context, hashes []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.PauseTorrents") defer span.End() if len(hashes) == 0 { return errors.New("no torrent hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/pause", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("pause torrents failed: " + string(result.body)) } return nil } func (c *client) ResumeTorrents(ctx context.Context, hashes []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.ResumeTorrents") defer span.End() if len(hashes) == 0 { return errors.New("no torrent hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/resume", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("resume torrents failed: " + string(result.body)) } return nil } func (c *client) DeleteTorrents(ctx context.Context, hashes []string, deleteFile bool) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.DeleteTorrents") defer span.End() if len(hashes) == 0 { return errors.New("no torrent hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) formData.Add("deleteFile", strconv.FormatBool(deleteFile)) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/resume", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("delete torrents failed: " + string(result.body)) } return nil } func (c *client) RecheckTorrents(ctx context.Context, hashes []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.RecheckTorrents") defer span.End() if len(hashes) == 0 { return errors.New("no torrent hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/recheck", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("recheck torrents failed: " + string(result.body)) } return nil } func (c *client) ReAnnounceTorrents(ctx context.Context, hashes []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.ReAnnounceTorrents") defer span.End() if len(hashes) == 0 { return errors.New("no torrent hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/reannounce", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("reannounce torrents failed: " + string(result.body)) } return nil } func (c *client) AddNewTorrent(ctx context.Context, opt *TorrentAddOption) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.AddNewTorrent") defer span.End() var requestBody bytes.Buffer var writer = multipart.NewWriter(&requestBody) if len(opt.URLs) == 0 && len(opt.Torrents) == 0 { return errors.New("no torrent url or data provided") } if opt.SavePath != "" { _ = writer.WriteField("savepath", opt.SavePath) } if opt.Cookies != "" { _ = writer.WriteField("cookies", opt.Cookies) } if opt.Category != "" { _ = writer.WriteField("category", opt.Category) } if len(opt.Tags) != 0 { _ = writer.WriteField("tags", strings.Join(opt.Tags, ",")) } if opt.SkipChecking { _ = writer.WriteField("skip_checking", "true") } if opt.Paused { _ = writer.WriteField("paused", "true") } if opt.RootFolder { _ = writer.WriteField("root_folder", "true") } if opt.Rename != "" { _ = writer.WriteField("rename", opt.Rename) } if opt.UpLimit != 0 { _ = writer.WriteField("upLimit", strconv.Itoa(opt.UpLimit)) } if opt.DlLimit != 0 { _ = writer.WriteField("dlLimit", strconv.Itoa(opt.DlLimit)) } if opt.RatioLimit != 0 { _ = writer.WriteField("ratioLimit", strconv.FormatFloat(opt.RatioLimit, 'f', -1, 64)) } if opt.SeedingTimeLimit != 0 { _ = writer.WriteField("seedingTimeLimit", strconv.Itoa(opt.SeedingTimeLimit)) } if opt.AutoTMM { _ = writer.WriteField("autoTMM", "true") } if opt.SequentialDownload != "" { _ = writer.WriteField("sequentialDownload", opt.SequentialDownload) } if opt.FirstLastPiecePrio != "" { _ = writer.WriteField("firstLastPiecePrio", opt.FirstLastPiecePrio) } if len(opt.URLs) != 0 { _ = writer.WriteField("urls", strings.Join(opt.URLs, "\n")) } else if len(opt.Torrents) != 0 { for _, torrent := range opt.Torrents { formFile, err := writer.CreateFormFile("torrents", torrent.Filename) if err != nil { return err } _, err = io.Copy(formFile, bytes.NewReader(torrent.Data)) if err != nil { return err } } } _ = writer.Close() var apiUrl = fmt.Sprintf("%s/api/v2/torrents/add", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, contentType: writer.FormDataContentType(), body: &requestBody, }) if err != nil { return err } if result.code != 200 { return errors.New("add torrents failed: " + string(result.body)) } return nil } func (c *client) AddTrackers(ctx context.Context, hash string, urls []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.AddTrackers") defer span.End() if len(urls) == 0 { return errors.New("no torrent tracker provided") } var formData = url.Values{} formData.Add("urls", strings.Join(urls, "%0A")) formData.Add("hash", hash) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/addTrackers", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("add torrent trackers failed: " + string(result.body)) } return nil } func (c *client) EditTrackers(ctx context.Context, hash, origUrl, newUrl string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.EditTrackers") defer span.End() var formData = url.Values{} formData.Add("origUrl", origUrl) formData.Add("newUrl", newUrl) formData.Add("hash", hash) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/editTracker", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("edit torrent trackers failed: " + string(result.body)) } return nil } func (c *client) RemoveTrackers(ctx context.Context, hash string, urls []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.RemoveTrackers") defer span.End() if len(urls) == 0 { return errors.New("no torrent tracker provided") } var formData = url.Values{} formData.Add("hash", hash) formData.Add("urls", strings.Join(urls, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/removeTrackers", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("remove torrent trackers failed: " + string(result.body)) } return nil } func (c *client) AddPeers(ctx context.Context, hashes []string, peers []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.AddPeers") defer span.End() if len(hashes) == 0 { return errors.New("no hashes provided") } if len(peers) == 0 { return errors.New("no peers provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) formData.Add("peers", strings.Join(peers, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/addPeers", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("addPeers torrents failed: " + string(result.body)) } return nil } func (c *client) IncreasePriority(ctx context.Context, hashes []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.IncreasePriority") defer span.End() if len(hashes) == 0 { return errors.New("no hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/increasePrio", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("increasePrio torrents failed: " + string(result.body)) } return nil } func (c *client) DecreasePriority(ctx context.Context, hashes []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.DecreasePriority") defer span.End() if len(hashes) == 0 { return errors.New("no hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/decreasePrio", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("decreasePrio torrents failed: " + string(result.body)) } return nil } func (c *client) MaxPriority(ctx context.Context, hashes []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.MaxPriority") defer span.End() if len(hashes) == 0 { return errors.New("no hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/topPrio", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("topPrio torrents failed: " + string(result.body)) } return nil } func (c *client) MinPriority(ctx context.Context, hashes []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.MinPriority") defer span.End() if len(hashes) == 0 { return errors.New("no hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/bottomPrio", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("bottomPrio torrents failed: " + string(result.body)) } return nil } func (c *client) SetFilePriority(ctx context.Context, hash string, id int, priority Priority) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.SetFilePriority") defer span.End() var formData = url.Values{} formData.Add("hash", hash) formData.Add("id", strconv.Itoa(id)) formData.Add("priority", strconv.Itoa(int(priority))) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/filePrio", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("filePrio torrents failed: " + string(result.body)) } return nil } func (c *client) GetDownloadLimit(ctx context.Context, hashes []string) (map[string]int, error) { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.GetDownloadLimit") defer span.End() if len(hashes) == 0 { return nil, errors.New("no hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/downloadLimit", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return nil, err } if result.code != 200 { return nil, errors.New("get torrents download limit failed: " + string(result.body)) } var data = make(map[string]int) err = json.Unmarshal(result.body, &data) return data, err } func (c *client) SetDownloadLimit(ctx context.Context, hashes []string, limit int) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.SetDownloadLimit") defer span.End() if len(hashes) == 0 { return errors.New("no hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) formData.Add("limit", strconv.Itoa(limit)) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/setDownloadLimit", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("set torrents download limit failed: " + string(result.body)) } return err } func (c *client) SetShareLimit(ctx context.Context, hashes []string, ratioLimit float64, seedingTimeLimit, inactiveSeedingTimeLimit int) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.SetShareLimit") defer span.End() if len(hashes) == 0 { return errors.New("no hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) formData.Add("ratioLimit", strconv.FormatFloat(ratioLimit, 'f', -1, 64)) formData.Add("seedingTimeLimit", strconv.Itoa(seedingTimeLimit)) formData.Add("inactiveSeedingTimeLimit", strconv.Itoa(inactiveSeedingTimeLimit)) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/setShareLimits", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("set torrents share limit failed: " + string(result.body)) } return err } func (c *client) GetUploadLimit(ctx context.Context, hashes []string) (map[string]int, error) { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.GetUploadLimit") defer span.End() if len(hashes) == 0 { return nil, errors.New("no hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/uploadLimit", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return nil, err } if result.code != 200 { return nil, errors.New("get torrents upload limit failed: " + string(result.body)) } var data = make(map[string]int) err = json.Unmarshal(result.body, &data) return data, err } func (c *client) SetUploadLimit(ctx context.Context, hashes []string, limit int) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.SetUploadLimit") defer span.End() if len(hashes) == 0 { return errors.New("no hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) formData.Add("limit", strconv.Itoa(limit)) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/setUploadLimit", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("set torrents upload limit failed: " + string(result.body)) } return err } func (c *client) SetLocation(ctx context.Context, hashes []string, location string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.SetLocation") defer span.End() if len(hashes) == 0 { return errors.New("no hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) formData.Add("location", location) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/setLocation", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("set torrents location failed: " + string(result.body)) } return err } func (c *client) SetName(ctx context.Context, hash string, name string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.SetName") defer span.End() var formData = url.Values{} formData.Add("hash", hash) formData.Add("name", name) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/rename", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("set torrents name failed: " + string(result.body)) } return err } func (c *client) SetCategory(ctx context.Context, hashes []string, category string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.SetCategory") defer span.End() if len(hashes) == 0 { return errors.New("no hashes provided") } var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) formData.Add("category", category) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/setCategory", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("set torrents category failed: " + string(result.body)) } return err } func (c *client) GetCategories(ctx context.Context) (map[string]*TorrentCategory, error) { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.GetCategories") defer span.End() var apiUrl = fmt.Sprintf("%s/api/v2/torrents/categories", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, }) if err != nil { return nil, err } if result.code != 200 { return nil, errors.New("get torrents upload limit failed: " + string(result.body)) } var data = make(map[string]*TorrentCategory) err = json.Unmarshal(result.body, &data) return data, err } func (c *client) AddNewCategory(ctx context.Context, category, savePath string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.AddNewCategory") defer span.End() var formData = url.Values{} formData.Add("category", category) formData.Add("savePath", savePath) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/createCategory", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("add new category failed: " + string(result.body)) } return err } func (c *client) EditCategory(ctx context.Context, category, savePath string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.EditCategory") defer span.End() var formData = url.Values{} formData.Add("category", category) formData.Add("savePath", savePath) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/editCategory", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("add new category failed: " + string(result.body)) } return err } func (c *client) RemoveCategories(ctx context.Context, categories []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.RemoveCategories") defer span.End() var formData = url.Values{} formData.Add("categories", strings.Join(categories, "\n")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/removeCategories", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("remove categories failed: " + string(result.body)) } return err } func (c *client) AddTags(ctx context.Context, hashes []string, tags []string) error { var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) formData.Add("tags", strings.Join(tags, ",")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/addTags", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("add torrent tags failed: " + string(result.body)) } return err } func (c *client) RemoveTags(ctx context.Context, hashes []string, tags []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.RemoveTags") defer span.End() var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) formData.Add("tags", strings.Join(tags, ",")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/removeTags", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("remove torrent tags failed: " + string(result.body)) } return err } func (c *client) GetTags(ctx context.Context) ([]string, error) { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.GetTags") defer span.End() var apiUrl = fmt.Sprintf("%s/api/v2/torrents/tags", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodGet, }) if err != nil { return nil, err } if result.code != 200 { return nil, errors.New("get tags failed: " + string(result.body)) } var data []string err = json.Unmarshal(result.body, &data) return data, err } func (c *client) CreateTags(ctx context.Context, tags []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.CreateTags") defer span.End() var formData = url.Values{} formData.Add("tags", strings.Join(tags, ",")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/createTags", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("create tags failed: " + string(result.body)) } return err } func (c *client) DeleteTags(ctx context.Context, tags []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.DeleteTags") defer span.End() var formData = url.Values{} formData.Add("tags", strings.Join(tags, ",")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/deleteTags", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("delete tags failed: " + string(result.body)) } return err } func (c *client) SetAutomaticManagement(ctx context.Context, hashes []string, enable bool) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.SetAutomaticManagement") defer span.End() var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) formData.Add("enable", strconv.FormatBool(enable)) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/setAutoManagement", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("set automatic management failed: " + string(result.body)) } return err } func (c *client) ToggleSequentialDownload(ctx context.Context, hashes []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.ToggleSequentialDownload") defer span.End() var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/toggleSequentialDownload", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("toggle sequential download failed: " + string(result.body)) } return err } func (c *client) SetFirstLastPiecePriority(ctx context.Context, hashes []string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.SetFirstLastPiecePriority") defer span.End() var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/toggleFirstLastPiecePrio", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("toggle first last piece prio failed: " + string(result.body)) } return err } func (c *client) SetForceStart(ctx context.Context, hashes []string, force bool) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.SetForceStart") defer span.End() var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) formData.Add("value", strconv.FormatBool(force)) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/setForceStart", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("set force start failed: " + string(result.body)) } return err } func (c *client) SetSuperSeeding(ctx context.Context, hashes []string, enable bool) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.SetSuperSeeding") defer span.End() var formData = url.Values{} formData.Add("hashes", strings.Join(hashes, "|")) formData.Add("value", strconv.FormatBool(enable)) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/setSuperSeeding", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("set super seeding failed: " + string(result.body)) } return err } func (c *client) RenameFile(ctx context.Context, hash, oldPath, newPath string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.RenameFile") defer span.End() var formData = url.Values{} formData.Add("oldPath", oldPath) formData.Add("newPath", newPath) formData.Add("hash", hash) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/renameFile", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("rename file failed: " + string(result.body)) } return nil } func (c *client) RenameFolder(ctx context.Context, hash, oldPath, newPath string) error { ctx, span := trace.Start(ctx, "qbittorrent.Torrent.RenameFolder") defer span.End() var formData = url.Values{} formData.Add("oldPath", oldPath) formData.Add("newPath", newPath) formData.Add("hash", hash) var apiUrl = fmt.Sprintf("%s/api/v2/torrents/renameFolder", c.config.Address) result, err := c.doRequest(ctx, &requestData{ url: apiUrl, method: http.MethodPost, body: strings.NewReader(formData.Encode()), }) if err != nil { return err } if result.code != 200 { return errors.New("rename folder failed: " + string(result.body)) } return nil }