Skip to content

Commit a0663a0

Browse files
feat(torrent): set share limits (#164)
1 parent 09555da commit a0663a0

4 files changed

Lines changed: 381 additions & 0 deletions

File tree

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ A cli to manage qBittorrent. Add torrents, categories, tags, reannounce and impo
5454
* [Recheck](#recheck)
5555
* [Remove](#remove)
5656
* [Resume](#resume)
57+
* [Share limit](#share-limit)
58+
* [Set](#set-1)
5759
* [Tag](#tag-1)
5860
* [Issues](#issues)
5961
* [Tracker](#tracker)
@@ -870,6 +872,62 @@ Global Flags:
870872
--config string config file (default is $HOME/.config/qbt/.qbt.toml)
871873
```
872874

875+
### Share limit
876+
877+
Set share limits (ratio and seeding time) for torrents.
878+
879+
Limit values use qBittorrent's special semantics:
880+
881+
* `-2` - use the global limit (default)
882+
* `-1` - no limit (unlimited)
883+
* `>=0` - a specific value (ratio, or minutes for the seeding time limits)
884+
885+
qBittorrent applies all three limits in a single request, so any limit you do not set is reset to the global limit (`-2`). At least one of `--ratio`, `--seeding-time` or `--inactive-seeding-time` must be set. The applied values are printed when the command runs. On qBittorrent 5.x (Web API 2.12+) this also resets each torrent's share limit action and mode to their defaults.
886+
887+
Select torrents with `--hashes`, `--all`, or by `--include-category` / `--include-tags` / `--exclude-tags`. `--hashes` can be combined with the category/tag selectors to act on the union of both.
888+
889+
#### Set
890+
891+
```text
892+
Set share limits (ratio, seeding time, inactive seeding time) for torrents.
893+
894+
Limit values use qBittorrent's special semantics:
895+
-2 use the global limit (default)
896+
-1 no limit (unlimited)
897+
>=0 a specific value (ratio, or minutes for the seeding time limits)
898+
899+
qBittorrent applies all three limits in a single request, so any limit you do not
900+
set is reset to the global limit (-2). The applied values are printed when the
901+
command runs.
902+
903+
Note: on qBittorrent 5.x (Web API 2.12+) this also resets each torrent's share
904+
limit action and mode to their defaults.
905+
906+
Usage:
907+
qbt torrent share-limit set [flags]
908+
909+
Examples:
910+
qbt torrent share-limit set --hashes hash1,hash2 --ratio 2.0
911+
qbt torrent share-limit set --all --seeding-time 1440
912+
qbt torrent share-limit set --include-category movies --ratio 1.5 --seeding-time 10080
913+
qbt torrent share-limit set --hashes hash1 --ratio -1
914+
915+
Flags:
916+
--all Set share limits for all torrents
917+
--dry-run Run without doing anything
918+
--exclude-tags strings Exclude torrents with any of these tags. Comma separated
919+
--hashes strings Torrent hashes, as comma separated list
920+
-h, --help help for set
921+
--inactive-seeding-time int Inactive seeding time limit in MINUTES. -2 = global, -1 = unlimited, >=0 = minutes (default -2)
922+
-c, --include-category strings Set share limits for torrents in these categories. Comma separated
923+
--include-tags strings Include torrents with any of these tags. Comma separated
924+
--ratio float Ratio limit. -2 = global, -1 = unlimited, >=0 = ratio (default -2)
925+
--seeding-time int Seeding time limit in MINUTES. -2 = global, -1 = unlimited, >=0 = minutes (default -2)
926+
927+
Global Flags:
928+
--config string config file (default is $HOME/.config/qbt/.qbt.toml)
929+
```
930+
873931
### Tag
874932

875933
Tag torrents.

cmd/torrent.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func RunTorrent() *cobra.Command {
2424
command.AddCommand(RunTorrentRecheck())
2525
command.AddCommand(RunTorrentRemove())
2626
command.AddCommand(RunTorrentResume())
27+
command.AddCommand(RunTorrentShareLimit())
2728
command.AddCommand(RunTorrentTag())
2829
command.AddCommand(RunTorrentTracker())
2930

cmd/torrent_share_limit.go

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package cmd
2+
3+
import (
4+
"log"
5+
"strconv"
6+
"strings"
7+
8+
"github.com/ludviglundgren/qbittorrent-cli/internal/config"
9+
"github.com/ludviglundgren/qbittorrent-cli/pkg/utils"
10+
11+
"github.com/autobrr/go-qbittorrent"
12+
"github.com/pkg/errors"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
// RunTorrentShareLimit cmd for torrent share limit operations
17+
func RunTorrentShareLimit() *cobra.Command {
18+
var command = &cobra.Command{
19+
Use: "share-limit",
20+
Short: "Torrent share limit subcommand",
21+
Long: `Do various torrent share limit operations`,
22+
}
23+
24+
command.AddCommand(RunTorrentShareLimitSet())
25+
26+
return command
27+
}
28+
29+
// RunTorrentShareLimitSet cmd to set share limits for torrents
30+
func RunTorrentShareLimitSet() *cobra.Command {
31+
var (
32+
dry bool
33+
shareAll bool
34+
hashes []string
35+
includeCategory []string
36+
includeTags []string
37+
excludeTags []string
38+
ratioLimit float64
39+
seedingTimeLimit int64
40+
inactiveSeedingTimeLimit int64
41+
)
42+
43+
var command = &cobra.Command{
44+
Use: "set",
45+
Short: "Set torrent share limits",
46+
Long: `Set share limits (ratio, seeding time, inactive seeding time) for torrents.
47+
48+
Limit values use qBittorrent's special semantics:
49+
-2 use the global limit (default)
50+
-1 no limit (unlimited)
51+
>=0 a specific value (ratio, or minutes for the seeding time limits)
52+
53+
qBittorrent applies all three limits in a single request, so any limit you do not
54+
set is reset to the global limit (-2). The applied values are printed when the
55+
command runs.
56+
57+
Note: on qBittorrent 5.x (Web API 2.12+) this also resets each torrent's share
58+
limit action and mode to their defaults.`,
59+
Example: ` qbt torrent share-limit set --hashes hash1,hash2 --ratio 2.0
60+
qbt torrent share-limit set --all --seeding-time 1440
61+
qbt torrent share-limit set --include-category movies --ratio 1.5 --seeding-time 10080
62+
qbt torrent share-limit set --hashes hash1 --ratio -1`,
63+
}
64+
65+
command.Flags().BoolVar(&dry, "dry-run", false, "Run without doing anything")
66+
command.Flags().BoolVar(&shareAll, "all", false, "Set share limits for all torrents")
67+
command.Flags().StringSliceVar(&hashes, "hashes", []string{}, "Torrent hashes, as comma separated list")
68+
command.Flags().StringSliceVarP(&includeCategory, "include-category", "c", []string{}, "Set share limits for torrents in these categories. Comma separated")
69+
command.Flags().StringSliceVar(&includeTags, "include-tags", []string{}, "Include torrents with any of these tags. Comma separated")
70+
command.Flags().StringSliceVar(&excludeTags, "exclude-tags", []string{}, "Exclude torrents with any of these tags. Comma separated")
71+
command.Flags().Float64Var(&ratioLimit, "ratio", -2, "Ratio limit. -2 = global, -1 = unlimited, >=0 = ratio")
72+
command.Flags().Int64Var(&seedingTimeLimit, "seeding-time", -2, "Seeding time limit in MINUTES. -2 = global, -1 = unlimited, >=0 = minutes")
73+
command.Flags().Int64Var(&inactiveSeedingTimeLimit, "inactive-seeding-time", -2, "Inactive seeding time limit in MINUTES. -2 = global, -1 = unlimited, >=0 = minutes")
74+
75+
command.RunE = func(cmd *cobra.Command, args []string) error {
76+
// require at least one limit to be set so we don't silently reset
77+
// every limit to the global value
78+
if !cmd.Flags().Changed("ratio") && !cmd.Flags().Changed("seeding-time") && !cmd.Flags().Changed("inactive-seeding-time") {
79+
return errors.New("you must set at least one limit: --ratio, --seeding-time or --inactive-seeding-time")
80+
}
81+
82+
if err := validateShareLimits(ratioLimit, seedingTimeLimit, inactiveSeedingTimeLimit); err != nil {
83+
return err
84+
}
85+
86+
// require at least one target
87+
if !shareAll && len(hashes) == 0 && len(includeCategory) == 0 && len(includeTags) == 0 {
88+
return errors.New("no torrents specified. Use --hashes, --all, --include-category or --include-tags")
89+
}
90+
91+
if len(hashes) > 0 {
92+
if err := utils.ValidateHash(hashes); err != nil {
93+
return errors.Wrap(err, "invalid hashes supplied")
94+
}
95+
}
96+
97+
config.InitConfig()
98+
99+
qbtSettings := qbittorrent.Config{
100+
Host: config.Qbit.Addr,
101+
APIKey: config.Qbit.APIKey,
102+
Username: config.Qbit.Login,
103+
Password: config.Qbit.Password,
104+
BasicUser: config.Qbit.BasicUser,
105+
BasicPass: config.Qbit.BasicPass,
106+
}
107+
108+
qb := qbittorrent.NewClient(qbtSettings)
109+
110+
ctx := cmd.Context()
111+
112+
if err := qb.LoginCtx(ctx); err != nil {
113+
return errors.Wrap(err, "could not login to qbit")
114+
}
115+
116+
// resolve target hashes
117+
if shareAll {
118+
hashes = []string{"all"}
119+
} else if len(includeCategory) > 0 || len(includeTags) > 0 {
120+
// when only tags are provided, fetch all torrents (empty category)
121+
// and filter them down by tag
122+
if len(includeCategory) == 0 {
123+
includeCategory = []string{""}
124+
}
125+
126+
// append category/tag matches to any explicitly provided hashes
127+
// so the two selection methods union instead of overwrite
128+
for _, category := range includeCategory {
129+
torrents, err := qb.GetTorrentsCtx(ctx, qbittorrent.TorrentFilterOptions{Category: category})
130+
if err != nil {
131+
return errors.Wrapf(err, "could not get torrents for category: %s", category)
132+
}
133+
134+
for _, torrent := range torrents {
135+
if len(includeTags) > 0 {
136+
if _, validTag := validateTag(includeTags, torrent.Tags); !validTag {
137+
continue
138+
}
139+
}
140+
141+
if len(excludeTags) > 0 {
142+
if _, found := validateTag(excludeTags, torrent.Tags); found {
143+
continue
144+
}
145+
}
146+
147+
hashes = append(hashes, torrent.Hash)
148+
}
149+
}
150+
}
151+
152+
if len(hashes) == 0 {
153+
log.Println("No torrents found to set share limits on")
154+
return nil
155+
}
156+
157+
opts := qbittorrent.ShareLimitOptions{
158+
RatioLimit: ratioLimit,
159+
SeedingTimeLimit: seedingTimeLimit,
160+
InactiveSeedingTimeLimit: inactiveSeedingTimeLimit,
161+
}
162+
163+
limitsDesc := formatShareLimits(opts)
164+
165+
target := strconv.Itoa(len(hashes)) + " torrent(s)"
166+
if shareAll {
167+
target = "all torrents"
168+
}
169+
170+
if dry {
171+
log.Printf("dry-run: would set share limits (%s) on %s\n", limitsDesc, target)
172+
return nil
173+
}
174+
175+
err := batchRequests(hashes, func(start, end int) error {
176+
return qb.SetTorrentShareLimitCtx(ctx, hashes[start:end], opts)
177+
})
178+
if err != nil {
179+
return errors.Wrap(err, "could not set share limits")
180+
}
181+
182+
log.Printf("successfully set share limits (%s) on %s\n", limitsDesc, target)
183+
184+
return nil
185+
}
186+
187+
return command
188+
}
189+
190+
// validateShareLimits checks that each limit is within qBittorrent's accepted
191+
// range: -2 (global), -1 (unlimited) or any value >= 0.
192+
func validateShareLimits(ratioLimit float64, seedingTimeLimit, inactiveSeedingTimeLimit int64) error {
193+
// ratio is a float, so a simple `< -2` check would let fractional
194+
// sentinels like -1.5 or -0.5 through; only -2, -1 and >= 0 are valid
195+
if ratioLimit < 0 && ratioLimit != -1 && ratioLimit != -2 {
196+
return errors.Errorf("invalid ratio limit %s: must be -2 (global), -1 (unlimited) or >= 0", strconv.FormatFloat(ratioLimit, 'f', -1, 64))
197+
}
198+
199+
if seedingTimeLimit < -2 {
200+
return errors.Errorf("invalid seeding-time limit %d: must be -2 (global), -1 (unlimited) or >= 0", seedingTimeLimit)
201+
}
202+
203+
if inactiveSeedingTimeLimit < -2 {
204+
return errors.Errorf("invalid inactive-seeding-time limit %d: must be -2 (global), -1 (unlimited) or >= 0", inactiveSeedingTimeLimit)
205+
}
206+
207+
return nil
208+
}
209+
210+
// formatShareLimits builds a human readable description of the share limits.
211+
func formatShareLimits(opts qbittorrent.ShareLimitOptions) string {
212+
return strings.Join([]string{
213+
"ratio=" + formatRatioLimit(opts.RatioLimit),
214+
"seeding-time=" + formatTimeLimit(opts.SeedingTimeLimit),
215+
"inactive-seeding-time=" + formatTimeLimit(opts.InactiveSeedingTimeLimit),
216+
}, ", ")
217+
}
218+
219+
func formatRatioLimit(ratioLimit float64) string {
220+
switch ratioLimit {
221+
case -2:
222+
return "global"
223+
case -1:
224+
return "unlimited"
225+
default:
226+
return strconv.FormatFloat(ratioLimit, 'f', 2, 64)
227+
}
228+
}
229+
230+
func formatTimeLimit(minutes int64) string {
231+
switch minutes {
232+
case -2:
233+
return "global"
234+
case -1:
235+
return "unlimited"
236+
default:
237+
return strconv.FormatInt(minutes, 10) + "m"
238+
}
239+
}

0 commit comments

Comments
 (0)