From ca56dccee91c30c8a7541f8fea11ecf476f8b490 Mon Sep 17 00:00:00 2001 From: Quentame Date: Mon, 7 Sep 2020 23:26:41 +0200 Subject: [PATCH] Add DownloadStation (#62) * Add DownloadStation * Add test * Add resume, fix create * Add get_config, create destination * Added readme --- README.rst | 28 ++ synology_dsm/api/download_station/__init__.py | 95 ++++++ synology_dsm/api/download_station/task.py | 53 ++++ synology_dsm/synology_dsm.py | 45 +-- tests/__init__.py | 36 ++- tests/api_data/dsm_6/__init__.py | 7 + .../dsm_6/download_station/__init__.py | 1 + .../const_6_download_station_info.py | 24 ++ .../const_6_download_station_task.py | 299 ++++++++++++++++++ tests/test_synology_dsm.py | 33 +- 10 files changed, 589 insertions(+), 32 deletions(-) create mode 100644 synology_dsm/api/download_station/__init__.py create mode 100644 synology_dsm/api/download_station/task.py create mode 100644 tests/api_data/dsm_6/download_station/__init__.py create mode 100644 tests/api_data/dsm_6/download_station/const_6_download_station_info.py create mode 100644 tests/api_data/dsm_6/download_station/const_6_download_station_task.py diff --git a/README.rst b/README.rst index 2c834ee..9c3fc46 100644 --- a/README.rst +++ b/README.rst @@ -126,6 +126,34 @@ The ``SynologyDSM`` class can also ``update()`` all APIs at once. print("Space used: " + str(api.share.share_size(share_uuid, human_readable=True))) print("Recycle Bin Enabled: " + str(api.share.share_recycle_bin(share_uuid))) print("--") + + +Download Station usage +-------------------------- + +.. code-block:: python + + from synology_dsm import SynologyDSM + + api = SynologyDSM("", "", "", "") + + if "SYNO.DownloadStation.Info" in api.apis: + + api.download_station.get_info() + api.download_station.get_config() + + # The download list will be updated after each of the following functions: + # You should have the right on the (default) directory that the download will be saved, or you will get a 403 or 406 error + api.download_station.create("http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4") + api.download_station.pause("dbid_1") + # Like the other function, you can eather pass a str or a list + api.download_station.resume(["dbid_1", "dbid_2"]) + api.download_station.delete("dbid_3") + + # Manual update + api.download_station.update() + + Surveillance Station usage -------------------------- diff --git a/synology_dsm/api/download_station/__init__.py b/synology_dsm/api/download_station/__init__.py new file mode 100644 index 0000000..b11c706 --- /dev/null +++ b/synology_dsm/api/download_station/__init__.py @@ -0,0 +1,95 @@ +"""Synology DownloadStation API wrapper.""" +from .task import SynoDownloadTask + + +class SynoDownloadStation(object): + """An implementation of a Synology DownloadStation.""" + + API_KEY = "SYNO.DownloadStation.*" + INFO_API_KEY = "SYNO.DownloadStation.Info" + TASK_API_KEY = "SYNO.DownloadStation.Task" + + def __init__(self, dsm): + """Initialize a Download Station.""" + self._dsm = dsm + self._tasks_by_id = {} + self.additionals = [ + "detail", + "file", + ] # Can contain: detail, transfer, file, tracker, peer + + def update(self): + """Update tasks from API.""" + list_data = self._dsm.get( + self.TASK_API_KEY, "List", {"additional": ",".join(self.additionals)} + )["data"] + for task_data in list_data["tasks"]: + if task_data["id"] in self._tasks_by_id: + self._tasks_by_id[task_data["id"]].update(task_data) + else: + self._tasks_by_id[task_data["id"]] = SynoDownloadTask(task_data) + + # Global + def get_info(self): + """Return general informations about the Download Station instance.""" + return self._dsm.get(self.INFO_API_KEY, "GetInfo") + + def get_config(self): + """Return configuration about the Download Station instance.""" + return self._dsm.get(self.INFO_API_KEY, "GetConfig") + + # Downloads + def get_all_tasks(self): + """Return a list of tasks.""" + return self._tasks_by_id.values() + + def get_task(self, task_id): + """Return task matching task_id.""" + return self._tasks_by_id[task_id] + + def create(self, uri, unzip_password=None, destination=None): + """Create a new task (uri accepts HTTP/FTP/magnet/ED2K links).""" + res = self._dsm.post( + self.TASK_API_KEY, + "Create", + { + "uri": ",".join(uri) if isinstance(uri, list) else uri, + "unzip_password": unzip_password, + "destination": destination, + }, + ) + self.update() + return res + + def pause(self, task_id): + """Pause a download task.""" + res = self._dsm.get( + self.TASK_API_KEY, + "Pause", + {"id": ",".join(task_id) if isinstance(task_id, list) else task_id}, + ) + self.update() + return res + + def resume(self, task_id): + """Resume a paused download task.""" + res = self._dsm.get( + self.TASK_API_KEY, + "Resume", + {"id": ",".join(task_id) if isinstance(task_id, list) else task_id}, + ) + self.update() + return res + + def delete(self, task_id, force_complete=False): + """Delete a download task.""" + res = self._dsm.get( + self.TASK_API_KEY, + "Delete", + { + "id": ",".join(task_id) if isinstance(task_id, list) else task_id, + "force_complete": force_complete, + }, + ) + self.update() + return res diff --git a/synology_dsm/api/download_station/task.py b/synology_dsm/api/download_station/task.py new file mode 100644 index 0000000..7a07561 --- /dev/null +++ b/synology_dsm/api/download_station/task.py @@ -0,0 +1,53 @@ +"""DownloadStation task.""" + + +class SynoDownloadTask(object): + """An representation of a Synology DownloadStation task.""" + + def __init__(self, data): + """Initialize a Download Station task.""" + self._data = data + + def update(self, data): + """Update the task.""" + self._data = data + + @property + def id(self): + """Return id of the task.""" + return self._data["id"] + + @property + def title(self): + """Return title of the task.""" + return self._data["title"] + + @property + def type(self): + """Return type of the task (bt, nzb, http(s), ftp, emule).""" + return self._data["type"] + + @property + def username(self): + """Return username of the task.""" + return self._data["username"] + + @property + def size(self): + """Return size of the task.""" + return self._data["size"] + + @property + def status(self): + """Return status of the task (waiting, downloading, paused, finishing, finished, hash_checking, seeding, filehosting_waiting, extracting, error).""" + return self._data["status"] + + @property + def status_extra(self): + """Return status_extra of the task.""" + return self._data.get("status_extra") + + @property + def additional(self): + """Return additional data of the task.""" + return self._data["additional"] diff --git a/synology_dsm/synology_dsm.py b/synology_dsm/synology_dsm.py index f8da1a5..71cc4b4 100644 --- a/synology_dsm/synology_dsm.py +++ b/synology_dsm/synology_dsm.py @@ -22,6 +22,7 @@ from .exceptions import ( from .api.core.security import SynoCoreSecurity from .api.core.utilization import SynoCoreUtilization from .api.core.share import SynoCoreShare +from .api.download_station import SynoDownloadStation from .api.dsm.information import SynoDSMInformation from .api.dsm.network import SynoDSMNetwork from .api.storage.storage import SynoStorage @@ -70,6 +71,7 @@ class SynologyDSM(object): self._apis = { "SYNO.API.Info": {"maxVersion": 1, "minVersion": 1, "path": "query.cgi"} } + self._download = None self._information = None self._network = None self._security = None @@ -183,11 +185,9 @@ class SynologyDSM(object): """Handles API GET request.""" return self._request("GET", api, method, params, **kwargs) - def post(self, api, method, params=None, data=None, json=None, **kwargs): + def post(self, api, method, params=None, **kwargs): """Handles API POST request.""" - return self._request( - "POST", api, method, params, data=data, json=json, **kwargs - ) + return self._request("POST", api, method, params, **kwargs) def _request( self, request_method, api, method, params=None, retry_once=True, **kwargs @@ -227,20 +227,6 @@ class SynologyDSM(object): url = self._build_url(api) - # If the request method is POST and the API is SynoCoreShare the params - # to the request body. Used to support the weird Syno use of POST - # to choose what fields to return. See ./api/core/share.py - # for an example. - if request_method == "POST" and api == SynoCoreShare.API_KEY: - body = {} - body.update(params) - body.update(kwargs.pop("data")) - body["mimeType"] = "application/json" - # Request data via POST (excluding FileStation file uploads) - self._debuglog("POST BODY: " + str(body)) - - kwargs["data"] = body - # Request data response = self._execute_request(request_method, url, params, **kwargs) self._debuglog("Request Method: " + request_method) @@ -279,6 +265,13 @@ class SynologyDSM(object): url, params=encoded_params, timeout=self._timeout, **kwargs ) elif method == "POST": + data = {} + data.update(params) + data.update(kwargs.pop("data", {})) + data["mimeType"] = "application/json" + kwargs["data"] = data + self._debuglog("POST data: " + str(data)) + response = self._session.post( url, params=params, timeout=self._timeout, **kwargs ) @@ -308,6 +301,9 @@ class SynologyDSM(object): def update(self, with_information=False, with_network=False): """Updates the various instanced modules.""" + if self._download: + self._download.update() + if self._information and with_information: self._information.update() @@ -337,6 +333,9 @@ class SynologyDSM(object): if hasattr(self, "_" + api): setattr(self, "_" + api, None) return True + if api == SynoDownloadStation.API_KEY: + self._download = None + return True if api == SynoCoreSecurity.API_KEY: self._security = None return True @@ -352,6 +351,9 @@ class SynologyDSM(object): if api == SynoSurveillanceStation.API_KEY: self._surveillance = None return True + if isinstance(api, SynoDownloadStation): + self._download = None + return True if isinstance(api, SynoCoreSecurity): self._security = None return True @@ -370,6 +372,13 @@ class SynologyDSM(object): return True return False + @property + def download_station(self): + """Gets NAS DownloadStation.""" + if not self._download: + self._download = SynoDownloadStation(self) + return self._download + @property def information(self): """Gets NAS informations.""" diff --git a/tests/__init__.py b/tests/__init__.py index 700ece0..fc737f1 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -10,6 +10,7 @@ from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.dsm.network import SynoDSMNetwork +from synology_dsm.api.download_station import SynoDownloadStation from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.core.share import SynoCoreShare from synology_dsm.api.surveillance_station import SynoSurveillanceStation @@ -44,6 +45,9 @@ from .api_data.dsm_6 import ( DSM_6_SURVEILLANCE_STATION_CAMERA_LIST, DSM_6_SURVEILLANCE_STATION_HOME_MODE_GET_INFO, DSM_6_SURVEILLANCE_STATION_HOME_MODE_SWITCH, + DSM_6_DOWNLOAD_STATION_INFO_INFO, + DSM_6_DOWNLOAD_STATION_INFO_CONFIG, + DSM_6_DOWNLOAD_STATION_TASK_LIST, ) from .api_data.dsm_5 import ( DSM_5_API_INFO, @@ -193,24 +197,32 @@ class SynologyDSMMock(SynologyDSM): if not self._session_id: return ERROR_INSUFFICIENT_USER_PRIVILEGE + if SynoCoreSecurity.API_KEY in url: + if self.error: + return DSM_6_CORE_SECURITY_UPDATE_OUTOFDATE + return API_SWITCHER[self.dsm_version]["CORE_SECURITY"] + + if SynoCoreShare.API_KEY in url: + return API_SWITCHER[self.dsm_version]["CORE_SHARE"] + + if SynoCoreUtilization.API_KEY in url: + if self.error: + return DSM_6_CORE_UTILIZATION_ERROR_1055 + return API_SWITCHER[self.dsm_version]["CORE_UTILIZATION"] + if SynoDSMInformation.API_KEY in url: return API_SWITCHER[self.dsm_version]["DSM_INFORMATION"] if SynoDSMNetwork.API_KEY in url: return API_SWITCHER[self.dsm_version]["DSM_NETWORK"] - if SynoCoreShare.API_KEY in url: - return API_SWITCHER[self.dsm_version]["CORE_SHARE"] - - if SynoCoreSecurity.API_KEY in url: - if self.error: - return DSM_6_CORE_SECURITY_UPDATE_OUTOFDATE - return API_SWITCHER[self.dsm_version]["CORE_SECURITY"] - - if SynoCoreUtilization.API_KEY in url: - if self.error: - return DSM_6_CORE_UTILIZATION_ERROR_1055 - return API_SWITCHER[self.dsm_version]["CORE_UTILIZATION"] + if SynoDownloadStation.TASK_API_KEY in url: + if "GetInfo" in url: + return DSM_6_DOWNLOAD_STATION_INFO_INFO + if "GetConfig" in url: + return DSM_6_DOWNLOAD_STATION_INFO_CONFIG + if "List" in url: + return DSM_6_DOWNLOAD_STATION_TASK_LIST if SynoStorage.API_KEY in url: return API_SWITCHER[self.dsm_version]["STORAGE_STORAGE"][ diff --git a/tests/api_data/dsm_6/__init__.py b/tests/api_data/dsm_6/__init__.py index 88747c0..aacc902 100644 --- a/tests/api_data/dsm_6/__init__.py +++ b/tests/api_data/dsm_6/__init__.py @@ -13,6 +13,13 @@ from .core.const_6_core_security import ( DSM_6_CORE_SECURITY, DSM_6_CORE_SECURITY_UPDATE_OUTOFDATE, ) +from .download_station.const_6_download_station_info import ( + DSM_6_DOWNLOAD_STATION_INFO_INFO, + DSM_6_DOWNLOAD_STATION_INFO_CONFIG, +) +from .download_station.const_6_download_station_task import ( + DSM_6_DOWNLOAD_STATION_TASK_LIST, +) from .dsm.const_6_dsm_info import DSM_6_DSM_INFORMATION from .dsm.const_6_dsm_network import DSM_6_DSM_NETWORK from .storage.const_6_storage_storage import ( diff --git a/tests/api_data/dsm_6/download_station/__init__.py b/tests/api_data/dsm_6/download_station/__init__.py new file mode 100644 index 0000000..fb3ebb2 --- /dev/null +++ b/tests/api_data/dsm_6/download_station/__init__.py @@ -0,0 +1 @@ +"""DSM 6 SYNO.DownloadStation.* datas.""" diff --git a/tests/api_data/dsm_6/download_station/const_6_download_station_info.py b/tests/api_data/dsm_6/download_station/const_6_download_station_info.py new file mode 100644 index 0000000..9ef80a7 --- /dev/null +++ b/tests/api_data/dsm_6/download_station/const_6_download_station_info.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +"""DSM 6 SYNO.DownloadStation.Info data.""" + +DSM_6_DOWNLOAD_STATION_INFO_INFO = { + "data": {"is_manager": True, "version": 3543, "version_string": "3.8-3543"}, + "success": True, +} + +DSM_6_DOWNLOAD_STATION_INFO_CONFIG = { + "data": { + "bt_max_download": 0, + "bt_max_upload": 800, + "default_destination": "downloads", + "emule_default_destination": None, + "emule_enabled": False, + "emule_max_download": 0, + "emule_max_upload": 20, + "ftp_max_download": 0, + "http_max_download": 0, + "nzb_max_download": 0, + "unzip_service_enabled": False, + }, + "success": True, +} diff --git a/tests/api_data/dsm_6/download_station/const_6_download_station_task.py b/tests/api_data/dsm_6/download_station/const_6_download_station_task.py new file mode 100644 index 0000000..1c7b717 --- /dev/null +++ b/tests/api_data/dsm_6/download_station/const_6_download_station_task.py @@ -0,0 +1,299 @@ +# -*- coding: utf-8 -*- +"""DSM 6 SYNO.DownloadStation.Task data.""" + +DSM_6_DOWNLOAD_STATION_TASK_LIST = { + "data": { + "offset": 0, + "tasks": [ + { + "additional": { + "detail": { + "completed_time": 0, + "connected_leechers": 0, + "connected_peers": 0, + "connected_seeders": 0, + "create_time": 1550089068, + "destination": "Folder/containing/downloads", + "seedelapsed": 0, + "started_time": 1592549339, + "total_peers": 0, + "total_pieces": 1239, + "unzip_password": "", + "uri": "magnet:?xt=urn:btih:1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD&dn=1234", + "waiting_seconds": 0, + }, + "file": [ + { + "filename": "INFO.nfo", + "index": 0, + "priority": "low", + "size": 1335, + "size_downloaded": 0, + "wanted": True, + }, + { + "filename": "My super movie 2 2022.mkv", + "index": 1, + "priority": "normal", + "size": 1591087515, + "size_downloaded": 0, + "wanted": True, + }, + { + "filename": "My super movie 2 sample.mkv", + "index": 2, + "priority": "normal", + "size": 2754524, + "size_downloaded": 0, + "wanted": False, + }, + { + "filename": "My super movie 2021.mkv", + "index": 3, + "priority": "normal", + "size": 1155085341, + "size_downloaded": 0, + "wanted": True, + }, + { + "filename": "My super movie 2021 sample.mkv", + "index": 4, + "priority": "normal", + "size": 4359701, + "size_downloaded": 0, + "wanted": False, + }, + { + "filename": "My super movie 3 2023.mkv", + "index": 5, + "priority": "normal", + "size": 1288819263, + "size_downloaded": 0, + "wanted": True, + }, + { + "filename": "My super movie 3 sample.mkv", + "index": 6, + "priority": "normal", + "size": 3077684, + "size_downloaded": 0, + "wanted": False, + }, + { + "filename": "My super movie 4 2031.mkv", + "index": 7, + "priority": "normal", + "size": 1149397942, + "size_downloaded": 0, + "wanted": True, + }, + { + "filename": "My super movie 4 sample.mkv", + "index": 8, + "priority": "normal", + "size": 2023179, + "size_downloaded": 0, + "wanted": False, + }, + ], + }, + "id": "dbid_86", + "size": 5196586484, + "status": "downloading", + "title": "My super movie Complete 2021-2031", + "type": "bt", + "username": "Test_useR", + }, + { + "additional": { + "detail": { + "completed_time": 0, + "connected_leechers": 0, + "connected_peers": 0, + "connected_seeders": 0, + "create_time": 1551214114, + "destination": "Folder/containing/downloads", + "seedelapsed": 0, + "started_time": 1592549348, + "total_peers": 0, + "total_pieces": 948, + "unzip_password": "", + "uri": "magnet:?xt=urn:btih:1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD&dn=1234", + "waiting_seconds": 0, + }, + "file": [ + { + "filename": "Blade Swipper 1984.mkv", + "index": 0, + "priority": "normal", + "size": 1986298376, + "size_downloaded": 1602519560, + "wanted": True, + } + ], + }, + "id": "dbid_164", + "size": 1986298376, + "status": "downloading", + "title": "Blade Swipper 1984.mkv", + "type": "bt", + "username": "Test_useR", + }, + { + "additional": { + "detail": { + "completed_time": 0, + "connected_leechers": 0, + "connected_peers": 50, + "connected_seeders": 50, + "create_time": 1585435581, + "destination": "Folder/containing/downloads", + "seedelapsed": 0, + "started_time": 1592549349, + "total_peers": 0, + "total_pieces": 0, + "unzip_password": "", + "uri": "magnet:?xt=urn:btih:1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD&dn=1234", + "waiting_seconds": 0, + }, + "file": [], + }, + "id": "dbid_486", + "size": 0, + "status": "downloading", + "title": "The falling State", + "type": "bt", + "username": "Test_useR", + }, + { + "additional": { + "detail": { + "completed_time": 0, + "connected_leechers": 0, + "connected_peers": 1, + "connected_seeders": 1, + "create_time": 1591562665, + "destination": "Folder/containing/downloads", + "seedelapsed": 0, + "started_time": 1591563597, + "total_peers": 0, + "total_pieces": 1494, + "unzip_password": "", + "uri": "magnet:?xt=urn:btih:1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD&dn=1234", + "waiting_seconds": 0, + } + }, + "id": "dbid_518", + "size": 391580448, + "status": "paused", + "title": "Welcome to the North.mkv", + "type": "bt", + "username": "Test_useR", + }, + { + "additional": { + "detail": { + "completed_time": 1591565351, + "connected_leechers": 0, + "connected_peers": 0, + "connected_seeders": 0, + "create_time": 1591563606, + "destination": "Folder/containing/downloads", + "seedelapsed": 172800, + "started_time": 1592601577, + "total_peers": 0, + "total_pieces": 5466, + "unzip_password": "", + "uri": "magnet:?xt=urn:btih:1234ABCD1234ABCD1234ABCD1234ABCD1234ABCD&dn=1234", + "waiting_seconds": 0, + } + }, + "id": "dbid_522", + "size": 5731285821, + "status": "finished", + "title": "Birds of Pokémon.mkv", + "type": "bt", + "username": "Test_useR", + }, + { + "additional": { + "detail": { + "completed_time": 1591566799, + "connected_leechers": 0, + "connected_peers": 0, + "connected_seeders": 0, + "create_time": 1591566523, + "destination": "Folder/containing/downloads", + "seedelapsed": 0, + "started_time": 1591566696, + "total_peers": 0, + "total_pieces": 0, + "unzip_password": "", + "uri": "https://1fichier.com/?1234ABCD1234ABCD1234&af=22123", + "waiting_seconds": 0, + } + }, + "id": "dbid_531", + "size": 2811892495, + "status": "finished", + "title": "1234ABCD1234ABCD1234", + "type": "https", + "username": "Test_useR", + }, + { + "additional": { + "detail": { + "completed_time": 0, + "connected_leechers": 0, + "connected_peers": 0, + "connected_seeders": 0, + "create_time": 1591566903, + "destination": "Folder/containing/downloads", + "seedelapsed": 0, + "started_time": 0, + "total_peers": 0, + "total_pieces": 0, + "unzip_password": "", + "uri": "https://1fichier.com/?123ABC123ABC123ABC12", + "waiting_seconds": 0, + } + }, + "id": "dbid_533", + "size": 0, + "status": "error", + "status_extra": {"error_detail": "unknown"}, + "title": "?123ABC123ABC123ABC12", + "type": "https", + "username": "Test_useR", + }, + { + "additional": { + "detail": { + "completed_time": 0, + "connected_leechers": 0, + "connected_peers": 0, + "connected_seeders": 0, + "create_time": 1592605687, + "destination": "Folder/containing/downloads", + "seedelapsed": 0, + "started_time": 1592605731, + "total_peers": 0, + "total_pieces": 0, + "unzip_password": "", + "uri": "https://1fichier.com/?123ABC123ABC123ABC12", + "waiting_seconds": 0, + } + }, + "id": "dbid_549", + "size": 0, + "status": "error", + "status_extra": {"error_detail": "broken_link"}, + "title": "123ABC123ABC123ABC12", + "type": "https", + "username": "Test_useR", + }, + ], + "total": 8, + }, + "success": True, +} diff --git a/tests/test_synology_dsm.py b/tests/test_synology_dsm.py index 360c047..4127222 100644 --- a/tests/test_synology_dsm.py +++ b/tests/test_synology_dsm.py @@ -698,8 +698,37 @@ class TestSynologyDSM(TestCase): assert self.api.storage.disk_below_remain_life_thr("test_disk") is None assert self.api.storage.disk_temp("test_disk") is None - def test_surveillance_camera(self): - """Test surveillance.""" + def test_download_station(self): + """Test DownloadStation.""" + assert self.api.download_station + assert not self.api.download_station.get_all_tasks() + + assert self.api.download_station.get_info() + assert self.api.download_station.get_config() + self.api.download_station.update() + assert self.api.download_station.get_all_tasks() + assert len(self.api.download_station.get_all_tasks()) == 8 + + # BT DL + assert self.api.download_station.get_task("dbid_86").status == "downloading" + assert not self.api.download_station.get_task("dbid_86").status_extra + assert self.api.download_station.get_task("dbid_86").type == "bt" + assert self.api.download_station.get_task("dbid_86").additional.get("file") + assert ( + len(self.api.download_station.get_task("dbid_86").additional.get("file")) + == 9 + ) + + # HTTPS error + assert self.api.download_station.get_task("dbid_549").status == "error" + assert ( + self.api.download_station.get_task("dbid_549").status_extra["error_detail"] + == "broken_link" + ) + assert self.api.download_station.get_task("dbid_549").type == "https" + + def test_surveillance_station(self): + """Test SurveillanceStation.""" self.api.with_surveillance = True assert self.api.surveillance_station assert not self.api.surveillance_station.get_all_cameras()