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