cdmclient 1.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cdm_client/__init__.py ADDED
File without changes
@@ -0,0 +1,246 @@
1
+ import logging
2
+ import os
3
+ import shutil
4
+ from enum import Enum
5
+ from logging.handlers import SysLogHandler
6
+ from time import sleep
7
+ from typing import Optional
8
+
9
+ import requests
10
+
11
+ from cdm_client.config import Config
12
+ from cdm_client.database_adapter import DatabaseAdapter
13
+ from cdm_client.torrent_client_factory import (
14
+ TorrentClientType,
15
+ create_torrent_client_adapter,
16
+ )
17
+
18
+
19
+ class InstructionAction(Enum):
20
+ STOP = "stop"
21
+ START = "start"
22
+ DELETE = "delete"
23
+ CLEAN = "clean"
24
+
25
+
26
+ class CDMClient:
27
+ def __init__(self) -> None:
28
+ self._logger = self._init_logger()
29
+ self._config = Config()
30
+ self._torrent_client_adapter = create_torrent_client_adapter(
31
+ TorrentClientType.get_enum_from_value(self._config["client_type"]),
32
+ username=self._config["client_username"] or None,
33
+ password=self._config["client_password"] or None,
34
+ host=self._config["client_host"] or None,
35
+ port=int(self._config["client_port"])
36
+ if self._config["client_port"]
37
+ else None,
38
+ )
39
+ self._database_adapter = DatabaseAdapter()
40
+
41
+ def _init_logger(self) -> logging.Logger:
42
+ syslog = SysLogHandler(address="/dev/log")
43
+ syslog.setFormatter(
44
+ logging.Formatter("cdm-client %(name)s: %(levelname)s %(message)s")
45
+ )
46
+ logger = logging.getLogger("cdm-client")
47
+ logger.addHandler(syslog)
48
+ logger.setLevel(logging.INFO)
49
+ return logger
50
+
51
+ def _update_status(self, status_data: list[dict]) -> None:
52
+ resp = requests.post(
53
+ f"{self._config['server_host']}/api/client/status/",
54
+ json={"data": status_data},
55
+ headers={"x-api-key": self._config["api_key"]},
56
+ timeout=5,
57
+ )
58
+ resp.raise_for_status()
59
+
60
+ def _download_files(self, files: dict[int, str]) -> None:
61
+ for tracker_id, path in files.items():
62
+ resp = requests.get(
63
+ f"{self._config['server_host']}/api/client/download/{tracker_id}/",
64
+ headers={"x-api-key": self._config["api_key"]},
65
+ stream=True,
66
+ timeout=5,
67
+ )
68
+ resp.raise_for_status()
69
+ new_torrent = self._torrent_client_adapter.add_torrent(
70
+ resp.content, download_dir=path
71
+ )
72
+ if new_torrent is None:
73
+ self._logger.error(
74
+ "Failed to add torrent for tracker_id=%s to %s", tracker_id, path
75
+ )
76
+ continue
77
+ with self._database_adapter as db_adapter:
78
+ status = db_adapter.create_or_update_download_torrent_mapping(
79
+ tracker_id=tracker_id,
80
+ torrent_hash=self._torrent_client_adapter.get_status_by_id(
81
+ new_torrent.id
82
+ )["hash"],
83
+ )
84
+ if not status:
85
+ self._logger.error(
86
+ "Failed to save download-torrent mapping: %s", status
87
+ )
88
+ self._logger.info("Downloading torrent: %s to %s", tracker_id, path)
89
+
90
+ def _execute_instructions(self, instructions: list[dict]) -> None:
91
+ for instruction in instructions:
92
+ self._logger.info("Received instruction: %s", instruction)
93
+ for action, params in instruction.items():
94
+ if action == InstructionAction.STOP.value:
95
+ self._torrent_client_adapter.pause_torrent(params["torrent_id"])
96
+ self._logger.info("Stopped torrent: %s", params["torrent_id"])
97
+ elif action == InstructionAction.START.value:
98
+ self._torrent_client_adapter.resume_torrent(params["torrent_id"])
99
+ self._logger.info("Started torrent: %s", params["torrent_id"])
100
+ elif action == InstructionAction.DELETE.value:
101
+ self.delete_download(params["torrent_id"])
102
+ elif action == InstructionAction.CLEAN.value:
103
+ self.clean_download_paths(params["paths"])
104
+ else:
105
+ self._logger.warning("Unknown instruction action: %s", action)
106
+
107
+ def _normalize_path(self, path: str) -> str:
108
+ return os.path.normpath(os.path.realpath(path))
109
+
110
+ def _is_protected_path(self, path: str, protected_paths: set[str]) -> bool:
111
+ normalized_path = self._normalize_path(path)
112
+ for protected_path in protected_paths:
113
+ if protected_path == normalized_path or protected_path.startswith(
114
+ f"{normalized_path}{os.sep}"
115
+ ):
116
+ return True
117
+ return False
118
+
119
+ def _delete_path(self, path: str) -> None:
120
+ if os.path.islink(path) or os.path.isfile(path):
121
+ os.remove(path)
122
+ else:
123
+ shutil.rmtree(path)
124
+
125
+ def clean_download_paths(self, paths: list[str]) -> None:
126
+ if not paths:
127
+ self._logger.warning("No clean paths provided")
128
+ return
129
+
130
+ status_entries = self._torrent_client_adapter.get_status()
131
+ protected_paths: set[str] = set()
132
+ for status_entry in status_entries:
133
+ download_dir = status_entry.get("downloadDir")
134
+ name = status_entry.get("name")
135
+ if not isinstance(download_dir, str) or not isinstance(name, str):
136
+ continue
137
+ protected_paths.add(self._normalize_path(os.path.join(download_dir, name)))
138
+
139
+ for path in paths:
140
+ clean_path = self._normalize_path(path)
141
+ if not os.path.isdir(clean_path):
142
+ self._logger.warning("Clean path does not exist: %s", clean_path)
143
+ continue
144
+
145
+ self._logger.info("Cleaning path: %s", clean_path)
146
+ for entry in os.scandir(clean_path):
147
+ entry_path = self._normalize_path(entry.path)
148
+ if self._is_protected_path(entry_path, protected_paths):
149
+ continue
150
+ try:
151
+ self._delete_path(entry_path)
152
+ self._logger.info("Deleted stale path: %s", entry_path)
153
+ except FileNotFoundError:
154
+ self._logger.warning(
155
+ "Path disappeared during clean: %s", entry_path
156
+ )
157
+ except OSError:
158
+ self._logger.exception(
159
+ "Failed to delete stale path: %s", entry_path
160
+ )
161
+
162
+ def delete_download(self, torrent_id: int) -> None:
163
+ try:
164
+ status_data = self._get_download_status(
165
+ torrent_id=torrent_id, for_deletion=True
166
+ )
167
+ self._torrent_client_adapter.remove_torrent(torrent_id)
168
+ self._update_status(status_data)
169
+ self._force_delete_if_exists(
170
+ os.path.join(status_data[0]["downloadDir"], status_data[0]["name"])
171
+ )
172
+ finally:
173
+ with self._database_adapter as db_adapter:
174
+ deleted_mapping = db_adapter.delete_mapping(
175
+ torrent_hash=status_data[0]["hash"]
176
+ )
177
+ self._update_status(status_data)
178
+
179
+ self._logger.info(
180
+ "Removed torrent and data: %s, deleted_mapping: %s",
181
+ torrent_id,
182
+ deleted_mapping,
183
+ )
184
+
185
+ def _force_delete_if_exists(self, file_path: str) -> None:
186
+ sleep(1)
187
+ if os.path.exists(file_path):
188
+ self._logger.info("Force deleting file: %s", file_path)
189
+ try:
190
+ self._delete_path(file_path)
191
+ except FileNotFoundError:
192
+ self._logger.warning("File not found during deletion")
193
+
194
+ def _get_order(self) -> None:
195
+ resp = requests.get(
196
+ f"{self._config['server_host']}/api/client/",
197
+ headers={"x-api-key": self._config["api_key"]},
198
+ timeout=5,
199
+ )
200
+ resp.raise_for_status()
201
+
202
+ files = resp.json()["data"]["files"]
203
+ if files:
204
+ self._download_files(files)
205
+ instructions = resp.json()["data"]["instructions"]
206
+ if instructions:
207
+ self._execute_instructions(instructions)
208
+ self._update_status(self._get_download_status())
209
+
210
+ def _get_download_status(
211
+ self, torrent_id: Optional[int] = None, for_deletion: bool = False
212
+ ) -> list[dict]:
213
+ status = []
214
+ if torrent_id:
215
+ status.append(self._torrent_client_adapter.get_status_by_id(torrent_id))
216
+ else:
217
+ status = self._torrent_client_adapter.get_status()
218
+ with self._database_adapter as db_adapter:
219
+ for status_entry in status:
220
+ tracker_id = db_adapter.get_tracker_id_by_torrent_hash(
221
+ status_entry["hash"]
222
+ )
223
+ if tracker_id:
224
+ status_entry["tracker_id"] = tracker_id
225
+ if for_deletion:
226
+ status_entry["is_deleted"] = True
227
+ return status
228
+
229
+ def run(self) -> None:
230
+ self._logger.info("Starting cdm-client...")
231
+ while True:
232
+ try:
233
+ self._update_status(self._get_download_status())
234
+ self._get_order()
235
+ except Exception:
236
+ self._logger.exception("An error occurred.")
237
+ sleep(5)
238
+
239
+
240
+ def main() -> None:
241
+ cdm_client = CDMClient()
242
+ cdm_client.run()
243
+
244
+
245
+ if __name__ == "__main__":
246
+ main()
cdm_client/config.py ADDED
@@ -0,0 +1,90 @@
1
+ import configparser
2
+ import os
3
+ from typing import Union
4
+
5
+ from cryptography.fernet import Fernet
6
+
7
+
8
+ class Config:
9
+ CONFIG_FOLDER_PATH = os.path.join(os.path.expanduser("~"), ".config", "cdm_client")
10
+ KEY_PATH = os.path.join(CONFIG_FOLDER_PATH, "key.key")
11
+ CONFIG_PATH = os.path.join(CONFIG_FOLDER_PATH, "config.ini")
12
+ DEFAULT_CONFIG = {
13
+ "connection": {
14
+ "server_host": "",
15
+ "api_key": "",
16
+ "client_host": "",
17
+ "client_port": "",
18
+ "client_username": "",
19
+ "client_password": "",
20
+ "client_type": "",
21
+ }
22
+ }
23
+ ENCRYPTED_CONFIG = ["client_password"]
24
+
25
+ def __init__(self) -> None:
26
+ self._key: Union[bytes, str] = ""
27
+ self._config = self._load_config()
28
+ self._check_key()
29
+
30
+ def _check_key(self) -> None:
31
+ if not self._key_exists:
32
+ self._create_key()
33
+ else:
34
+ self._read_key()
35
+
36
+ def _create_key(self) -> None:
37
+ self._key = Fernet.generate_key()
38
+ with open(self.KEY_PATH, "w", encoding="utf-8") as f:
39
+ f.write(self._key.decode())
40
+
41
+ def _read_key(self) -> None:
42
+ with open(self.KEY_PATH, "r", encoding="utf-8") as f:
43
+ self._key = f.read().encode()
44
+
45
+ def _load_config(self) -> configparser.ConfigParser:
46
+ config = configparser.ConfigParser()
47
+ if config.read(self.CONFIG_PATH):
48
+ return config
49
+ return self._create_config()
50
+
51
+ def _create_config(self) -> configparser.ConfigParser:
52
+ if not os.path.exists(self.CONFIG_FOLDER_PATH):
53
+ os.makedirs(self.CONFIG_FOLDER_PATH)
54
+ config = configparser.ConfigParser()
55
+ for section, configs in self.DEFAULT_CONFIG.items():
56
+ config[section] = {}
57
+ for config_key, config_value in configs.items():
58
+ config[section][config_key] = config_value
59
+ with open(self.CONFIG_PATH, "w", encoding="utf-8") as f:
60
+ config.write(f)
61
+ return config
62
+
63
+ def _decrypt(self, value: str) -> str:
64
+ f = Fernet(self._key)
65
+ return f.decrypt(value.encode()).decode()
66
+
67
+ def _encrypt(self, value: str) -> str:
68
+ f = Fernet(self._key)
69
+ return f.encrypt(value.encode()).decode()
70
+
71
+ def _write_creds(self) -> None:
72
+ with open(self.CONFIG_PATH, "w", encoding="utf-8") as f:
73
+ self._config.write(f)
74
+
75
+ @property
76
+ def _key_exists(self) -> bool:
77
+ return os.path.exists(self.KEY_PATH)
78
+
79
+ def __getitem__(self, name: str) -> str:
80
+ if name in self.ENCRYPTED_CONFIG:
81
+ try:
82
+ return self._decrypt(self._config["connection"][name])
83
+ except Exception:
84
+ raw_value = self._config["connection"][name]
85
+ self._config["connection"][name] = self._encrypt(
86
+ self._config["connection"][name]
87
+ )
88
+ self._write_creds()
89
+ return raw_value
90
+ return self._config["connection"][name]
@@ -0,0 +1,113 @@
1
+ import os
2
+ from types import TracebackType
3
+ from typing import Optional
4
+
5
+ from sqlalchemy import (
6
+ Column,
7
+ Integer,
8
+ String,
9
+ UniqueConstraint,
10
+ create_engine,
11
+ )
12
+ from sqlalchemy.exc import IntegrityError
13
+ from sqlalchemy.ext.declarative import declarative_base
14
+ from sqlalchemy.orm import sessionmaker
15
+
16
+ Base = declarative_base()
17
+
18
+
19
+ class DownloadTorrentMapping(Base):
20
+ __tablename__ = "download_torrent_mapping"
21
+
22
+ tracker_id: int = Column(Integer, primary_key=True) # type: ignore[assignment]
23
+ torrent_hash: str = Column(String(40), nullable=False, unique=True) # type: ignore[assignment]
24
+
25
+ __table_args__ = (
26
+ UniqueConstraint("tracker_id", "torrent_hash", name="unique_download_torrent"),
27
+ )
28
+
29
+
30
+ class DatabaseAdapter:
31
+ DATABASE_PATH = os.path.join(
32
+ os.path.expanduser("~"), ".local", "share", "cdm_client", "cdm_client.db"
33
+ )
34
+
35
+ def __init__(self) -> None:
36
+ os.makedirs(os.path.dirname(self.DATABASE_PATH), exist_ok=True)
37
+
38
+ self.engine = create_engine(f"sqlite:///{self.DATABASE_PATH}")
39
+ Base.metadata.create_all(self.engine)
40
+
41
+ def __enter__(self) -> "DatabaseAdapter":
42
+ Session = sessionmaker(bind=self.engine)
43
+ self.session = Session()
44
+ return self
45
+
46
+ def __exit__(
47
+ self,
48
+ exc_type: Optional[type],
49
+ exc_val: Optional[BaseException],
50
+ exc_tb: Optional[TracebackType],
51
+ ) -> None:
52
+ self.session.close()
53
+
54
+ def create_or_update_download_torrent_mapping(
55
+ self, tracker_id: int, torrent_hash: str
56
+ ) -> bool:
57
+ if self.get_torrent_hash_by_tracker_id(tracker_id):
58
+ return self.update_torrent_hash(tracker_id, torrent_hash)
59
+ try:
60
+ mapping = DownloadTorrentMapping(
61
+ tracker_id=tracker_id, torrent_hash=torrent_hash
62
+ )
63
+ self.session.add(mapping)
64
+ self.session.commit()
65
+ return True
66
+ except IntegrityError:
67
+ self.session.rollback()
68
+ return False
69
+ except Exception:
70
+ self.session.rollback()
71
+ return False
72
+
73
+ def get_torrent_hash_by_tracker_id(self, tracker_id: int) -> Optional[str]:
74
+ mapping = (
75
+ self.session.query(DownloadTorrentMapping)
76
+ .filter_by(tracker_id=tracker_id)
77
+ .first()
78
+ )
79
+ return mapping.torrent_hash if mapping else None
80
+
81
+ def update_torrent_hash(self, tracker_id: int, new_torrent_hash: str) -> bool:
82
+ mapping = (
83
+ self.session.query(DownloadTorrentMapping)
84
+ .filter_by(tracker_id=tracker_id)
85
+ .first()
86
+ )
87
+
88
+ if mapping:
89
+ mapping.torrent_hash = new_torrent_hash
90
+ self.session.commit()
91
+ return True
92
+ return False
93
+
94
+ def get_tracker_id_by_torrent_hash(self, torrent_hash: str) -> Optional[int]:
95
+ mapping = (
96
+ self.session.query(DownloadTorrentMapping)
97
+ .filter_by(torrent_hash=torrent_hash)
98
+ .first()
99
+ )
100
+ return mapping.tracker_id if mapping else None
101
+
102
+ def delete_mapping(self, torrent_hash: str) -> bool:
103
+ mapping = (
104
+ self.session.query(DownloadTorrentMapping)
105
+ .filter_by(torrent_hash=torrent_hash)
106
+ .first()
107
+ )
108
+
109
+ if mapping:
110
+ self.session.delete(mapping)
111
+ self.session.commit()
112
+ return True
113
+ return False
@@ -0,0 +1,132 @@
1
+ import logging
2
+ from time import sleep
3
+ from typing import Optional
4
+
5
+ from qbittorrentapi import Client, TorrentDictionary, TorrentState
6
+
7
+ from cdm_client.torrent_client_adapter_base import TorrentClientAdapterBase
8
+
9
+
10
+ class TorrentWrapper:
11
+ def __init__(self, torrent_dict: TorrentDictionary) -> None:
12
+ self._torrent_dict = torrent_dict
13
+
14
+ @property
15
+ def id(self) -> int:
16
+ return int(self._torrent_dict.hash[:8], 16)
17
+
18
+
19
+ class QBitTorrentAdapter(TorrentClientAdapterBase):
20
+ def __init__(
21
+ self,
22
+ username: Optional[str] = "admin",
23
+ password: Optional[str] = "adminadmin",
24
+ host: str = "127.0.0.1",
25
+ port: int = 8080,
26
+ ) -> None:
27
+ self._client = Client(
28
+ username=username, password=password, host=host, port=port
29
+ )
30
+ self._logger = logging.getLogger("cdm-client")
31
+
32
+ def _hash_to_id(self, torrent_hash: str) -> int:
33
+ """Convert torrent hash to integer ID using Python's hash function."""
34
+ return int(torrent_hash[:8], 16)
35
+
36
+ def _map_status(self, qbittorrent_status: TorrentState) -> str:
37
+ status_mapping = {
38
+ TorrentState.ERROR: "stopped",
39
+ TorrentState.MISSING_FILES: "stopped",
40
+ TorrentState.UPLOADING: "seeding",
41
+ TorrentState.PAUSED_UPLOAD: "stopped",
42
+ TorrentState.STOPPED_UPLOAD: "stopped",
43
+ TorrentState.QUEUED_UPLOAD: "stopped",
44
+ TorrentState.STALLED_UPLOAD: "seeding",
45
+ TorrentState.CHECKING_UPLOAD: "checking",
46
+ TorrentState.FORCED_UPLOAD: "seeding",
47
+ TorrentState.ALLOCATING: "checking",
48
+ TorrentState.DOWNLOADING: "downloading",
49
+ TorrentState.METADATA_DOWNLOAD: "downloading",
50
+ TorrentState.FORCED_METADATA_DOWNLOAD: "downloading",
51
+ TorrentState.PAUSED_DOWNLOAD: "stopped",
52
+ TorrentState.STOPPED_DOWNLOAD: "stopped",
53
+ TorrentState.QUEUED_DOWNLOAD: "download pending",
54
+ TorrentState.FORCED_DOWNLOAD: "downloading",
55
+ TorrentState.STALLED_DOWNLOAD: "stopped",
56
+ TorrentState.CHECKING_DOWNLOAD: "checking",
57
+ TorrentState.CHECKING_RESUME_DATA: "checking",
58
+ TorrentState.MOVING: "checking",
59
+ TorrentState.UNKNOWN: "unknown",
60
+ }
61
+ return status_mapping.get(qbittorrent_status, "unknown")
62
+
63
+ def _get_status_dict(self, torrent: TorrentDictionary) -> dict:
64
+ return {
65
+ "id": self._hash_to_id(torrent.hash),
66
+ "hash": torrent.hash,
67
+ "name": torrent.name,
68
+ "status": self._map_status(torrent.state),
69
+ "progress": int(torrent.progress * 100),
70
+ "downloadDir": torrent.save_path,
71
+ "addedDate": torrent.added_on,
72
+ "totalSize": torrent.size,
73
+ "eta": torrent.eta,
74
+ }
75
+
76
+ def get_status(self) -> list[dict]:
77
+ torrents = self._client.torrents_info()
78
+ status = []
79
+ for torrent in torrents:
80
+ status.append(self._get_status_dict(torrent))
81
+ self._logger.info("Retrieved status of %s torrents", len(status))
82
+ return status
83
+
84
+ def get_status_by_id(self, torrent_id: int) -> dict:
85
+ return self._get_status_dict(self._get_torrent_by_id(torrent_id))
86
+
87
+ def _get_torrent_by_id(self, torrent_id: int) -> TorrentDictionary:
88
+ torrents = self._client.torrents_info()
89
+ for torrent in torrents:
90
+ if self._hash_to_id(torrent.hash) == torrent_id:
91
+ return torrent
92
+ raise ValueError(f"Torrent with ID {torrent_id} not found")
93
+
94
+ def _get_latest_torrent(self) -> Optional[TorrentDictionary]:
95
+ torrents = self._client.torrents_info(sort="added_on")
96
+ self._logger.info(
97
+ "Current torrents: %s", [(t.name, t.added_on) for t in torrents]
98
+ )
99
+ return torrents[-1] if len(torrents) > 0 else None
100
+
101
+ def add_torrent(
102
+ self, torrent: bytes, download_dir: str
103
+ ) -> Optional[TorrentWrapper]:
104
+ states_before = self._client.torrents_info()
105
+ self._client.torrents_add(
106
+ torrent_files=torrent, save_path=download_dir, is_sequential_download=True
107
+ )
108
+ for _ in range(20): # Wait up to 10 seconds
109
+ if self._client.torrents_info() == states_before:
110
+ sleep(0.5)
111
+ else:
112
+ break
113
+ if self._client.torrents_info() == states_before:
114
+ return None
115
+ if last_torrent := self._get_latest_torrent():
116
+ self._logger.info(
117
+ "Added torrent: %s to %s", last_torrent.name, download_dir
118
+ )
119
+ return TorrentWrapper(last_torrent)
120
+ return None
121
+
122
+ def pause_torrent(self, torrent_id: int) -> None:
123
+ torrent = self._get_torrent_by_id(torrent_id)
124
+ self._client.torrents_pause(torrent_hashes=[torrent.hash])
125
+
126
+ def resume_torrent(self, torrent_id: int) -> None:
127
+ torrent = self._get_torrent_by_id(torrent_id)
128
+ self._client.torrents_resume(torrent_hashes=[torrent.hash])
129
+
130
+ def remove_torrent(self, torrent_id: int) -> None:
131
+ torrent = self._get_torrent_by_id(torrent_id)
132
+ self._client.torrents_delete(torrent_hashes=[torrent.hash], delete_files=True)
@@ -0,0 +1,32 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Optional, Protocol
3
+
4
+
5
+ class TorrentRef(Protocol):
6
+ @property
7
+ def id(self) -> int: ...
8
+
9
+
10
+ class TorrentClientAdapterBase(ABC):
11
+ @abstractmethod
12
+ def __init__(self) -> None: ...
13
+
14
+ @abstractmethod
15
+ def get_status(self) -> list[dict]: ...
16
+
17
+ @abstractmethod
18
+ def get_status_by_id(self, torrent_id: int) -> dict: ...
19
+
20
+ @abstractmethod
21
+ def add_torrent(
22
+ self, torrent: bytes, download_dir: str
23
+ ) -> Optional[TorrentRef]: ...
24
+
25
+ @abstractmethod
26
+ def pause_torrent(self, torrent_id: int) -> None: ...
27
+
28
+ @abstractmethod
29
+ def resume_torrent(self, torrent_id: int) -> None: ...
30
+
31
+ @abstractmethod
32
+ def remove_torrent(self, torrent_id: int) -> None: ...
@@ -0,0 +1,60 @@
1
+ from enum import Enum
2
+ from typing import Optional, TypedDict
3
+
4
+ from cdm_client.qbittorrent_adapter import QBitTorrentAdapter
5
+ from cdm_client.torrent_client_adapter_base import TorrentClientAdapterBase
6
+ from cdm_client.transmission_adapter import TransmissionAdapter
7
+
8
+
9
+ class TorrentClientType(Enum):
10
+ TRANSMISSION = "transmission"
11
+ QBITTORRENT = "qbittorrent"
12
+
13
+ @classmethod
14
+ def get_enum_from_value(cls, value: str) -> "TorrentClientType":
15
+ for member in cls:
16
+ if member.value == value:
17
+ return member
18
+ return cls.TRANSMISSION
19
+
20
+
21
+ class AdapterKwargs(TypedDict, total=False):
22
+ username: str
23
+ password: str
24
+ host: str
25
+ port: int
26
+
27
+
28
+ def _filter_none(
29
+ username: Optional[str] = None,
30
+ password: Optional[str] = None,
31
+ host: Optional[str] = None,
32
+ port: Optional[int] = None,
33
+ ) -> AdapterKwargs:
34
+ kwargs: AdapterKwargs = {}
35
+ if username is not None:
36
+ kwargs["username"] = username
37
+ if password is not None:
38
+ kwargs["password"] = password
39
+ if host is not None:
40
+ kwargs["host"] = host
41
+ if port is not None:
42
+ kwargs["port"] = port
43
+ return kwargs
44
+
45
+
46
+ def create_torrent_client_adapter(
47
+ client_type: TorrentClientType,
48
+ username: Optional[str] = None,
49
+ password: Optional[str] = None,
50
+ host: Optional[str] = None,
51
+ port: Optional[int] = None,
52
+ ) -> TorrentClientAdapterBase:
53
+ kwargs = _filter_none(username=username, password=password, host=host, port=port)
54
+
55
+ if client_type == TorrentClientType.TRANSMISSION:
56
+ return TransmissionAdapter(**kwargs)
57
+ if client_type == TorrentClientType.QBITTORRENT:
58
+ return QBitTorrentAdapter(**kwargs)
59
+ else:
60
+ raise ValueError(f"Unsupported torrent client type: {client_type}")
@@ -0,0 +1,59 @@
1
+ import logging
2
+ from typing import Optional
3
+
4
+ from transmission_rpc import Client, Torrent
5
+
6
+ from cdm_client.torrent_client_adapter_base import TorrentClientAdapterBase
7
+
8
+
9
+ class TransmissionAdapter(TorrentClientAdapterBase):
10
+ def __init__(
11
+ self,
12
+ username: Optional[str] = None,
13
+ password: Optional[str] = None,
14
+ host: str = "127.0.0.1",
15
+ port: int = 9091,
16
+ ) -> None:
17
+ self._client = Client(
18
+ username=username, password=password, host=host, port=port
19
+ )
20
+ # The sequential download setting is not working somehow :(
21
+ self._client.set_session(rename_partial_files=False, sequential_download=True)
22
+ self._logger = logging.getLogger("cdm-client")
23
+
24
+ def _get_status_dict(self, torrent: Torrent) -> dict:
25
+ return {
26
+ "id": torrent.id,
27
+ "hash": torrent.hashString,
28
+ "name": torrent.name,
29
+ "status": torrent.status.value,
30
+ "progress": int(torrent.progress),
31
+ "downloadDir": torrent.download_dir,
32
+ "addedDate": torrent.added_date.timestamp(),
33
+ "totalSize": torrent.total_size,
34
+ "eta": torrent.eta.total_seconds() if torrent.eta else None,
35
+ }
36
+
37
+ def get_status(self) -> list[dict]:
38
+ torrents = self._client.get_torrents()
39
+ status = []
40
+ for torrent in torrents:
41
+ status.append(self._get_status_dict(torrent))
42
+ self._logger.info("Retrieved status of %s torrents", len(status))
43
+ return status
44
+
45
+ def get_status_by_id(self, torrent_id: int) -> dict:
46
+ torrent = self._client.get_torrent(torrent_id)
47
+ return self._get_status_dict(torrent)
48
+
49
+ def add_torrent(self, torrent: bytes, download_dir: str) -> Torrent:
50
+ return self._client.add_torrent(torrent, download_dir=download_dir)
51
+
52
+ def pause_torrent(self, torrent_id: int) -> None:
53
+ return self._client.stop_torrent(ids=[torrent_id])
54
+
55
+ def resume_torrent(self, torrent_id: int) -> None:
56
+ return self._client.start_torrent(ids=[torrent_id])
57
+
58
+ def remove_torrent(self, torrent_id: int) -> None:
59
+ return self._client.remove_torrent(ids=[torrent_id], delete_data=True)
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: cdmclient
3
+ Version: 1.2.4
4
+ Summary: CDM - Centralized Download Manager Client
5
+ Project-URL: Homepage, https://github.com/radaron/CDMServer/tree/master/client
6
+ Project-URL: Repository, https://github.com/radaron/CDMServer
7
+ Author-email: Aron Radics <radics.aron.jozsef@gmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2024 Aron Radics
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Classifier: Operating System :: OS Independent
31
+ Classifier: Programming Language :: Python :: 3
32
+ Requires-Python: >=3.9
33
+ Requires-Dist: cryptography
34
+ Requires-Dist: qbittorrent-api>=2025.7.0
35
+ Requires-Dist: requests
36
+ Requires-Dist: sqlalchemy>=2.0.0
37
+ Requires-Dist: transmission-rpc
38
+ Description-Content-Type: text/markdown
39
+
40
+ # CDMClient
41
+ **CDM - Centralized Download Manager Client**
42
+ CDMClient is the client application for the [CDM Server](../README.md).
43
+
44
+ This client communicates with the CDM Server to periodically send the download status from Transmission/QBittorrent and check for new torrent files to download. If new torrents are available, they are downloaded and added to Transmission/QBittorrent client.
45
+
46
+ ## Installation
47
+
48
+ ### Install or Upgrade the Package
49
+ Run the following command to install or upgrade CDMClient:
50
+ ```shell
51
+ python3 -m pip install --upgrade cdmclient --user
52
+ ```
53
+
54
+ ### Create a Systemd Service
55
+ To set up CDMClient as a systemd service, execute the following commands:
56
+
57
+ 1. Create the service file:
58
+ ```shell
59
+ echo "[Unit]
60
+ Description=cdm-client service
61
+ After=multi-user.target
62
+ Conflicts=getty@tty1.service
63
+
64
+ [Service]
65
+ User=${USER}
66
+ Type=simple
67
+ Environment=LC_ALL=C.UTF-8
68
+ Environment=LANG=C.UTF-8
69
+ ExecStart=${HOME}/.local/bin/cdm-client
70
+ Restart=on-failure
71
+ RestartSec=3
72
+
73
+ [Install]
74
+ WantedBy=multi-user.target" | sudo tee /etc/systemd/system/cdm-client.service
75
+ ```
76
+
77
+ 2. Reload systemd and enable the service:
78
+ ```shell
79
+ sudo systemctl daemon-reload
80
+ sudo systemctl enable cdm-client.service
81
+ sudo systemctl start cdm-client.service
82
+ ```
83
+
84
+ ### Add automatic update (optional)
85
+ To keep CDMClient updated automatically, you can add a cron job. Run:
86
+ ```shell
87
+ crontab -e
88
+ ```
89
+ Then add the following line to the crontab file:
90
+ ```shell
91
+ 0 3 * * * /usr/bin/python3 -m pip install --upgrade cdmclient --user && systemctl restart cdm-client.service
92
+ ```
93
+
94
+ ## Configuration
95
+ The configuration file is located at:
96
+ ```~/.config/cdm_client/config.ini```
97
+
98
+ This file is automatically generated when the service starts. Below is an example configuration:
99
+
100
+ ```ini
101
+ [connection]
102
+ server_host = "https://cdmserver.com"
103
+ api_key = "1409d6ed79280aadcbe20f8f21981e89"
104
+ client_host =
105
+ client_port =
106
+ client_username =
107
+ client_password =
108
+ client_type= transmission # possible values: transmission or qbitorrent
109
+ ```
110
+
111
+ ### Configuration Details:
112
+ - **`server_host`**: The full URL of the CDM Server (e.g., `https://cdmserver.com`).
113
+ - **`api_key`**: The API key generated when adding a new device on the CDM Server's devices page. Refer to the [Devices Page Documentation](../doc/USAGE.md#devices-page).
114
+ - **`client_username`** and **`client_password`**: Required authentication for the selected torrent client.
115
+ - **`client_type`**: Specify either `transmission` or `qbitorrent`, depending on the torrent client you are using.
116
+ - **`client_host`** and **`client_port`**: Optional. If not specified, defaults to `localhost` and the default port for the selected client type.
117
+
118
+ ## Viewing Logs
119
+ To monitor the service logs, use the following command:
120
+ ```shell
121
+ journalctl -fu cdm-client
122
+ ```
@@ -0,0 +1,13 @@
1
+ cdm_client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ cdm_client/cdm_client.py,sha256=CVu9MO9y09OZYRUL2bZHiokazeav7wDOk2pMGpv0i_w,9406
3
+ cdm_client/config.py,sha256=rHxgcu3QTNly5ISgSvJmbSNoqhFXW722iin2B8Gwb0U,3001
4
+ cdm_client/database_adapter.py,sha256=UGln3g4tiywaN7og9k2p7VEXIW094gyzQzLnDZhVUco,3428
5
+ cdm_client/qbittorrent_adapter.py,sha256=8lZRnRs4WB-qmaVGH_hL1_LfvnO9GWbR0J5txNMQQTc,5236
6
+ cdm_client/torrent_client_adapter_base.py,sha256=d-wP64gnzQw5dHNJ354JH-wrbF4ui83FDcljA5JG-_U,753
7
+ cdm_client/torrent_client_factory.py,sha256=1NWDXEfuy1Zf2_Exo1FQ_Nvw8wF7TS5JswJvKjOXs48,1766
8
+ cdm_client/transmission_adapter.py,sha256=3EvdioQ1ImPyhR30B4wtWP2WsjkTwVODP2pKcxlgmoA,2203
9
+ cdmclient-1.2.4.dist-info/METADATA,sha256=fyo9zkaulJhpShDVzEj2c7Tpzm4HAefyYvE7M2m8vvQ,4666
10
+ cdmclient-1.2.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ cdmclient-1.2.4.dist-info/entry_points.txt,sha256=cbr164wsAg2ll7-lMsGQxjkVfk9rQI83nq_Fub1Xsl0,58
12
+ cdmclient-1.2.4.dist-info/licenses/LICENSE,sha256=yXUdXegqLKZ48VoEPZ-YYje08vmKRdB3C2Xh6IA8HLU,1068
13
+ cdmclient-1.2.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cdm-client = cdm_client.cdm_client:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Aron Radics
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.