/ src / aria2p / downloads.py
downloads.py
   1  """This module defines the BitTorrent, File and Download classes.
   2  
   3  They respectively hold structured information about
   4  torrent files, files and downloads in aria2c.
   5  """
   6  
   7  from __future__ import annotations
   8  
   9  from contextlib import suppress
  10  from datetime import datetime, timedelta, timezone
  11  from pathlib import Path
  12  from typing import TYPE_CHECKING
  13  
  14  from loguru import logger
  15  
  16  from aria2p.client import ClientException
  17  from aria2p.utils import bool_or_value, human_readable_bytes, human_readable_timedelta
  18  
  19  if TYPE_CHECKING:
  20      from aria2p.api import API
  21      from aria2p.options import Options
  22  
  23  
  24  class BitTorrent:
  25      """Information retrieved from a torrent file."""
  26  
  27      def __init__(self, struct: dict) -> None:
  28          """Initialize the object.
  29  
  30          Parameters:
  31              struct: A dictionary Python object returned by the JSON-RPC client.
  32          """
  33          self._struct = struct or {}
  34  
  35      def __str__(self):
  36          return self.info["name"]
  37  
  38      @property
  39      def announce_list(self) -> list[list[str]] | None:
  40          """List of lists of announce URIs.
  41  
  42          If the torrent contains announce and no announce-list, announce is converted to the announce-list format.
  43  
  44          Returns:
  45              The announce URIs.
  46          """
  47          return self._struct.get("announceList")
  48  
  49      @property
  50      def comment(self) -> str | None:
  51          """Return the comment of the torrent.
  52  
  53          comment.utf-8 is used if available.
  54  
  55          Returns:
  56              The torrent's comment.
  57          """
  58          return self._struct.get("comment")
  59  
  60      @property
  61      def creation_date(self) -> datetime:
  62          """Return the creation time of the torrent.
  63  
  64          The value is an integer since the epoch, measured in seconds.
  65  
  66          Returns:
  67              The creation date.
  68          """
  69          return datetime.fromtimestamp(self._struct["creationDate"], tz=timezone.utc)
  70  
  71      @property
  72      def mode(self) -> str | None:
  73          """File mode of the torrent.
  74  
  75          The value is either single or multi.
  76  
  77          Returns:
  78              The file mode.
  79          """
  80          return self._struct.get("mode")
  81  
  82      @property
  83      def info(self) -> dict | None:
  84          """Struct which contains data from Info dictionary.
  85  
  86          It contains the `name` key: name in info dictionary. `name.utf-8` is used if available.
  87  
  88          Returns:
  89              The torrent's info.
  90          """
  91          return self._struct.get("info")
  92  
  93  
  94  class File:
  95      """Information about a download's file."""
  96  
  97      def __init__(self, struct: dict) -> None:
  98          """Initialize the object.
  99  
 100          Parameters:
 101              struct: A dictionary Python object returned by the JSON-RPC client.
 102          """
 103          self._struct = struct or {}
 104  
 105      def __str__(self):
 106          return str(self.path)
 107  
 108      def __eq__(self, other: object) -> bool:
 109          if isinstance(other, File):
 110              return self.path == other.path
 111          return NotImplemented
 112  
 113      @property
 114      def index(self) -> int:
 115          """Index of the file, starting at 1, in the same order as files appear in the multi-file torrent.
 116  
 117          Returns:
 118              The index of the file.
 119          """
 120          return int(self._struct["index"])
 121  
 122      @property
 123      def path(self) -> Path:
 124          """File path.
 125  
 126          Returns:
 127              The file path.
 128          """
 129          return Path(self._struct["path"])
 130  
 131      @property
 132      def is_metadata(self) -> bool:
 133          """Return True if this file is aria2 metadata and not an actual file.
 134  
 135          Returns:
 136              If the file is metadata.
 137          """
 138          return str(self.path).startswith("[METADATA]")
 139  
 140      @property
 141      def length(self) -> int:
 142          """Return the file size in bytes.
 143  
 144          Returns:
 145              The file size in bytes.
 146          """
 147          return int(self._struct["length"])
 148  
 149      def length_string(self, human_readable: bool = True) -> str:  # noqa: FBT001,FBT002
 150          """Return the length as string.
 151  
 152          Parameters:
 153              human_readable: Return in human readable format or not.
 154  
 155          Returns:
 156              The length string.
 157          """
 158          if human_readable:
 159              return human_readable_bytes(self.length, delim=" ")
 160          return str(self.length) + " B"
 161  
 162      @property
 163      def completed_length(self) -> int:
 164          """Completed length of this file in bytes.
 165  
 166          Please note that it is possible that sum of completedLength is less than the completedLength returned by the
 167          aria2.tellStatus() method. This is because completedLength in aria2.getFiles() only includes completed
 168          pieces. On the other hand, completedLength in aria2.tellStatus() also includes partially completed pieces.
 169  
 170          Returns:
 171              The completed length.
 172          """
 173          return int(self._struct["completedLength"])
 174  
 175      def completed_length_string(self, human_readable: bool = True) -> str:  # noqa: FBT001,FBT002
 176          """Return the completed length as string.
 177  
 178          Parameters:
 179              human_readable: Return in human readable format or not.
 180  
 181          Returns:
 182              The completed length string.
 183          """
 184          if human_readable:
 185              return human_readable_bytes(self.completed_length, delim=" ")
 186          return str(self.completed_length) + " B"
 187  
 188      @property
 189      def selected(self) -> bool:
 190          """Return True if this file is selected by [`--select-file`][aria2p.options.Options.select_file] option.
 191  
 192          If [`--select-file`][aria2p.options.Options.select_file] is not specified
 193          or this is single-file torrent or not a torrent download at all, this value is always true.
 194          Otherwise false.
 195  
 196          Returns:
 197              If this file is selected.
 198          """
 199          return bool_or_value(self._struct["selected"])
 200  
 201      @property
 202      def uris(self) -> list[dict]:
 203          """Return a list of URIs for this file.
 204  
 205          The element type is the same struct
 206          used in the [`client.get_uris()`][aria2p.client.Client.get_uris] method.
 207  
 208          Returns:
 209              The list of URIs.
 210          """
 211          return self._struct.get("uris", [])
 212  
 213  
 214  class Download:
 215      """Class containing all information about a download, as retrieved with the client."""
 216  
 217      def __init__(self, api: API, struct: dict) -> None:
 218          """Initialize the object.
 219  
 220          Parameters:
 221              api: The reference to an [`API`][aria2p.api.API] instance.
 222              struct: A dictionary Python object returned by the JSON-RPC client.
 223          """
 224          self.api = api
 225          self._struct = struct or {}
 226          self._files: list[File] = []
 227          self._root_files_paths: list[Path] = []
 228          self._bittorrent: BitTorrent | None = None
 229          self._name = ""
 230          self._options: Options | None = None
 231          self._followed_by: list[Download] | None = None
 232          self._following: Download | None = None
 233          self._belongs_to: Download | None = None
 234  
 235      def __str__(self):
 236          return self.name
 237  
 238      def __eq__(self, other: object) -> bool:
 239          if isinstance(other, Download):
 240              return self.gid == other.gid
 241          return NotImplemented
 242  
 243      def update(self) -> None:
 244          """Update the internal values of the download with more recent values."""
 245          self._struct = self.api.client.tell_status(self.gid)
 246  
 247          self._files = []
 248          self._name = ""
 249          self._bittorrent = None
 250          self._followed_by = None
 251          self._following = None
 252          self._belongs_to = None
 253          self._options = None
 254  
 255      @property
 256      def live(self) -> Download:
 257          """Return the same object with updated data.
 258  
 259          Returns:
 260              Itself.
 261          """
 262          self.update()
 263          return self
 264  
 265      @property
 266      def name(self) -> str:
 267          """Return the name of the download.
 268  
 269          Name is the name of the file if single-file, first file's directory name if multi-file.
 270  
 271          Returns:
 272              The download name.
 273          """
 274          if not self._name:
 275              if self.bittorrent and self.bittorrent.info:
 276                  self._name = self.bittorrent.info["name"]
 277              elif self.files[0].is_metadata:
 278                  self._name = str(self.files[0].path)
 279              else:
 280                  file_path = str(self.files[0].path.absolute())
 281                  dir_path = str(self.dir.absolute())
 282                  if file_path.startswith(dir_path):
 283                      start_pos = len(dir_path) + 1
 284                      with suppress(IndexError):
 285                          self._name = Path(file_path[start_pos:]).parts[0]
 286                  else:
 287                      with suppress(IndexError):
 288                          self._name = self.files[0].uris[0]["uri"].split("/")[-1]
 289          return self._name
 290  
 291      @property
 292      def control_file_path(self) -> Path:
 293          """Return the path to the aria2 control file for this download.
 294  
 295          Returns:
 296              The control file path.
 297          """
 298          return self.dir / (self.name + ".aria2")
 299  
 300      @property
 301      def root_files_paths(self) -> list[Path]:
 302          """Return the unique set of directories/files for this download.
 303  
 304          Instead of returning all the leaves like self.files,
 305          return the relative root directories if any, and relative root files.
 306  
 307          This property is useful when we need to list the directories and files
 308          in order to move or copy them. We don't want to copy files one by one,
 309          but rather entire directories at once when possible.
 310  
 311          Returns:
 312              The root file paths.
 313  
 314          Examples:
 315              Download directory is `/a/b`.
 316  
 317              >>> self.files
 318              ["/a/b/c/1.txt", "/a/b/c/2.txt", "/a/b/3.txt"]
 319              >>> self.root_files_paths
 320              ["/a/b/c", "/a/b/3.txt"]
 321          """
 322          if not self._root_files_paths:
 323              paths = []
 324              for file in self.files:
 325                  if file.is_metadata:
 326                      continue
 327                  try:
 328                      relative_path = file.path.relative_to(self.dir)
 329                  except ValueError as error:
 330                      logger.warning(f"Can't determine file path '{file.path}' relative to '{self.dir}'")
 331                      logger.opt(exception=True).trace(error)
 332                  else:
 333                      path = self.dir / relative_path.parts[0]
 334                      if path not in paths:
 335                          paths.append(path)
 336              self._root_files_paths = paths
 337          return self._root_files_paths
 338  
 339      @property
 340      def options(self) -> Options:
 341          """Options specific to this download.
 342  
 343          Returns:
 344              The download options.
 345          """
 346          if not self._options:
 347              self.update_options()
 348          return self._options  # type: ignore
 349  
 350      @options.setter
 351      def options(self, value: Options) -> None:
 352          self._options = value
 353  
 354      def update_options(self) -> None:
 355          """Re-fetch the options from the remote."""
 356          self._options = self.api.get_options(downloads=[self])[0]
 357  
 358      @property
 359      def gid(self) -> str:
 360          """GID of the download.
 361  
 362          Returns:
 363              The download GID.
 364          """
 365          return self._struct["gid"]
 366  
 367      @property
 368      def status(self) -> str:
 369          """Return the status of the download.
 370  
 371          Returns:
 372              `active`, `waiting`, `paused`, `error`, `complete` or `removed`.
 373          """
 374          return self._struct["status"]
 375  
 376      @property
 377      def is_active(self) -> bool:
 378          """Return True if download is active.
 379  
 380          Returns:
 381              If this download is active.
 382          """
 383          return self.status == "active"
 384  
 385      @property
 386      def is_waiting(self) -> bool:
 387          """Return True if download is waiting.
 388  
 389          Returns:
 390              If this download is waiting.
 391          """
 392          return self.status == "waiting"
 393  
 394      @property
 395      def is_paused(self) -> bool:
 396          """Return True if download is paused.
 397  
 398          Returns:
 399              If this download is paused.
 400          """
 401          return self.status == "paused"
 402  
 403      @property
 404      def has_failed(self) -> bool:
 405          """Return True if download has errored.
 406  
 407          Returns:
 408              If this download has failed.
 409          """
 410          return self.status == "error"
 411  
 412      @property
 413      def is_complete(self) -> bool:
 414          """Return True if download is complete.
 415  
 416          Returns:
 417              If this download is complete.
 418          """
 419          return self.status == "complete"
 420  
 421      @property
 422      def is_removed(self) -> bool:
 423          """Return True if download was removed.
 424  
 425          Returns:
 426              If this download was removed.
 427          """
 428          return self.status == "removed"
 429  
 430      @property
 431      def is_metadata(self) -> bool:
 432          """Return True if this download is only composed of metadata, and no actual files.
 433  
 434          Returns:
 435              If this is a metadata download.
 436          """
 437          return all(_.is_metadata for _ in self.files)
 438  
 439      @property
 440      def is_torrent(self) -> bool:
 441          """Return true if this download is a torrent.
 442  
 443          Returns:
 444              If this is a torrent downlaod.
 445          """
 446          return "bittorrent" in self._struct
 447  
 448      @property
 449      def total_length(self) -> int:
 450          """Total length of the download in bytes.
 451  
 452          Returns:
 453              The total length in bytes.
 454          """
 455          return int(self._struct["totalLength"])
 456  
 457      def total_length_string(self, human_readable: bool = True) -> str:  # noqa: FBT001,FBT002
 458          """Return the total length as string.
 459  
 460          Parameters:
 461              human_readable: Return in human readable format or not.
 462  
 463          Returns:
 464              The total length string.
 465          """
 466          if human_readable:
 467              return human_readable_bytes(self.total_length, delim=" ")
 468          return str(self.total_length) + " B"
 469  
 470      @property
 471      def completed_length(self) -> int:
 472          """Completed length of the download in bytes.
 473  
 474          Returns:
 475              The completed length in bytes.
 476          """
 477          return int(self._struct["completedLength"])
 478  
 479      def completed_length_string(self, human_readable: bool = True) -> str:  # noqa: FBT001,FBT002
 480          """Return the completed length as string.
 481  
 482          Parameters:
 483              human_readable: Return in human readable format or not.
 484  
 485          Returns:
 486              The completed length string.
 487          """
 488          if human_readable:
 489              return human_readable_bytes(self.completed_length, delim=" ")
 490          return str(self.completed_length) + " B"
 491  
 492      @property
 493      def upload_length(self) -> int:
 494          """Return the uploaded length of the download in bytes.
 495  
 496          Returns:
 497              The uploaded length in bytes.
 498          """
 499          return int(self._struct["uploadLength"])
 500  
 501      def upload_length_string(self, human_readable: bool = True) -> str:  # noqa: FBT001,FBT002
 502          """Return the upload length as string.
 503  
 504          Parameters:
 505              human_readable: Return in human readable format or not.
 506  
 507          Returns:
 508              The upload length string.
 509          """
 510          if human_readable:
 511              return human_readable_bytes(self.upload_length, delim=" ")
 512          return str(self.upload_length) + " B"
 513  
 514      @property
 515      def bitfield(self) -> str | None:
 516          """Hexadecimal representation of the download progress.
 517  
 518          The highest bit corresponds to the piece at index 0. Any set bits indicate loaded pieces, while unset bits
 519          indicate not yet loaded and/or missing pieces. Any overflow bits at the end are set to zero. When the
 520          download was not started yet, this key will not be included in the response.
 521  
 522          Returns:
 523              The hexadecimal representation of the download progress.
 524          """
 525          return self._struct.get("bitfield")
 526  
 527      @property
 528      def download_speed(self) -> int:
 529          """Download speed of this download measured in bytes/sec.
 530  
 531          Returns:
 532              The download speed in bytes/sec.
 533          """
 534          return int(self._struct["downloadSpeed"])
 535  
 536      def download_speed_string(self, human_readable: bool = True) -> str:  # noqa: FBT001,FBT002
 537          """Return the download speed as string.
 538  
 539          Parameters:
 540              human_readable: Return in human readable format or not.
 541  
 542          Returns:
 543              The download speed string.
 544          """
 545          if human_readable:
 546              return human_readable_bytes(self.download_speed, delim=" ", postfix="/s")
 547          return str(self.download_speed) + " B/s"
 548  
 549      @property
 550      def upload_speed(self) -> int:
 551          """Upload speed of this download measured in bytes/sec.
 552  
 553          Returns:
 554              The upload speed in bytes/sec.
 555          """
 556          return int(self._struct["uploadSpeed"])
 557  
 558      def upload_speed_string(self, human_readable: bool = True) -> str:  # noqa: FBT001,FBT002
 559          """Return the upload speed as string.
 560  
 561          Parameters:
 562              human_readable: Return in human readable format or not.
 563  
 564          Returns:
 565              The upload speed string.
 566          """
 567          if human_readable:
 568              return human_readable_bytes(self.upload_speed, delim=" ", postfix="/s")
 569          return str(self.upload_speed) + " B/s"
 570  
 571      @property
 572      def info_hash(self) -> str | None:
 573          """Return the InfoHash.
 574  
 575          BitTorrent only.
 576  
 577          Returns:
 578              The InfoHash.
 579          """
 580          return self._struct.get("infoHash")
 581  
 582      @property
 583      def num_seeders(self) -> int:
 584          """Return the number of seeders aria2 has connected to.
 585  
 586          BitTorrent only.
 587  
 588          Returns:
 589              The numbers of seeders.
 590          """
 591          return int(self._struct["numSeeders"])
 592  
 593      @property
 594      def seeder(self) -> bool:
 595          """Return True if the local endpoint is a seeder, otherwise false.
 596  
 597          BitTorrent only.
 598  
 599          Returns:
 600              If the local endpoint is a seeder.
 601          """
 602          return bool_or_value(self._struct.get("seeder"))
 603  
 604      @property
 605      def piece_length(self) -> int:
 606          """Piece length in bytes.
 607  
 608          Returns:
 609              The piece length in bytes.
 610          """
 611          return int(self._struct["pieceLength"])
 612  
 613      def piece_length_string(self, human_readable: bool = True) -> str:  # noqa: FBT001,FBT002
 614          """Return the piece length as string.
 615  
 616          Parameters:
 617              human_readable: Return in human readable format or not.
 618  
 619          Returns:
 620              The piece length string.
 621          """
 622          if human_readable:
 623              return human_readable_bytes(self.piece_length, delim=" ")
 624          return str(self.piece_length) + " B"
 625  
 626      @property
 627      def num_pieces(self) -> int:
 628          """Return the number of pieces.
 629  
 630          Returns:
 631              The number of pieces.
 632          """
 633          return int(self._struct["numPieces"])
 634  
 635      @property
 636      def connections(self) -> int:
 637          """Return the number of peers/servers aria2 has connected to.
 638  
 639          Returns:
 640              The number of connected peers/servers.
 641          """
 642          return int(self._struct["connections"])
 643  
 644      @property
 645      def error_code(self) -> str | None:
 646          """Return the code of the last error for this item, if any.
 647  
 648          The value is a string. The error codes are defined in the EXIT STATUS section. This value is only available
 649          for stopped/completed downloads.
 650  
 651          Returns:
 652              The error code.
 653          """
 654          return self._struct.get("errorCode")
 655  
 656      @property
 657      def error_message(self) -> str | None:
 658          """Return the (hopefully) human readable error message associated to errorCode.
 659  
 660          Returns:
 661              The error message.
 662          """
 663          return self._struct.get("errorMessage")
 664  
 665      @property
 666      def followed_by_ids(self) -> list[str]:
 667          """List of GIDs which are generated as the result of this download.
 668  
 669          For example, when aria2 downloads a Metalink file, it generates downloads described in the Metalink (see the
 670          --follow-metalink option). This value is useful to track auto-generated downloads. If there are no such
 671          downloads, this key will not be included in the response.
 672  
 673          Returns:
 674              The children downloads IDs.
 675          """
 676          return self._struct.get("followedBy", [])
 677  
 678      @property
 679      def followed_by(self) -> list[Download]:
 680          """List of downloads generated as the result of this download.
 681  
 682          Returns:
 683              A list of instances of [`Download`][aria2p.downloads.Download].
 684          """
 685          if self._followed_by is None:
 686              result = []
 687              for gid in self.followed_by_ids:
 688                  try:
 689                      result.append(self.api.get_download(gid))
 690                  except ClientException as error:
 691                      logger.warning(
 692                          f"Can't find download with GID {gid}, try to update download {self.gid} ({id(self)}",
 693                      )
 694                      logger.opt(exception=True).trace(error)
 695              self._followed_by = result
 696          return self._followed_by
 697  
 698      @property
 699      def following_id(self) -> str | None:
 700          """Return the reverse link for followedBy.
 701  
 702          A download included in followedBy has this object's GID in its following value.
 703  
 704          Returns:
 705              The parent download ID.
 706          """
 707          return self._struct.get("following")
 708  
 709      @property
 710      def following(self) -> Download | None:
 711          """Return the download this download is following.
 712  
 713          Returns:
 714              An instance of [`Download`][aria2p.downloads.Download].
 715          """
 716          if not self._following:
 717              following_id = self.following_id
 718              if following_id:
 719                  try:
 720                      self._following = self.api.get_download(following_id)
 721                  except ClientException as error:
 722                      logger.warning(
 723                          f"Can't find download with GID {following_id}, try to update download {self.gid} ({id(self)}",
 724                      )
 725                      logger.opt(exception=True).trace(error)
 726                      self._following = None
 727          return self._following
 728  
 729      @property
 730      def belongs_to_id(self) -> str | None:
 731          """GID of a parent download.
 732  
 733          Some downloads are a part of another download. For example, if a file in a Metalink has BitTorrent resources,
 734          The downloads of ".torrent" files are parts of that parent. If this download has no parent, this key will not
 735          be included in the response.
 736  
 737          Returns:
 738              The GID of the parent download.
 739          """
 740          return self._struct.get("belongsTo")
 741  
 742      @property
 743      def belongs_to(self) -> Download | None:
 744          """Parent download.
 745  
 746          Returns:
 747              An instance of [`Download`][aria2p.downloads.Download].
 748          """
 749          if not self._belongs_to:
 750              belongs_to_id = self.belongs_to_id
 751              if belongs_to_id:
 752                  try:
 753                      self._belongs_to = self.api.get_download(belongs_to_id)
 754                  except ClientException as error:
 755                      logger.warning(
 756                          f"Can't find download with GID {belongs_to_id}, try to update download {self.gid} ({id(self)})",
 757                      )
 758                      logger.opt(exception=True).trace(error)
 759                      self._belongs_to = None
 760          return self._belongs_to
 761  
 762      @property
 763      def dir(self) -> Path:
 764          """Directory to save files.
 765  
 766          Returns:
 767              The directory where the files are saved.
 768          """
 769          return Path(self._struct["dir"])
 770  
 771      @property
 772      def files(self) -> list[File]:
 773          """Return the list of files.
 774  
 775          The elements of this list are the same structs used in aria2.getFiles() method.
 776  
 777          Returns:
 778              The files of this download.
 779          """
 780          if not self._files:
 781              self._files = [File(struct) for struct in self._struct.get("files", [])]
 782          return self._files
 783  
 784      @property
 785      def bittorrent(self) -> BitTorrent | None:
 786          """Struct which contains information retrieved from the .torrent (file).
 787  
 788          BitTorrent only.
 789  
 790          Returns:
 791              A [BitTorrent][aria2p.downloads.BitTorrent] instance or `None`.
 792          """
 793          if not self._bittorrent and "bittorrent" in self._struct:
 794              self._bittorrent = BitTorrent(self._struct.get("bittorrent", {}))
 795          return self._bittorrent
 796  
 797      @property
 798      def verified_length(self) -> int:
 799          """Return the number of verified number of bytes while the files are being hash checked.
 800  
 801          This key exists only when this download is being hash checked.
 802  
 803          Returns:
 804              The verified length.
 805          """
 806          return int(self._struct.get("verifiedLength", 0))
 807  
 808      def verified_length_string(self, human_readable: bool = True) -> str:  # noqa: FBT001,FBT002
 809          """Return the verified length as string.
 810  
 811          Parameters:
 812              human_readable: Return in human readable format or not.
 813  
 814          Returns:
 815              The verified length string.
 816          """
 817          if human_readable:
 818              return human_readable_bytes(self.verified_length, delim=" ")
 819          return str(self.verified_length) + " B"
 820  
 821      @property
 822      def verify_integrity_pending(self) -> bool | None:
 823          """Return True if this download is waiting for the hash check in a queue.
 824  
 825          This key exists only when this download is in the queue.
 826  
 827          Returns:
 828              Whether this download is waiting for the hash check.
 829          """
 830          return bool_or_value(self._struct.get("verifyIntegrityPending"))
 831  
 832      @property
 833      def progress(self) -> float:
 834          """Return the progress of the download as float.
 835  
 836          Returns:
 837              Progress percentage.
 838          """
 839          try:
 840              return self.completed_length / self.total_length * 100
 841          except ZeroDivisionError:
 842              return 0.0
 843  
 844      def progress_string(self, digits: int = 2) -> str:
 845          """Return the progress percentage as string.
 846  
 847          Parameters:
 848              digits: Number of decimal digits to use.
 849  
 850          Returns:
 851              The progress percentage.
 852          """
 853          return f"{self.progress:.{digits}f}%"
 854  
 855      @property
 856      def eta(self) -> timedelta:
 857          """Return the Estimated Time of Arrival (a timedelta).
 858  
 859          Returns:
 860              ETA or `timedelta.max` if unknown.
 861          """
 862          try:
 863              return timedelta(seconds=int((self.total_length - self.completed_length) / self.download_speed))
 864          except ZeroDivisionError:
 865              return timedelta.max
 866  
 867      def eta_string(self, precision: int = 0) -> str:
 868          """Return the Estimated Time of Arrival as a string.
 869  
 870          Parameters:
 871              precision: The precision to use, see [aria2p.utils.human_readable_timedelta].
 872  
 873          Returns:
 874              The Estimated Time of Arrival as a string.
 875          """
 876          eta = self.eta
 877  
 878          if eta == timedelta.max:
 879              return "-"
 880  
 881          return human_readable_timedelta(eta, precision=precision)
 882  
 883      def move(self, pos: int) -> int:
 884          """Move the download in the queue, relatively.
 885  
 886          Parameters:
 887              pos: Number of times to move.
 888  
 889          Returns:
 890              The new position of the download.
 891          """
 892          return self.api.move(self, pos)
 893  
 894      def move_to(self, pos: int) -> int:
 895          """Move the download in the queue, absolutely.
 896  
 897          Parameters:
 898              pos: The absolute position in the queue to take.
 899  
 900          Returns:
 901              The new position of the download.
 902          """
 903          return self.api.move_to(self, pos)
 904  
 905      def move_up(self, pos: int = 1) -> int:
 906          """Move the download up in the queue.
 907  
 908          Parameters:
 909              pos: Number of times to move up.
 910  
 911          Returns:
 912              The new position of the download.
 913          """
 914          return self.api.move_up(self, pos)
 915  
 916      def move_down(self, pos: int = 1) -> int:
 917          """Move the download down in the queue.
 918  
 919          Parameters:
 920              pos: Number of times to move down.
 921  
 922          Returns:
 923              The new position of the download.
 924          """
 925          return self.api.move_down(self, pos)
 926  
 927      def move_to_top(self) -> int:
 928          """Move the download to the top of the queue.
 929  
 930          Returns:
 931              The new position of the download.
 932          """
 933          return self.api.move_to_top(self)
 934  
 935      def move_to_bottom(self) -> int:
 936          """Move the download to the bottom of the queue.
 937  
 938          Returns:
 939              The new position of the download.
 940          """
 941          return self.api.move_to_bottom(self)
 942  
 943      def remove(self, force: bool = False, files: bool = False) -> bool:  # noqa: FBT001,FBT002
 944          """Remove the download from the queue (even if active).
 945  
 946          Parameters:
 947              force: Whether to force removal.
 948              files: Whether to remove files as well.
 949  
 950          Returns:
 951              Always True (raises exception otherwise).
 952  
 953          Raises:
 954              ClientException: When removal failed.
 955          """
 956          result = self.api.remove([self], force=force, files=files)[0]
 957          if not result:
 958              raise result  # type: ignore  # we know it's a ClientException
 959          return True
 960  
 961      def pause(self, force: bool = False) -> bool:  # noqa: FBT001,FBT002
 962          """Pause the download.
 963  
 964          Parameters:
 965              force: Whether to force pause (don't contact servers).
 966  
 967          Returns:
 968              Always True (raises exception otherwise).
 969  
 970          Raises:
 971              ClientException: When pausing failed.
 972          """
 973          result = self.api.pause([self], force=force)[0]
 974          if not result:
 975              raise result  # type: ignore  # we know it's a ClientException
 976          return True
 977  
 978      def resume(self) -> bool:
 979          """Resume the download.
 980  
 981          Returns:
 982              Always True (raises exception otherwise).
 983  
 984          Raises:
 985              ClientException: When resuming failed.
 986          """
 987          result = self.api.resume([self])[0]
 988          if not result:
 989              raise result  # type: ignore  # we know it's a ClientException
 990          return True
 991  
 992      def purge(self) -> bool:
 993          """Purge itself from the results.
 994  
 995          Returns:
 996              Success or failure of the operation.
 997          """
 998          return self.api.client.remove_download_result(self.gid) == "OK"
 999  
1000      def move_files(self, to_directory: str | Path, force: bool = False) -> bool:  # noqa: FBT001,FBT002
1001          """Move downloaded files to another directory.
1002  
1003          Parameters:
1004              to_directory: The target directory to move files to.
1005              force: Whether to move files even if download is not complete.
1006  
1007          Returns:
1008              Success or failure of the operation.
1009          """
1010          return self.api.move_files([self], to_directory, force)[0]
1011  
1012      def copy_files(self, to_directory: str | Path, force: bool = False) -> bool:  # noqa: FBT001,FBT002
1013          """Copy downloaded files to another directory.
1014  
1015          Parameters:
1016              to_directory: The target directory to copy files into.
1017              force: Whether to move files even if download is not complete.
1018  
1019          Returns:
1020              Success or failure of the operation.
1021          """
1022          return self.api.copy_files([self], to_directory, force)[0]