From cb2279ede140617f6f38bbb5cdc076c4cfe770d6 Mon Sep 17 00:00:00 2001 From: Pierre <397503+bemble@users.noreply.github.com> Date: Sat, 11 May 2024 12:22:38 +0000 Subject: [PATCH 1/5] chore(docker): move entrypoint --- Dockerfile | 5 ++++- server/entrypoint.sh => entrypoint.sh | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) rename server/entrypoint.sh => entrypoint.sh (90%) diff --git a/Dockerfile b/Dockerfile index 84c5e21..f3fee86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,9 @@ RUN chmod +x /app/server/entrypoint.sh WORKDIR /app/server RUN pip install -r requirements.txt -ENTRYPOINT ["/app/server/entrypoint.sh"] + +WORKDIR /app +COPY entrypoint.sh /app/entrypoint.sh +ENTRYPOINT ["/app/entrypoint.sh"] EXPOSE 8765 \ No newline at end of file diff --git a/server/entrypoint.sh b/entrypoint.sh similarity index 90% rename from server/entrypoint.sh rename to entrypoint.sh index f0e3567..1e46bd2 100755 --- a/server/entrypoint.sh +++ b/entrypoint.sh @@ -1,5 +1,7 @@ #!/bin/sh +cd server + if [ -d ".venv" ] then source ".venv/bin/activate" From 962057aa609c691312db559edcff98ec7da6baa5 Mon Sep 17 00:00:00 2001 From: Pierre <397503+bemble@users.noreply.github.com> Date: Sat, 11 May 2024 12:57:25 +0000 Subject: [PATCH 2/5] feat(server): add aria2 is_connected --- Dockerfile | 3 +- server/holerr/core/config_models.py | 11 ++- server/holerr/downloaders/__init__.py | 6 +- server/holerr/downloaders/aria2_jsonrpc.py | 76 +++++++++++++++++++ .../downloaders/aria2_jsonrpc_models.py | 18 +++++ .../downloaders/synology_download_station.py | 3 - 6 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 server/holerr/downloaders/aria2_jsonrpc.py create mode 100644 server/holerr/downloaders/aria2_jsonrpc_models.py diff --git a/Dockerfile b/Dockerfile index f3fee86..4c13d48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,13 +18,14 @@ COPY --from=front-builder /app/public /app/public # copy server files COPY ./server /app/server -RUN chmod +x /app/server/entrypoint.sh WORKDIR /app/server RUN pip install -r requirements.txt WORKDIR /app COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + ENTRYPOINT ["/app/entrypoint.sh"] EXPOSE 8765 \ No newline at end of file diff --git a/server/holerr/core/config_models.py b/server/holerr/core/config_models.py index 3a34611..652cc37 100644 --- a/server/holerr/core/config_models.py +++ b/server/holerr/core/config_models.py @@ -94,13 +94,22 @@ class SynologyDownloadStation(Model): def dump_secret(self, v, info): return self._dump_secret(v, info) +class Aria2JsonRpc(Model): + endpoint: str + secret: Optional[SecretStr] + + @field_serializer("secret") + def dump_secret(self, v, info): + return self._dump_secret(v, info) + class Downloader(Model): synology_download_station: Optional[SynologyDownloadStation] = None + aria2_jsonrpc: Optional[Aria2JsonRpc] = None @model_validator(mode="after") def verify_any_of(self): - if not self.synology_download_station: + if not (self.synology_download_station or self.aria2_jsonrpc): raise ValidationError("A downloader needs to be set.") return self diff --git a/server/holerr/downloaders/__init__.py b/server/holerr/downloaders/__init__.py index ba6cc86..63b6222 100644 --- a/server/holerr/downloaders/__init__.py +++ b/server/holerr/downloaders/__init__.py @@ -7,8 +7,10 @@ def __init__(self) -> None: def update(self): if config.downloader.synology_download_station: from .synology_download_station import SynologyDownloadStation - - self._downloader = SynologyDownloadStation(config.downloader.synology_download_station) + self._downloader = SynologyDownloadStation() + elif config.downloader.aria2_jsonrpc: + from .aria2_jsonrpc import Aria2JsonRpc + self._downloader = Aria2JsonRpc() else: self._downloader = None diff --git a/server/holerr/downloaders/aria2_jsonrpc.py b/server/holerr/downloaders/aria2_jsonrpc.py new file mode 100644 index 0000000..e9e1018 --- /dev/null +++ b/server/holerr/downloaders/aria2_jsonrpc.py @@ -0,0 +1,76 @@ +from .downloader import Downloader +from holerr.core import config +from holerr.core.log import Log +from .aria2_jsonrpc_models import Status, StatusResult +from holerr.core.config_models import Preset +from holerr.core.exceptions import HttpRequestException + +from typing import Any +import requests +from pathlib import Path +import urllib +import json +import uuid + +log = Log.get_logger(__name__) + + +class Aria2JsonRpc(Downloader): + def __init__(self): + # TODO: handle websocket when endpoints starts with ws:// or wss:// + pass + + def get_id(self) -> str: + return "aria2_jsonrpc" + + def get_name(self) -> str: + return "Aria2 JSON-RPC" + + def is_connected(self) -> bool: + try: + self._get_global_status() + except Exception: + return False + return True + + def add_download(self, uri: str, title: str, preset: Preset) -> str: + pass + + def get_task_status(self, id: str) -> tuple[str, int]: + pass + + def delete_download(self, id: str): + pass + + def _call(self, payload:dict[str, Any]): + aria2_cfg = config.downloader.aria2_jsonrpc + + headers = { + "Content-Type": "application/json", + } + + if aria2_cfg.secret is not None: + if "params" not in payload: + payload["params"] = [] + payload["params"].insert(0, f"token:{aria2_cfg.secret.get_secret_value()}") + + if "id" not in payload: + payload["id"] = Aria2JsonRpc.compute_call_id() + if "jsonrpc" not in payload: + payload["jsonrpc"] = "2.0" + + data = json.dumps(payload) + return requests.request("POST", config.downloader.aria2_jsonrpc.endpoint, headers=headers, data=data) + + def _get_global_status(self) -> StatusResult: + payload = { + "method": "aria2.getGlobalStat", + } + res = self._call(payload) + if res.status_code != 200: + raise HttpRequestException("Error while getting global status", res.status_code) + return Status(**res.json()).result + + @staticmethod + def compute_call_id() -> str: + return f"holerr.{str(uuid.uuid4())}" diff --git a/server/holerr/downloaders/aria2_jsonrpc_models.py b/server/holerr/downloaders/aria2_jsonrpc_models.py new file mode 100644 index 0000000..ffe30c2 --- /dev/null +++ b/server/holerr/downloaders/aria2_jsonrpc_models.py @@ -0,0 +1,18 @@ +from typing import Optional, Any +from pydantic import BaseModel + + +class Aria2Base(BaseModel): + id: str + jsonrpc: str + +class StatusResult(BaseModel): + downloadSpeed: str + numActive: str + numStopped: str + numStoppedTotal: str + numWaiting: str + uploadSpeed: str + +class Status(Aria2Base): + result: StatusResult \ No newline at end of file diff --git a/server/holerr/downloaders/synology_download_station.py b/server/holerr/downloaders/synology_download_station.py index d6911ca..ed5f218 100644 --- a/server/holerr/downloaders/synology_download_station.py +++ b/server/holerr/downloaders/synology_download_station.py @@ -14,9 +14,6 @@ class SynologyDownloadStation(Downloader): - def __init__(self, config): - pass - def get_id(self) -> str: return "synology_download_station" From 221e1aa9c653a50149f005f69dde56aa304b3ea5 Mon Sep 17 00:00:00 2001 From: Pierre <397503+bemble@users.noreply.github.com> Date: Sat, 11 May 2024 20:38:01 +0000 Subject: [PATCH 3/5] feat(server): add aria2 add_download, get_task_status & delete_download --- README.md | 18 +++++- server/holerr/downloaders/aria2_jsonrpc.py | 59 +++++++++++++++---- .../downloaders/aria2_jsonrpc_models.py | 44 +++++++++++++- server/holerr/downloaders/downloader.py | 10 ++++ .../downloaders/synology_download_station.py | 5 +- server/holerr/tasks/tasks/downloader.py | 6 +- 6 files changed, 119 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 5c3ace7..98243e6 100644 --- a/README.md +++ b/README.md @@ -67,18 +67,24 @@ debrider: downloader: # Synology synology_download_station: - # Your Synology endpoint (example: "http://192.168.1.1:5000") - endpoint: synology endpoint # string + # Your Synology endpoint + endpoint: synology endpoint # string (example: "http://192.168.1.1:5000") # DSM username (this user must not have 2FA) username: dsm user # string # DSM password password: use password # string + # Aria2 + aria2_jsonrpc: + # Your aria2 JSON-RPC endpoint (http) + endpoint: aria2 endpoint # string (example: "http://192.168.1.1:6000") + # Aria2 JSONRPC secret + secret: aria2 secret # optional, string # Presets list presets: - name: Downloads # string # Directory that holerr will watch watch_dir: holes/downloads # string - # Downloader output directory + # Downloader output directory, relative to the downloader output_dir: Downloads # string # Whether the file should be downloaded in a subdoctory or not create_sub_dir: false # optional, boolean @@ -88,6 +94,8 @@ presets: min_file_size: # human readbale string, example: 3.0B, 12KB, 432.2MB, 4.5GB, 1TB ``` +:warning: currently, you can only have one debrider and one downloader. + ### Synology specifics You should have a user dedicated to this. Create a user (_Configuration_ > _Users and groups_), for this project it only needs access to DownloadStation application and write/read access to the output directories. @@ -102,6 +110,10 @@ Before being able to start a download in Download Station you **must**: - log out - remove access to DSM for this user (_Configuration_ > _Users and groups_ > _[the user], edit_ > _Applications_ > _DSM_) +### Aria2 specifics + +aria2 does not push any progress event over websocket, it has to be pulled, so for now there's real advantage to implement websocket connection. + ## Development ### Folder structure diff --git a/server/holerr/downloaders/aria2_jsonrpc.py b/server/holerr/downloaders/aria2_jsonrpc.py index e9e1018..2004dac 100644 --- a/server/holerr/downloaders/aria2_jsonrpc.py +++ b/server/holerr/downloaders/aria2_jsonrpc.py @@ -1,14 +1,13 @@ from .downloader import Downloader from holerr.core import config from holerr.core.log import Log -from .aria2_jsonrpc_models import Status, StatusResult +from .aria2_jsonrpc_models import Aria2TaskStatus, GlobalStatus, GlobalStatusResult, TaskStatus, AddUriResult +from .downloader_models import DownloadStatus from holerr.core.config_models import Preset from holerr.core.exceptions import HttpRequestException from typing import Any import requests -from pathlib import Path -import urllib import json import uuid @@ -16,10 +15,6 @@ class Aria2JsonRpc(Downloader): - def __init__(self): - # TODO: handle websocket when endpoints starts with ws:// or wss:// - pass - def get_id(self) -> str: return "aria2_jsonrpc" @@ -34,13 +29,44 @@ def is_connected(self) -> bool: return True def add_download(self, uri: str, title: str, preset: Preset) -> str: - pass + payload = { + "method": "aria2.addUri", + "params": [[uri]] + } + + if preset.output_dir is not None: + destination = preset.output_dir + if preset.create_sub_dir: + destination += "/" + Downloader.get_sub_folder_name(title) + + payload["params"].append({"dir": destination}) + + res = self._call(payload) + if res.status_code != 200: + raise HttpRequestException("Could not add download " + res, res.status_code) + return AddUriResult(**res.json()).result def get_task_status(self, id: str) -> tuple[str, int]: - pass + payload = { + "method": "aria2.tellStatus", + "params": [id] + } + res = self._call(payload) + if res.status_code != 200: + raise HttpRequestException("Error while getting task status", res.status_code) + status_data = TaskStatus(**res.json()).result + return status_data.status, int(status_data.completedLength) + def delete_download(self, id: str): - pass + payload = { + "method": "aria2.forceRemove", + "params": [id] + } + res = self._call(payload) + if res.status_code != 200: + log.debug(res) + raise HttpRequestException(f"Could not delete download {id}", res.status_code) def _call(self, payload:dict[str, Any]): aria2_cfg = config.downloader.aria2_jsonrpc @@ -62,15 +88,24 @@ def _call(self, payload:dict[str, Any]): data = json.dumps(payload) return requests.request("POST", config.downloader.aria2_jsonrpc.endpoint, headers=headers, data=data) - def _get_global_status(self) -> StatusResult: + def _get_global_status(self) -> GlobalStatusResult: payload = { "method": "aria2.getGlobalStat", } res = self._call(payload) if res.status_code != 200: raise HttpRequestException("Error while getting global status", res.status_code) - return Status(**res.json()).result + return GlobalStatus(**res.json()).result @staticmethod def compute_call_id() -> str: return f"holerr.{str(uuid.uuid4())}" + + def to_download_status(self, status: str) -> str: + if status ==Aria2TaskStatus["ACTIVE"]: + return DownloadStatus["DOWNLOADING"] + if status == Aria2TaskStatus["COMPLETE"]: + return DownloadStatus["FINISHED"] + if status == Aria2TaskStatus["REMOVED"]: + return DownloadStatus["ERROR"] + return status diff --git a/server/holerr/downloaders/aria2_jsonrpc_models.py b/server/holerr/downloaders/aria2_jsonrpc_models.py index ffe30c2..66738bd 100644 --- a/server/holerr/downloaders/aria2_jsonrpc_models.py +++ b/server/holerr/downloaders/aria2_jsonrpc_models.py @@ -1,12 +1,20 @@ from typing import Optional, Any from pydantic import BaseModel +Aria2TaskStatus = { + "WAITING": "waiting", + "ACTIVE": "active", + "PAUSED": "paused", + "COMPLETE": "complete", + "REMOVED": "removed", + "ERROR": "error", +} class Aria2Base(BaseModel): id: str jsonrpc: str -class StatusResult(BaseModel): +class GlobalStatusResult(BaseModel): downloadSpeed: str numActive: str numStopped: str @@ -14,5 +22,35 @@ class StatusResult(BaseModel): numWaiting: str uploadSpeed: str -class Status(Aria2Base): - result: StatusResult \ No newline at end of file +class GlobalStatus(Aria2Base): + result: GlobalStatusResult + + +class File(BaseModel): + completedLength: str + index: str + length: str + path: str + selected: str + uris: list[Any] + +class TaskStatusResult(BaseModel): + bitfield: str + completedLength: str + connections: str + dir: str + downloadSpeed: str + files: list[File] + gid: str + numPieces: str + pieceLength: str + status: str + totalLength: str + uploadLength: str + uploadSpeed: str + +class TaskStatus(Aria2Base): + result: TaskStatusResult + +class AddUriResult(BaseModel): + result: str \ No newline at end of file diff --git a/server/holerr/downloaders/downloader.py b/server/holerr/downloaders/downloader.py index 327b0f5..42c8bdf 100644 --- a/server/holerr/downloaders/downloader.py +++ b/server/holerr/downloaders/downloader.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from holerr.core.config_models import Preset +from pathlib import Path class Downloader(ABC): @abstractmethod @@ -27,3 +28,12 @@ def get_task_status(self, id: str) -> tuple[str, int]: @abstractmethod def delete_download(self, id: str): pass + + @abstractmethod + # Return the download status from the downloader specific status + def to_download_status(self, status: str) -> str: + pass + + @staticmethod + def get_sub_folder_name(name: str) -> str: + return Path(name).stem \ No newline at end of file diff --git a/server/holerr/downloaders/synology_download_station.py b/server/holerr/downloaders/synology_download_station.py index ed5f218..cd80874 100644 --- a/server/holerr/downloaders/synology_download_station.py +++ b/server/holerr/downloaders/synology_download_station.py @@ -182,6 +182,5 @@ def _create_output_dir(self, parent: str, name: str): log.debug(res.request.url) raise Exception("Error while creating folder, code: " + str(obj.error.code)) - @staticmethod - def get_sub_folder_name(name: str) -> str: - return Path(name).stem + def to_download_status(self, status: str) -> str: + return status diff --git a/server/holerr/tasks/tasks/downloader.py b/server/holerr/tasks/tasks/downloader.py index 04859b8..d9ee6f5 100644 --- a/server/holerr/tasks/tasks/downloader.py +++ b/server/holerr/tasks/tasks/downloader.py @@ -20,13 +20,15 @@ def handle_download(self, download: Download): total_bytes_downloaded = 0 for task in download.downloader_tasks: try: - [status, size_downloaded] = downloader.get_task_status(task.id) + [raw_status, size_downloaded] = downloader.get_task_status(task.id) + status = downloader.to_download_status(raw_status) task.status = DownloaderRepository.downloader_status_to_download_status( status ) task.bytes_downloaded = size_downloaded total_bytes_downloaded += size_downloaded - except Exception: + except Exception as e: + log.debug(f"Error while getting task status, {e}") task.status = DownloadStatus["ERROR_DOWNLOADER"] download_status = download.downloader_tasks[0].status From 8154b17aebbcf3fc0d6d7909eefd86e4c4fcdf98 Mon Sep 17 00:00:00 2001 From: Pierre <397503+bemble@users.noreply.github.com> Date: Sat, 11 May 2024 21:07:01 +0000 Subject: [PATCH 4/5] feat(front): add aria2 configuration --- front/src/i18n/en.json | 2 + front/src/i18n/fr.json | 2 + front/src/models/configuration.type.ts | 8 +- front/src/pages/Settings/Settings.tsx | 102 ++++++++++++++++-- server/holerr/api/routers/routers_models.py | 5 + .../downloaders/synology_download_station.py | 2 +- 6 files changed, 112 insertions(+), 9 deletions(-) diff --git a/front/src/i18n/en.json b/front/src/i18n/en.json index 918c7df..eedf9ef 100644 --- a/front/src/i18n/en.json +++ b/front/src/i18n/en.json @@ -49,6 +49,7 @@ "settings": { "title": "Settings", "language": "Language", + "configuration_downloader": "Downloader", "configuration_subtitle": "Configuration", "configuration_debug": "Debug", "configuration_base_path": "Base path", @@ -57,6 +58,7 @@ "configuration_endpoint": "Endpoint", "configuration_username": "Username", "configuration_password": "Password", + "configuration_secret": "Secret", "configuration_save": "Save", "configuration_restart_required": "A restart will be necessary for the changes to take effect." }, diff --git a/front/src/i18n/fr.json b/front/src/i18n/fr.json index 982e843..4df4349 100644 --- a/front/src/i18n/fr.json +++ b/front/src/i18n/fr.json @@ -48,6 +48,7 @@ "settings": { "title": "Paramètres", "language": "Langue", + "configuration_downloader": "Downloader", "configuration_subtitle": "Configuration", "configuration_debug": "Debug", "configuration_base_path": "Chemin de base", @@ -56,6 +57,7 @@ "configuration_endpoint": "Endpoint", "configuration_username": "Nom d'utilisateur", "configuration_password": "Mot de passe", + "configuration_secret": "Secret", "configuration_save": "Enregistrer", "configuration_restart_required": "Un redémarrage sera nécessaire pour que les changements soient effectifs." }, diff --git a/front/src/models/configuration.type.ts b/front/src/models/configuration.type.ts index 67181b7..3cee288 100644 --- a/front/src/models/configuration.type.ts +++ b/front/src/models/configuration.type.ts @@ -22,7 +22,8 @@ export type RealDebrid = { }; export type Downloader = { - synology_download_station: SynologyDownloadStation; + synology_download_station?: SynologyDownloadStation; + aria2_jsonrpc?: Aria2JsonRpc; }; export type SynologyDownloadStation = { @@ -30,3 +31,8 @@ export type SynologyDownloadStation = { username: string; password: string; }; + +export type Aria2JsonRpc = { + endpoint: string; + secret?: string; +}; diff --git a/front/src/pages/Settings/Settings.tsx b/front/src/pages/Settings/Settings.tsx index 8616849..f48c56e 100644 --- a/front/src/pages/Settings/Settings.tsx +++ b/front/src/pages/Settings/Settings.tsx @@ -81,7 +81,10 @@ const Settings = () => { }: ChangeEvent) => { if (config) { const tmpConf = Object.assign({}, config); - if (!tmpConf.downloader) { + if ( + !tmpConf.downloader || + !tmpConf.downloader.synology_download_station + ) { tmpConf.downloader = { synology_download_station: { endpoint: "", @@ -90,6 +93,8 @@ const Settings = () => { }, }; } + + // @ts-ignore tmpConf.downloader.synology_download_station.endpoint = target.value; const tmpNewConfig = Object.assign({}, newConfig); @@ -97,6 +102,7 @@ const Settings = () => { tmpNewConfig.downloader = { synology_download_station: {} }; } tmpNewConfig.downloader.synology_download_station.endpoint = + // @ts-ignore tmpConf.downloader.synology_download_station.endpoint; setConfig(tmpConf); @@ -109,7 +115,10 @@ const Settings = () => { }: ChangeEvent) => { if (config) { const tmpConf = Object.assign({}, config); - if (!tmpConf.downloader) { + if ( + !tmpConf.downloader || + !tmpConf.downloader.synology_download_station + ) { tmpConf.downloader = { synology_download_station: { endpoint: "", @@ -118,13 +127,16 @@ const Settings = () => { }, }; } + // @ts-ignore tmpConf.downloader.synology_download_station.username = target.value; const tmpNewConfig = Object.assign({}, newConfig); if (!tmpNewConfig.downloader) { tmpNewConfig.downloader = { synology_download_station: {} }; } + tmpNewConfig.downloader.synology_download_station.username = + // @ts-ignore tmpConf.downloader.synology_download_station.username; setConfig(tmpConf); @@ -137,7 +149,10 @@ const Settings = () => { }: ChangeEvent) => { if (config) { const tmpConf = Object.assign({}, config); - if (!tmpConf.downloader) { + if ( + !tmpConf.downloader || + !tmpConf.downloader.synology_download_station + ) { tmpConf.downloader = { synology_download_station: { endpoint: "", @@ -146,6 +161,7 @@ const Settings = () => { }, }; } + // @ts-ignore tmpConf.downloader.synology_download_station.password = target.value; const tmpNewConfig = Object.assign({}, newConfig); @@ -153,6 +169,7 @@ const Settings = () => { tmpNewConfig.downloader = { synology_download_station: {} }; } tmpNewConfig.downloader.synology_download_station.password = + // @ts-ignore tmpConf.downloader.synology_download_station.password; setConfig(tmpConf); @@ -160,6 +177,65 @@ const Settings = () => { } }; + const handleChangeAria2Endpoint = ({ + target, + }: ChangeEvent) => { + if (config) { + const tmpConf = Object.assign({}, config); + if (!tmpConf.downloader || !tmpConf.downloader.aria2_jsonrpc) { + tmpConf.downloader = { + aria2_jsonrpc: { + endpoint: "", + }, + }; + } + + // @ts-ignore + tmpConf.downloader.aria2_jsonrpc.endpoint = target.value; + + const tmpNewConfig = Object.assign({}, newConfig); + if (!tmpNewConfig.downloader) { + tmpNewConfig.downloader = { aria2_jsonrpc: {} }; + } + tmpNewConfig.downloader.aria2_jsonrpc.endpoint = + // @ts-ignore + tmpConf.downloader.aria2_jsonrpc.endpoint; + + setConfig(tmpConf); + setNewConfig(tmpNewConfig); + } + }; + + const handleChangeAria2Secret = ({ + target, + }: ChangeEvent) => { + if (config) { + const tmpConf = Object.assign({}, config); + if (!tmpConf.downloader || !tmpConf.downloader.aria2_jsonrpc) { + tmpConf.downloader = { + aria2_jsonrpc: { + endpoint: "", + secret: "", + }, + }; + } + + // @ts-ignore + tmpConf.downloader.aria2_jsonrpc.secret = target.value; + + const tmpNewConfig = Object.assign({}, newConfig); + if (!tmpNewConfig.downloader) { + tmpNewConfig.downloader = { aria2_jsonrpc: {} }; + } + tmpNewConfig.downloader.aria2_jsonrpc.secret = + // @ts-ignore + tmpConf.downloader.aria2_jsonrpc.secret; + + setConfig(tmpConf); + setNewConfig(tmpNewConfig); + } + }; + const handleSave = async (e: React.FormEvent) => { e.preventDefault(); @@ -233,11 +309,12 @@ const Settings = () => { {t("settings.real_debrid_website")} -

Synology Download Station

+

{t("settings.configuration_downloader")}

+

Synology Download Station

{ { +

Aria2 JSON-RPC

+ +