Assorted shell and Python scripts
1#!/usr/bin/env -S uv run --script
2# /// script
3# dependencies = [
4# "qbittorrent-api",
5# "requests",
6# "docopt",
7# ]
8# ///
9
10"""scihub_knapsack.py
11
12Description:
13This script will add torrents to a qBittorrent instance until a specified size
14limit is reached.
15
16By default, the larger torrents are prioritized in descending order, but the
17script can be run with the --smaller flag to prioritize smaller torrents in
18ascending order.
19
20The script will select only torrents with less than or equal to <max_seeders>.
21
22Usage:
23 scihub_knapsack.py [--smaller] [--dry-run] -H <hostname> -U <username> -P <password> -S <size> -s <max_seeders>
24 scihub_knapsack.py -h
25
26Examples:
27 scihub_knapsack.py -H http://localhost:8080 -U admin -P adminadmin -S 42T
28 scihub_knapsack.py --smaller -H https://qbt.hello.world -U admin -P adminadmin -S 2.2T
29
30Options:
31 --smaller Prioritize from the smallest torrent sizes and work upward
32 to larger sizes. Default is to prioritize larger sizes.
33 --dry-run Only print the torrent names, total number of torrents, and
34 their total combined size instead of adding them to the
35 qBittorrent instance.
36 -H <hostname> Hostname of the server where the qBittorrent instance is
37 running.
38 -U <username> Username of the user to login to the qBittorrent instance.
39 -P <password> Password of the user to login to the qBittorrent instance.
40 -S <size> The maximum size, in GiB or TiB, of the knapsack to add Sci
41 Hub torrents to. Must be a positive integer or float. Must
42 have either G or T on the end, which represents GiB or TiB.
43 -s <max_seeders> Select torrents with less than or equal to <max_seeders>
44 seeders. <max_seeders> is a positive integer.
45"""
46
47import json
48
49import qbittorrentapi
50import requests
51from docopt import docopt
52
53
54def get_torrent_health_data() -> list[dict]:
55 """
56 Fetch Sci Hub torrent health checker data from the given URL. The URL
57 should refer to a JSON-formatted file.
58 """
59 TORRENT_HEALTH_URL = (
60 "https://zrthstr.github.io/libgen_torrent_cardiography/torrent.json"
61 )
62 response = requests.get(TORRENT_HEALTH_URL, timeout=60)
63 return json.loads(response.text)
64
65
66def convert_size_to_bytes(size: str) -> int:
67 """
68 Convert the given size string to bytes.
69
70 Example: 42G --> 45097156608 bytes
71 """
72 total_bytes = int()
73
74 if size.endswith("T"):
75 total_bytes = int(size.split("T")[0]) * (1024**4)
76
77 if size.endswith("G"):
78 total_bytes = int(size.split("G")[0]) * (1024**3)
79
80 return total_bytes
81
82
83def human_bytes(bites: int) -> str:
84 """
85 Convert bytes to KiB, MiB, GiB, or TiB.
86
87 Example: 45097156608 bytes -> 42 GiB
88 """
89 B = float(bites)
90 KiB = float(1024)
91 MiB = float(KiB**2)
92 GiB = float(KiB**3)
93 TiB = float(KiB**4)
94
95 match B:
96 case B if B < KiB:
97 return "{0} {1}".format(B, "bytes" if 0 == B > 1 else "byte")
98 case B if KiB <= B < MiB:
99 return "{0:.2f} KiB".format(B / KiB)
100 case B if MiB <= B < GiB:
101 return "{0:.2f} MiB".format(B / MiB)
102 case B if GiB <= B < TiB:
103 return "{0:.2f} GiB".format(B / GiB)
104 case B if TiB <= B:
105 return "{0:.2f} TiB".format(B / TiB)
106 case _:
107 return ""
108
109
110def get_knapsack_weight(knapsack: list[dict]) -> str:
111 """
112 Get the weight of the given knapsack in GiB or TiB.
113 """
114 return human_bytes(sum([torrent["size_bytes"] for torrent in knapsack]))
115
116
117def fill_knapsack(
118 max_seeders: int, knapsack_size: int, smaller: bool = False
119) -> list[dict]:
120 """
121 Fill the knapsack.
122
123 Arguments:
124 max_seeders: int -- Select only torrents with less than or equal to
125 this number of seeders
126 knapsack_size: int -- The size in bytes of the knapsack
127 smaller: bool -- Prioritize smaller sized torrents (Default = False)
128
129 Return value:
130 A list of dictionaries that represent the torrents.
131 """
132
133 # List of torrents with less than or equal to <max_seeders>
134 torrents = [t for t in get_torrent_health_data() if t["seeders"] <= max_seeders]
135
136 # Sorted list of torrents with <max_seeders>. If smaller == True, sort them
137 # in ascending order by size_bytes. Else sort them in descending order by
138 # size_bytes.
139 sorted_torrents = (
140 sorted(torrents, key=lambda d: d["size_bytes"])
141 if smaller == True
142 else sorted(torrents, key=lambda d: d["size_bytes"], reverse=True)
143 )
144
145 # Sum the sizes of each torrent in sorted_torrents and add them to the
146 # knapsack until it is filled, then return the knapsack.
147 sum = 0
148 knapsack = []
149 for torrent in sorted_torrents:
150 if sum + torrent["size_bytes"] >= knapsack_size:
151 break
152 sum += torrent["size_bytes"]
153 knapsack.append(torrent)
154
155 return knapsack
156
157
158if __name__ == "__main__":
159 args = docopt(__doc__) # type: ignore
160 hostname = args["-H"]
161 username = args["-U"]
162 password = args["-P"]
163 max_seeders = int(args["-s"])
164 knapsack_size = convert_size_to_bytes(args["-S"])
165 smaller = args["--smaller"]
166 dry_run = args["--dry-run"]
167
168 # Initialize client and login
169 qbt_client = qbittorrentapi.Client(
170 host=hostname, username=username, password=password
171 )
172
173 try:
174 qbt_client.auth_log_in()
175 except qbittorrentapi.LoginFailed as e:
176 print(e)
177
178 # Fill the knapsack
179 knapsack = fill_knapsack(max_seeders, knapsack_size, smaller)
180
181 # If it's a dry run, only print the knapsack's contents. Otherwise,
182 # add the knapsack's contents to the qBittorrent instance.
183 # When finished, print the number of items and the combined weight of all
184 # items in the knapsack. Before attempting to add items to the qBittorrent
185 # instance, check to see if libgen.rs is even working. If libgen.rs is down
186 # no torrents can be added to the qBittorrent instance, so exit with an
187 # notice.
188 if dry_run:
189 for torrent in knapsack:
190 print(torrent["link"])
191 else:
192 response = requests.get("https://libgen.is/")
193 if not response.ok:
194 exit(
195 "It appears https://libgen.is is currently down. Please try again later."
196 )
197 for torrent in knapsack:
198 for torrent in knapsack:
199 if "gen.lib.rus.ec" in torrent["link"]:
200 new_torrent = torrent["link"].replace("gen.lib.rus.ec", "libgen.is")
201 qbt_client.torrents_add(new_torrent, category="scihub")
202
203 if "libgen.rs" in torrent["link"]:
204 new_torrent = torrent["link"].replace("libgen.rs", "libgen.is")
205 qbt_client.torrents_add(new_torrent, category="scihub")
206 # print(f"Added {torrent['name']}")
207
208 qbt_client.auth_log_out()
209
210 print("----------------")
211 print(f"Count: {len(knapsack)} torrents")
212 print(f"Total combined size: {get_knapsack_weight(knapsack)}")
213 print("----------------")