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 +0 -0
- cdm_client/cdm_client.py +246 -0
- cdm_client/config.py +90 -0
- cdm_client/database_adapter.py +113 -0
- cdm_client/qbittorrent_adapter.py +132 -0
- cdm_client/torrent_client_adapter_base.py +32 -0
- cdm_client/torrent_client_factory.py +60 -0
- cdm_client/transmission_adapter.py +59 -0
- cdmclient-1.2.4.dist-info/METADATA +122 -0
- cdmclient-1.2.4.dist-info/RECORD +13 -0
- cdmclient-1.2.4.dist-info/WHEEL +4 -0
- cdmclient-1.2.4.dist-info/entry_points.txt +2 -0
- cdmclient-1.2.4.dist-info/licenses/LICENSE +21 -0
cdm_client/__init__.py
ADDED
|
File without changes
|
cdm_client/cdm_client.py
ADDED
|
@@ -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,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.
|