From c5dcbb6166433eb44e06787965387edb272951d2 Mon Sep 17 00:00:00 2001 From: Russell Martin Date: Thu, 9 Dec 2021 14:14:15 -0500 Subject: [PATCH] Support qBittorrent 4.4.0 (Web API v2.8.4) - torrents/info results can now be filtered by a torrent tag - Added new torrent state "Forced Metadata Downloading" - Support per-torrent/per-category "download folder" --- .github/workflows/python-app.yml | 8 +- CHANGELOG.txt | 6 + README.md | 2 +- docs/source/introduction.rst | 2 +- qbittorrentapi/definitions.py | 2 + qbittorrentapi/torrents.py | 182 ++++++++++++++++++++++++-- qbittorrentapi/torrents.pyi | 49 ++++++- setup.py | 2 +- tests/conftest.py | 41 +----- tests/test_definitions.py | 2 + tests/test_torrent.py | 49 ++++++- tests/test_torrents.py | 211 +++++++++++++++++++++++++++---- tests/version_map.py | 33 +++++ 13 files changed, 506 insertions(+), 83 deletions(-) create mode 100644 tests/version_map.py diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index a9636c02a..2626ab704 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -21,7 +21,7 @@ jobs: env: LATEST_PYTHON_VERSION: "3.10" LATEST_QBT_VERSION: 4.4.0rc1 - QBT_ALWAYS_TEST: 4.4.0rc1, 4.3.9, 4.3.8, 4.3.5, 4.3.4.1, 4.3.3, 4.3.2, 4.3.1, 4.3.0.1, 4.2.0 + QBT_ALWAYS_TEST: 4.4.0, 4.3.9, 4.3.8, 4.3.5, 4.3.4.1, 4.3.3, 4.3.2, 4.3.1, 4.3.0.1, 4.2.0 QT_USE_DEFAULT_PAA: 4.3.9, 4.3.8, 4.3.7, 4.3.6, 4.3.5, 4.3.4.1, 4.3.3, 4.3.2, 4.3.1, 4.3.0.1, 4.2.5, 4.2.0 SUBMIT_COVERAGE_VERSIONS: 2.7, 3.10 COMPREHENSIVE_TESTS_BRANCH: comprehensive_tests @@ -32,8 +32,8 @@ jobs: QBT_LEGACY_INSTALL: 4.2.5, 4.2.0 strategy: matrix: - QBT_VER: [4.4.0rc1, 4.3.9, 4.3.8, 4.3.7, 4.3.6, 4.3.5, 4.3.4.1, 4.3.3, 4.3.2, 4.3.1, 4.3.0.1, 4.2.5, 4.2.0] - # QBT_VER: [4.4.0beta3] + QBT_VER: [4.4.0, 4.3.9, 4.3.8, 4.3.7, 4.3.6, 4.3.5, 4.3.4.1, 4.3.3, 4.3.2, 4.3.1, 4.3.0.1, 4.2.5, 4.2.0] + # QBT_VER: [4.4.0] python-version: ["3.10", "3.9", "3.8", "3.7", "3.6", "2.7", "pypy2", "pypy3", "3.11.0-alpha.2"] # python-version: [3.9] @@ -129,6 +129,7 @@ jobs: mkdir -p "$SRC_DIR" && mkdir -p "$QBT_DIR" cd "$SRC_DIR" + rm -rf qBittorrent git clone https://github.com/qbittorrent/qBittorrent.git --branch release-${{ matrix.QBT_VER }} --depth 1 cd qBittorrent export libtorrent_CFLAGS="$LIBTOR_DIR/include/" && export libtorrent_LIBS="$LIBTOR_DIR/lib/libtorrent-rasterbar.so" @@ -149,6 +150,7 @@ jobs: mkdir -p "$SRC_DIR" && mkdir -p "$QBT_DIR" cd "$SRC_DIR" + rm -rf qBittorrent git clone https://github.com/qbittorrent/qBittorrent.git --branch release-${{ matrix.QBT_VER }} --depth 1 cd qBittorrent cmake -G "Ninja" -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$LIBTOR_DIR" -DVERBOSE_CONFIGURE=ON \ diff --git a/CHANGELOG.txt b/CHANGELOG.txt index bba08c6e9..b68e53357 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,9 @@ +Version 2022.1.27 (9 jan 2022) + - Support for qBittorrent v4.4.0 + - torrents/info results can now be filtered by a torrent tag + - Added new torrent state "Forced Metadata Downloading" + - Support per-torrent/per-category "download folder" + Version 2021.12.26 (11 dec 2021) - Stop sending Origin and Referer headers (Fixes #63) diff --git a/README.md b/README.md index ef194dc39..72e52b0f6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ qBittorrent Web API Client Python client implementation for qBittorrent Web API. Supports qBittorrent v4.1.0+ (i.e. Web API v2.0+). -Currently supports up to qBittorrent [v4.3.9](https://github.com/qbittorrent/qBittorrent/releases/tag/release-4.3.9) (Web API v2.8.2) released on Oct 31, 2021. +Currently supports up to qBittorrent [v4.4.0](https://github.com/qbittorrent/qBittorrent/releases/tag/release-4.4.0) (Web API v2.8.4) released on Jan 6, 2022. [Find the full documentation for this client on RTD.](https://qbittorrent-api.readthedocs.io/) diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 76ea53adf..d7405638c 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -21,7 +21,7 @@ Introduction Python client implementation for qBittorrent Web API. -Currently supports up to qBittorrent `v4.3.9 `_ (Web API v2.8.2) released on Oct 31, 2021. +Currently supports up to qBittorrent `v4.4.0 `_ (Web API v2.8.4) released on Jan 6, 2022. The full qBittorrent Web API documentation is available on their `wiki `_. diff --git a/qbittorrentapi/definitions.py b/qbittorrentapi/definitions.py index d9d7aac7e..759c9f0a4 100644 --- a/qbittorrentapi/definitions.py +++ b/qbittorrentapi/definitions.py @@ -59,6 +59,7 @@ class TorrentStates(Enum): ALLOCATING = "allocating" DOWNLOADING = "downloading" METADATA_DOWNLOAD = "metaDL" + FORCED_METADATA_DOWNLOAD = "forcedMetaDL" PAUSED_DOWNLOAD = "pausedDL" QUEUED_DOWNLOAD = "queuedDL" FORCED_DOWNLOAD = "forcedDL" @@ -74,6 +75,7 @@ def is_downloading(self): return self in ( TorrentStates.DOWNLOADING, TorrentStates.METADATA_DOWNLOAD, + TorrentStates.FORCED_METADATA_DOWNLOAD, TorrentStates.STALLED_DOWNLOAD, TorrentStates.CHECKING_DOWNLOAD, TorrentStates.PAUSED_DOWNLOAD, diff --git a/qbittorrentapi/torrents.py b/qbittorrentapi/torrents.py index 420977496..292a3b26e 100644 --- a/qbittorrentapi/torrents.py +++ b/qbittorrentapi/torrents.py @@ -212,6 +212,20 @@ def set_location(self, location=None, **kwargs): location=location, torrent_hashes=self._torrent_hash, **kwargs ) + @Alias("setSavePath") + def set_save_path(self, save_path=None, **kwargs): + """Implements :meth:`~TorrentsAPIMixIn.torrents_set_save_path`""" + self._client.torrents_set_save_path( + save_path=save_path, torrent_hashes=self._torrent_hash, **kwargs + ) + + @Alias("setDownloadPath") + def set_download_path(self, download_path=None, **kwargs): + """Implements :meth:`~TorrentsAPIMixIn.torrents_set_download_path`""" + self._client.torrents_set_download_path( + download_path=download_path, torrent_hashes=self._torrent_hash, **kwargs + ) + @Alias("setCategory") def set_category(self, category=None, **kwargs): """Implements :meth:`~TorrentsAPIMixIn.torrents_set_category`""" @@ -543,6 +557,14 @@ def __init__(self, client): self.set_location = self._ActionForAllTorrents( client=client, func=client.torrents_set_location ) + self.set_save_path = self._ActionForAllTorrents( + client=client, func=client.torrents_set_save_path + ) + self.setSavePath = self.set_save_path + self.set_download_path = self._ActionForAllTorrents( + client=client, func=client.torrents_set_download_path + ) + self.setDownloadPath = self.set_download_path self.setLocation = self.set_location self.set_category = self._ActionForAllTorrents( client=client, func=client.torrents_set_category @@ -593,6 +615,8 @@ def add( content_layout=None, ratio_limit=None, seeding_time_limit=None, + download_path=None, + use_download_path=None, **kwargs ): return self._client.torrents_add( @@ -614,6 +638,8 @@ def add( content_layout=content_layout, ratio_limit=ratio_limit, seeding_time_limit=seeding_time_limit, + download_path=download_path, + use_download_path=use_download_path, **kwargs ) @@ -638,6 +664,7 @@ def __call__( limit=None, offset=None, torrent_hashes=None, + tag=None, **kwargs ): return self._client.torrents_info( @@ -648,6 +675,7 @@ def __call__( limit=limit, offset=offset, torrent_hashes=torrent_hashes, + tag=tag, **kwargs ) @@ -659,6 +687,7 @@ def all( limit=None, offset=None, torrent_hashes=None, + tag=None, **kwargs ): return self._client.torrents_info( @@ -669,6 +698,7 @@ def all( limit=limit, offset=offset, torrent_hashes=torrent_hashes, + tag=tag, **kwargs ) @@ -680,6 +710,7 @@ def downloading( limit=None, offset=None, torrent_hashes=None, + tag=None, **kwargs ): return self._client.torrents_info( @@ -690,6 +721,7 @@ def downloading( limit=limit, offset=offset, torrent_hashes=torrent_hashes, + tag=tag, **kwargs ) @@ -701,6 +733,7 @@ def completed( limit=None, offset=None, torrent_hashes=None, + tag=None, **kwargs ): return self._client.torrents_info( @@ -711,6 +744,7 @@ def completed( limit=limit, offset=offset, torrent_hashes=torrent_hashes, + tag=tag, **kwargs ) @@ -722,6 +756,7 @@ def paused( limit=None, offset=None, torrent_hashes=None, + tag=None, **kwargs ): return self._client.torrents_info( @@ -732,6 +767,7 @@ def paused( limit=limit, offset=offset, torrent_hashes=torrent_hashes, + tag=tag, **kwargs ) @@ -743,6 +779,7 @@ def active( limit=None, offset=None, torrent_hashes=None, + tag=None, **kwargs ): return self._client.torrents_info( @@ -753,6 +790,7 @@ def active( limit=limit, offset=offset, torrent_hashes=torrent_hashes, + tag=tag, **kwargs ) @@ -764,6 +802,7 @@ def inactive( limit=None, offset=None, torrent_hashes=None, + tag=None, **kwargs ): return self._client.torrents_info( @@ -774,6 +813,7 @@ def inactive( limit=limit, offset=offset, torrent_hashes=torrent_hashes, + tag=tag, **kwargs ) @@ -785,6 +825,7 @@ def resumed( limit=None, offset=None, torrent_hashes=None, + tag=None, **kwargs ): return self._client.torrents_info( @@ -795,6 +836,7 @@ def resumed( limit=limit, offset=offset, torrent_hashes=torrent_hashes, + tag=tag, **kwargs ) @@ -806,6 +848,7 @@ def stalled( limit=None, offset=None, torrent_hashes=None, + tag=None, **kwargs ): return self._client.torrents_info( @@ -816,6 +859,7 @@ def stalled( limit=limit, offset=offset, torrent_hashes=torrent_hashes, + tag=tag, **kwargs ) @@ -827,6 +871,7 @@ def stalled_uploading( limit=None, offset=None, torrent_hashes=None, + tag=None, **kwargs ): return self._client.torrents_info( @@ -837,6 +882,7 @@ def stalled_uploading( limit=limit, offset=offset, torrent_hashes=torrent_hashes, + tag=tag, **kwargs ) @@ -848,6 +894,7 @@ def stalled_downloading( limit=None, offset=None, torrent_hashes=None, + tag=None, **kwargs ): return self._client.torrents_info( @@ -858,6 +905,7 @@ def stalled_downloading( limit=limit, offset=offset, torrent_hashes=torrent_hashes, + tag=tag, **kwargs ) @@ -897,17 +945,39 @@ def categories(self, v): self.create_category(**v) @Alias("createCategory") - def create_category(self, name=None, save_path=None, **kwargs): + def create_category( + self, + name=None, + save_path=None, + download_path=None, + enable_download_path=None, + **kwargs + ): """Implements :meth:`~TorrentsAPIMixIn.torrents_create_category`""" return self._client.torrents_create_category( - name=name, save_path=save_path, **kwargs + name=name, + save_path=save_path, + download_path=download_path, + enable_download_path=enable_download_path, + **kwargs ) @Alias("editCategory") - def edit_category(self, name=None, save_path=None, **kwargs): + def edit_category( + self, + name=None, + save_path=None, + download_path=None, + enable_download_path=None, + **kwargs + ): """Implements :meth:`~TorrentsAPIMixIn.torrents_edit_category`""" return self._client.torrents_edit_category( - name=name, save_path=save_path, **kwargs + name=name, + save_path=save_path, + download_path=download_path, + enable_download_path=enable_download_path, + **kwargs ) @Alias("removeCategories") @@ -1035,6 +1105,8 @@ def torrents_add( content_layout=None, ratio_limit=None, seeding_time_limit=None, + download_path=None, + use_download_path=None, **kwargs ): """ @@ -1071,6 +1143,8 @@ def torrents_add( :param content_layout: Original, Subfolder, or NoSubfolder to control filesystem structure for content (added in Web API v2.7) :param ratio_limit: share limit as ratio of upload amt over download amt; e.g. 0.5 or 2.0 (added in Web API v2.8.1) :param seeding_time_limit: number of minutes to seed torrent (added in Web API v2.8.1) + :param download_path: location to download torrent content before moving to save_path (added in Web API v2.8.4) + :param use_download_path: whether the download_path should be used...defaults to True if download_path is specified (added in Web API v2.8.4) :return: "Ok." for success and "Fails." for failure """ @@ -1091,6 +1165,10 @@ def torrents_add( is_root_folder = content_layout in {"Subfolder", "Original"} content_layout = None + # default to actually using the specified download path + if use_download_path is None and download_path is not None: + use_download_path = True + data = { "urls": (None, self._list2string(urls, "\n")), "savepath": (None, save_path), @@ -1109,6 +1187,8 @@ def torrents_add( "autoTMM": (None, use_auto_torrent_management), "sequentialDownload": (None, is_sequential_download), "firstLastPiecePrio": (None, is_first_last_piece_priority), + "downloadPath": (None, download_path), + "useDownloadPath": (None, use_download_path), } files_to_send, files_to_close = self._normalize_torrent_files(torrent_files) @@ -1544,6 +1624,7 @@ def torrents_info( limit=None, offset=None, torrent_hashes=None, + tag=None, **kwargs ): """ @@ -1558,6 +1639,7 @@ def torrents_info( :param limit: Limit length of list :param offset: Start of list (if < 0, offset from end of list) :param torrent_hashes: Filter list by hash (separate multiple hashes with a '|') + :param tag: Filter list by tag (empty string means "untagged"; no "tag" param means "any tag"; added in Web API v2.8.3) :return: :class:`TorrentInfoList` - https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-list """ data = { @@ -1568,6 +1650,7 @@ def torrents_info( "limit": limit, "offset": offset, "hashes": self._list2string(torrent_hashes, "|"), + "tag": tag, } return self._post(_name=APINames.Torrents, _method="info", data=data, **kwargs) @@ -1811,6 +1894,50 @@ def torrents_set_location(self, location=None, torrent_hashes=None, **kwargs): } self._post(_name=APINames.Torrents, _method="setLocation", data=data, **kwargs) + @Alias("torrents_setSavePath") + @handle_hashes + @endpoint_introduced("2.8.4", "torrents/setSavePath") + @login_required + def torrents_set_save_path(self, save_path=None, torrent_hashes=None, **kwargs): + """ + Set the Save Path for one or more torrents. (alias: torrents_setSavePath) + + :raises Forbidden403Error: cannot write to directory + :raises Conflict409Error: directory cannot be created + + :param save_path: file path to save torrent contents + :param torrent_hashes: single torrent hash or list of torrent hashes. Or 'all' for all torrents. + """ + data = { + "id": self._list2string(torrent_hashes, "|"), + "path": save_path, + } + self._post(_name=APINames.Torrents, _method="setSavePath", data=data, **kwargs) + + @Alias("torrents_setDownloadPath") + @handle_hashes + @endpoint_introduced("2.8.4", "torrents/setDownloadPath") + @login_required + def torrents_set_download_path( + self, download_path=None, torrent_hashes=None, **kwargs + ): + """ + Set the Download Path for one or more torrents. (alias: torrents_setDownloadPath) + + :raises Forbidden403Error: cannot write to directory + :raises Conflict409Error: directory cannot be created + + :param download_path: file path to save torrent contents before torrent finishes downloading + :param torrent_hashes: single torrent hash or list of torrent hashes. Or 'all' for all torrents. + """ + data = { + "id": self._list2string(torrent_hashes, "|"), + "path": download_path, + } + self._post( + _name=APINames.Torrents, _method="setDownloadPath", data=data, **kwargs + ) + @Alias("torrents_setCategory") @handle_hashes @login_required @@ -1961,7 +2088,14 @@ def torrents_categories(self, **kwargs): @Alias("torrents_createCategory") @login_required - def torrents_create_category(self, name=None, save_path=None, **kwargs): + def torrents_create_category( + self, + name=None, + save_path=None, + download_path=None, + enable_download_path=None, + **kwargs + ): """ Create a new torrent category. (alias: torrents_createCategory) @@ -1971,9 +2105,20 @@ def torrents_create_category(self, name=None, save_path=None, **kwargs): :param name: name for new category :param save_path: location to save torrents for this category + :param download_path: download location for torrents with this category + :param enable_download_path: True or False to enable or disable download path :return: None """ - data = {"category": name, "savePath": save_path} + # default to actually using the specified download path + if enable_download_path is None and download_path is not None: + enable_download_path = True + + data = { + "category": name, + "savePath": save_path, + "downloadPath": download_path, + "downloadPathEnabled": enable_download_path, + } self._post( _name=APINames.Torrents, _method="createCategory", data=data, **kwargs ) @@ -1981,19 +2126,38 @@ def torrents_create_category(self, name=None, save_path=None, **kwargs): @endpoint_introduced("2.1.0", "torrents/editCategory") @Alias("torrents_editCategory") @login_required - def torrents_edit_category(self, name=None, save_path=None, **kwargs): + def torrents_edit_category( + self, + name=None, + save_path=None, + download_path=None, + enable_download_path=None, + **kwargs + ): """ Edit an existing category. (alias: torrents_editCategory) Note: torrents/editCategory not available until web API version 2.1.0 - :raises Conflict409Error: + :raises Conflict409Error: if category name is not valid or unable to create :param name: category to edit :param save_path: new location to save files for this category + :param download_path: download location for torrents with this category + :param enable_download_path: True or False to enable or disable download path :return: None """ - data = {"category": name, "savePath": save_path} + + # default to actually using the specified download path + if enable_download_path is None and download_path is not None: + enable_download_path = True + + data = { + "category": name, + "savePath": save_path, + "downloadPath": download_path, + "downloadPathEnabled": enable_download_path, + } self._post(_name=APINames.Torrents, _method="editCategory", data=data, **kwargs) @Alias("torrents_removeCategories") diff --git a/qbittorrentapi/torrents.pyi b/qbittorrentapi/torrents.pyi index 4d320d95c..5977145ad 100644 --- a/qbittorrentapi/torrents.pyi +++ b/qbittorrentapi/torrents.pyi @@ -197,6 +197,10 @@ class Torrents(ClientCache): setUploadLimit: _ActionForAllTorrents set_location: _ActionForAllTorrents setLocation: _ActionForAllTorrents + set_save_path: _ActionForAllTorrents + setSavePath: _ActionForAllTorrents + set_download_path: _ActionForAllTorrents + setDownloadPath: _ActionForAllTorrents set_category: _ActionForAllTorrents setCategory: _ActionForAllTorrents set_auto_management: _ActionForAllTorrents @@ -367,9 +371,23 @@ class TorrentCategories(ClientCache): def categories(self) -> TorrentCategoriesDictionary: ... @categories.setter def categories(self, v: Iterable[Text]) -> None: ... - def create_category(self, name: Text = None, save_path: Text = None, **kwargs): ... + def create_category( + self, + name: Text = None, + save_path: Text = None, + download_path: Text = None, + enable_download_path: bool = None, + **kwargs + ): ... createCategory = create_category - def edit_category(self, name: Text = None, save_path: Text = None, **kwargs): ... + def edit_category( + self, + name: Text = None, + save_path: Text = None, + download_path: Text = None, + enable_download_path: bool = None, + **kwargs + ): ... editCategory = edit_category def remove_categories(self, categories: Iterable[Text] = None, **kwargs): ... removeCategories = remove_categories @@ -421,6 +439,8 @@ class TorrentsAPIMixIn(Request): content_layout: Literal["Original", "Subfolder", "NoSubFolder"] = None, ratio_limit: Text | float = None, seeding_time_limit: Text | int = None, + download_path: Text = None, + use_download_path: bool = None, **kwargs ) -> Text: ... @staticmethod @@ -566,6 +586,17 @@ class TorrentsAPIMixIn(Request): self, location: Text = None, torrent_hashes: Iterable[Text] = None, **kwargs ) -> None: ... torrents_setLocation = torrents_set_location + def torrents_set_save_path( + self, save_path: Text = None, torrents_hashes: Iterable[Text] = None, **kwargs + ) -> None: ... + torrents_setSavePath = torrents_set_save_path + def torrents_set_download_path( + self, + download_path: Text = None, + torrents_hashes: Iterable[Text] = None, + **kwargs + ) -> None: ... + torrents_setDownloadPath = torrents_set_download_path def torrents_set_category( self, category: Text = None, torrent_hashes: Iterable[Text] = None, **kwargs ) -> None: ... @@ -599,11 +630,21 @@ class TorrentsAPIMixIn(Request): torrents_addPeers = torrents_add_peers def torrents_categories(self, **kwargs) -> TorrentCategoriesDictionary: ... def torrents_create_category( - self, name: Text = None, save_path: Text = None, **kwargs + self, + name: Text = None, + save_path: Text = None, + download_path: Text = None, + enable_download_path: bool = None, + **kwargs ) -> None: ... torrents_createCategory = torrents_create_category def torrents_edit_category( - self, name: Text = None, save_path: Text = None, **kwargs + self, + name: Text = None, + save_path: Text = None, + download_path: Text = None, + enable_download_path: bool = None, + **kwargs ) -> None: ... torrents_editCategory = torrents_edit_category def torrents_remove_categories( diff --git a/setup.py b/setup.py index 55b9c6285..890e46959 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="qbittorrent-api", - version="2021.12.26", + version="2022.1.27", packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), include_package_data=True, install_requires=[ diff --git a/tests/conftest.py b/tests/conftest.py index ed810300a..dbadc78aa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,47 +7,16 @@ from qbittorrentapi import APIConnectionError from qbittorrentapi import Client -from qbittorrentapi.request import Request -qbt_version = "v" + os.environ["QBT_VER"] +from .version_map import api_version_map -api_version_map = { - "v4.1.0": "2.0", - "v4.1.1": "2.0.1", - "v4.1.2": "2.0.2", - "v4.1.3": "2.1", - "v4.1.4": "2.1.1", - "v4.1.5": "2.2", - "v4.1.6": "2.2", - "v4.1.7": "2.2", - "v4.1.8": "2.2", - "v4.1.9": "2.2.1", - "v4.1.9.1": "2.2.1", - "v4.2.0": "2.3", - "v4.2.1": "2.4", - "v4.2.2": "2.4.1", - "v4.2.3": "2.4.1", - "v4.2.4": "2.5", - "v4.2.5": "2.5.1", - "v4.3.0": "2.6", - "v4.3.0.1": "2.6", - "v4.3.1": "2.6.1", - "v4.3.2": "2.7", - "v4.3.3": "2.7", - "v4.3.4.1": "2.8.1", - "v4.3.5": "2.8.2", - "v4.3.6": "2.8.2", - "v4.3.7": "2.8.2", - "v4.3.8": "2.8.2", - "v4.3.9": "2.8.2", - "v4.4.0rc1": "2.8.3", -} +qbt_version = "v" + os.environ["QBT_VER"] BASE_PATH = sys_path[0] _check_limit = 10 _orig_torrent_url = ( - "http://releases.ubuntu.com/21.04/ubuntu-21.04-desktop-amd64.iso.torrent" + "https://releases.ubuntu.com/21.04/ubuntu-21.04-desktop-amd64.iso.torrent" ) _orig_torrent_hash = "64a980abe6e448226bb930ba061592e44c3781a1" @@ -58,11 +27,11 @@ mode="rb", ) as f: torrent1_file = f.read() -torrent1_url = "http://cdimage.ubuntu.com/kubuntu/releases/21.04/release/kubuntu-21.04-desktop-amd64.iso.torrent" +torrent1_url = "https://cdimage.ubuntu.com/kubuntu/releases/21.04/release/kubuntu-21.04-desktop-amd64.iso.torrent" torrent1_filename = torrent1_url.split("/")[-1] torrent1_hash = "d65d07329264aecb2d2be7a6c0e86b6613b2a600" -torrent2_url = "http://cdimage.ubuntu.com/xubuntu/releases/21.04/release/xubuntu-21.04-desktop-amd64.iso.torrent" +torrent2_url = "https://cdimage.ubuntu.com/xubuntu/releases/21.04/release/xubuntu-21.04-desktop-amd64.iso.torrent" torrent2_filename = torrent2_url.split("/")[-1] torrent2_hash = "80d773cbf111e906608077967683a0ffcc3a7668" diff --git a/tests/test_definitions.py b/tests/test_definitions.py index 262e7ef74..a53e65c9e 100644 --- a/tests/test_definitions.py +++ b/tests/test_definitions.py @@ -18,6 +18,7 @@ "allocating", "downloading", "metaDL", + "forcedMetaDL", "pausedDL", "queuedDL", "forcedDL", @@ -31,6 +32,7 @@ downloading_states = ( "downloading", "metaDL", + "forcedMetaDL", "stalledDL", "checkingDL", "pausedDL", diff --git a/tests/test_torrent.py b/tests/test_torrent.py index 535101708..c12bb246d 100644 --- a/tests/test_torrent.py +++ b/tests/test_torrent.py @@ -182,7 +182,54 @@ def test_set_location(api_version, new_torrent, client_func): try: loc = path.expanduser("~/Downloads/3/") getattr(new_torrent, client_func)(loc) - check(lambda: new_torrent.info.save_path, loc) + # qBittorrent may return trailing separators depending on version.... + check( + lambda: new_torrent.info.save_path, + (loc, loc[: len(loc) - 1]), + any=True, + ) + break + except AssertionError as e: + exp = e + if exp: + raise exp + + +@pytest.mark.parametrize("client_func", ("set_save_path", "setSavePath")) +def test_set_save_path(api_version, new_torrent, client_func): + if v(api_version) >= v("2.8.4"): + exp = None + for attempt in range(2): + try: + loc = path.expanduser("~/Downloads/savepath3/") + getattr(new_torrent, client_func)(loc) + # qBittorrent may return trailing separators depending on version.... + check( + lambda: new_torrent.info.save_path, + (loc, loc[: len(loc) - 1]), + any=True, + ) + break + except AssertionError as e: + exp = e + if exp: + raise exp + + +@pytest.mark.parametrize("client_func", ("set_download_path", "setDownloadPath")) +def test_set_download_path(api_version, new_torrent, client_func): + if v(api_version) >= v("2.8.4"): + exp = None + for attempt in range(2): + try: + loc = path.expanduser("~/Downloads/downloadpath3/") + getattr(new_torrent, client_func)(loc) + # qBittorrent may return trailing separators depending on version.... + check( + lambda: new_torrent.info.download_path, + (loc, loc[: len(loc) - 1]), + any=True, + ) break except AssertionError as e: exp = e diff --git a/tests/test_torrents.py b/tests/test_torrents.py index aea69a4ce..d84677b9f 100644 --- a/tests/test_torrents.py +++ b/tests/test_torrents.py @@ -306,6 +306,32 @@ def test_add_options(client, api_version, keep_root_folder, content_layout): check(lambda: torrent.info.seeding_time_limit, 120) +@pytest.mark.parametrize("use_download_path", (None, True, False)) +def test_torrents_add_download_path(client, api_version, use_download_path): + if v(api_version) < v("2.8.4"): + return + + client.torrents_delete(torrent_hashes=root_folder_torrent_hash, delete_files=True) + save_path = path.expanduser("~/down_path_save_path_test") + download_path = path.expanduser("~/down_path_test") + torrent = next( + new_torrent_standalone( + client=client, + torrent_hash=root_folder_torrent_hash, + torrent_files=root_folder_torrent_file, + download_path=download_path, + use_download_path=use_download_path, + test_download_limit=1024, + save_path=save_path, + ) + ) + + if use_download_path is False: + check(lambda: torrent.info.download_path, download_path, negate=True) + else: + check(lambda: torrent.info.download_path, download_path) + + def test_properties(client, orig_torrent): props = client.torrents_properties(torrent_hash=orig_torrent.hash) assert isinstance(props, TorrentPropertiesDictionary) @@ -517,6 +543,18 @@ def test_torrents_info(client, api_version, orig_torrent_hash, client_func): get_func(client, client_func)(torrent_hashes=orig_torrent_hash) +@pytest.mark.parametrize("client_func", ("torrents_info", "torrents.info")) +def test_torrents_info_tag(client, api_version, new_torrent, client_func): + if v(api_version) < v("2.8.3"): + return + tag_name = "tag_filter_name" + client.torrents_add_tags(tags=tag_name, torrent_hashes=new_torrent.hash) + torrents = get_func(client, client_func)( + torrent_hashes=new_torrent.hash, tag=tag_name + ) + assert new_torrent.hash in {t.hash for t in torrents} + + @pytest.mark.parametrize( "client_func", (("torrents_pause", "torrents_resume"), ("torrents.pause", "torrents.resume")), @@ -746,18 +784,83 @@ def test_set_share_limits(client, api_version, client_func, orig_torrent): ) def test_set_location(client, api_version, client_func, new_torrent): if v(api_version) > v("2.0.1"): - home = path.expanduser("~") - # whether the location is writable is only checked after version 2.0.1 - if v(api_version) > v("2.0.1"): - with pytest.raises(Forbidden403Error): - get_func(client, client_func)( - location="/etc/", torrent_hashes=new_torrent.hash - ) + with pytest.raises(Forbidden403Error): + get_func(client, client_func)( + location="/etc/", torrent_hashes=new_torrent.hash + ) + + loc = path.expanduser("~/Downloads/1/") + get_func(client, client_func)(location=loc, torrent_hashes=new_torrent.hash) + # qBittorrent may return trailing separators depending on version.... + check(lambda: new_torrent.info.save_path, (loc, loc[: len(loc) - 1]), any=True) + + +@pytest.mark.parametrize( + "client_func", + ( + "torrents_set_save_path", + "torrents_setSavePath", + "torrents.set_save_path", + "torrents.setSavePath", + ), +) +def test_set_save_path(client, api_version, client_func, new_torrent): + if v(api_version) >= v("2.8.4"): + with pytest.raises(Forbidden403Error): + get_func(client, client_func)( + save_path="/etc/", torrent_hashes=new_torrent.hash + ) + with pytest.raises(Conflict409Error): + get_func(client, client_func)( + save_path="/etc/asdf", torrent_hashes=new_torrent.hash + ) + + loc = path.expanduser("~/Downloads/savepath1/") + get_func(client, client_func)(save_path=loc, torrent_hashes=new_torrent.hash) + # qBittorrent may return trailing separators depending on version.... + check(lambda: new_torrent.info.save_path, (loc, loc[: len(loc) - 1]), any=True) + + else: + with pytest.raises(NotImplementedError): + get_func(client, client_func)( + save_path="/etc/", torrent_hashes=new_torrent.hash + ) + +@pytest.mark.parametrize( + "client_func", + ( + "torrents_set_download_path", + "torrents_setDownloadPath", + "torrents.set_download_path", + "torrents.setDownloadPath", + ), +) +def test_set_download_path(client, api_version, client_func, new_torrent): + if v(api_version) >= v("2.8.4"): + with pytest.raises(Forbidden403Error): + get_func(client, client_func)( + download_path="/etc/", torrent_hashes=new_torrent.hash + ) + with pytest.raises(Conflict409Error): + get_func(client, client_func)( + download_path="/etc/asdf", torrent_hashes=new_torrent.hash + ) + + loc = path.expanduser("~/Downloads/savepath1/") get_func(client, client_func)( - location="%s/Downloads/1/" % home, torrent_hashes=new_torrent.hash + download_path=loc, torrent_hashes=new_torrent.hash + ) + # qBittorrent may return trailing separators depending on version.... + check( + lambda: new_torrent.info.download_path, (loc, loc[: len(loc) - 1]), any=True ) - check(lambda: new_torrent.info.save_path, "%s/Downloads/1/" % home) + + else: + with pytest.raises(NotImplementedError): + get_func(client, client_func)( + save_path="/etc/", torrent_hashes=new_torrent.hash + ) @pytest.mark.parametrize( @@ -901,6 +1004,14 @@ def test_torrents_add_peers(client, api_version, orig_torrent, client_func, peer assert isinstance(p, TorrentsAddPeersDictionary) +def _categories_save_path_key(api_version): + """ + With qBittorrent 4.4.0 (Web API 2.8.4), the key in the category + definition returned changed from savePath to save_path.... + """ + return "save_path" if v("2.8.5") > v(api_version) >= v("2.8.4") else "savePath" + + def test_categories1(client, api_version): if v(api_version) < v("2.1.1"): with pytest.raises(NotImplementedError): @@ -914,11 +1025,12 @@ def test_categories2(client, api_version): with pytest.raises(NotImplementedError): client.torrent_categories.categories else: + save_path_key = _categories_save_path_key(api_version) name = "new_category" - client.torrent_categories.categories = {"name": name, "savePath": "/tmp"} + client.torrent_categories.categories = {"name": name, save_path_key: "/tmp"} assert name in client.torrent_categories.categories - client.torrent_categories.categories = {"name": name, "savePath": "/tmp/new"} - assert client.torrent_categories.categories[name]["savePath"] == "/tmp/new" + client.torrent_categories.categories = {"name": name, save_path_key: "/tmp/new"} + assert client.torrent_categories.categories[name][save_path_key] == "/tmp/new" client.torrents_remove_categories(categories=name) @@ -931,19 +1043,28 @@ def test_categories2(client, api_version): "torrent_categories.createCategory", ), ) -@pytest.mark.parametrize("save_path", (None, "", "/tmp/")) +@pytest.mark.parametrize("filepath", (None, "", "/tmp/")) @pytest.mark.parametrize("name", ("name", "name 1")) +@pytest.mark.parametrize("enable_download_path", (None, True, False)) def test_create_categories( - client, api_version, orig_torrent, client_func, save_path, name + client, api_version, orig_torrent, client_func, filepath, name, enable_download_path ): - extra_kwargs = dict(save_path=save_path) - if v(api_version) < v("2.1.0") and save_path is not None: + save_path = download_path = filepath + if filepath: + save_path += "save" + download_path += "download" + + if v(api_version) < v("2.1.0"): with pytest.raises(NotImplementedError): - get_func(client, client_func)(name=name, save_path=save_path) - extra_kwargs = {} + get_func(client, client_func)(name=name) try: - get_func(client, client_func)(name=name, **extra_kwargs) + get_func(client, client_func)( + name=name, + save_path=save_path, + download_path=download_path, + enable_download_path=enable_download_path, + ) client.torrents_set_category(torrent_hashes=orig_torrent.hash, category=name) check(lambda: orig_torrent.info.category.replace("+", " "), name) if v(api_version) > v("2.1.1"): @@ -952,11 +1073,23 @@ def test_create_categories( name, reverse=True, ) + save_path_key = _categories_save_path_key(api_version) check( - lambda: (cat.savePath for cat in client.torrents_categories().values()), + lambda: [ + cat[save_path_key] for cat in client.torrents_categories().values() + ], save_path or "", reverse=True, ) + if v(api_version) >= v("2.8.4") and enable_download_path is not False: + check( + lambda: [ + cat.get("download_path", "") + for cat in client.torrents_categories().values() + ], + download_path or "", + reverse=True, + ) finally: client.torrents_remove_categories(categories=name) @@ -970,27 +1103,51 @@ def test_create_categories( "torrent_categories.editCategory", ), ) -@pytest.mark.parametrize("save_path", ("", "/tmp/")) +@pytest.mark.parametrize("filepath", ("", "/tmp/")) @pytest.mark.parametrize("name", ("editcategory",)) -def test_edit_category(client, api_version, client_func, save_path, name): - if v(api_version) < v("2.1.0") and save_path is not None: +@pytest.mark.parametrize("enable_download_path", (None, True, False)) +def test_edit_category( + client, api_version, client_func, filepath, name, enable_download_path +): + if v(api_version) < v("2.1.0") and filepath is not None: with pytest.raises(NotImplementedError): - get_func(client, client_func)(name=name, save_path=save_path) + get_func(client, client_func)(name=name, save_path=filepath) if v(api_version) > v("2.1.1"): try: - client.torrents_create_category(name=name, save_path="/tmp/tmp") - get_func(client, client_func)(name=name, save_path=save_path) + client.torrents_create_category( + name=name, save_path="/tmp/savetmp", download_path="/tmp/savetmp" + ) + save_path = filepath + "save/" + download_path = filepath + "down/" + get_func(client, client_func)( + name=name, + save_path=save_path, + download_path=download_path, + enable_download_path=enable_download_path, + ) check( lambda: [n.replace("+", " ") for n in client.torrents_categories()], name, reverse=True, ) + save_path_key = _categories_save_path_key(api_version) check( - lambda: (cat.savePath for cat in client.torrents_categories().values()), + lambda: ( + cat[save_path_key] for cat in client.torrents_categories().values() + ), save_path or "", reverse=True, ) + if v(api_version) > v("2.8.4") and download_path is not False: + check( + lambda: [ + cat.get("download_path", "") + for cat in client.torrents_categories().values() + ], + download_path or "", + reverse=True, + ) finally: client.torrents_remove_categories(categories=name) diff --git a/tests/version_map.py b/tests/version_map.py new file mode 100644 index 000000000..2761897c4 --- /dev/null +++ b/tests/version_map.py @@ -0,0 +1,33 @@ +# A map of qBittorrent versions to their respective API versions + +api_version_map = { + "v4.1.0": "2.0", + "v4.1.1": "2.0.1", + "v4.1.2": "2.0.2", + "v4.1.3": "2.1", + "v4.1.4": "2.1.1", + "v4.1.5": "2.2", + "v4.1.6": "2.2", + "v4.1.7": "2.2", + "v4.1.8": "2.2", + "v4.1.9": "2.2.1", + "v4.1.9.1": "2.2.1", + "v4.2.0": "2.3", + "v4.2.1": "2.4", + "v4.2.2": "2.4.1", + "v4.2.3": "2.4.1", + "v4.2.4": "2.5", + "v4.2.5": "2.5.1", + "v4.3.0": "2.6", + "v4.3.0.1": "2.6", + "v4.3.1": "2.6.1", + "v4.3.2": "2.7", + "v4.3.3": "2.7", + "v4.3.4.1": "2.8.1", + "v4.3.5": "2.8.2", + "v4.3.6": "2.8.2", + "v4.3.7": "2.8.2", + "v4.3.8": "2.8.2", + "v4.3.9": "2.8.2", + "v4.4.0": "2.8.4", +}