cdmclient 1.2.4__tar.gz
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.
- cdmclient-1.2.4/.gitignore +39 -0
- cdmclient-1.2.4/.python-version +1 -0
- cdmclient-1.2.4/LICENSE +21 -0
- cdmclient-1.2.4/Makefile +14 -0
- cdmclient-1.2.4/PKG-INFO +122 -0
- cdmclient-1.2.4/README.md +83 -0
- cdmclient-1.2.4/cdm_client/__init__.py +0 -0
- cdmclient-1.2.4/cdm_client/cdm_client.py +246 -0
- cdmclient-1.2.4/cdm_client/config.py +90 -0
- cdmclient-1.2.4/cdm_client/database_adapter.py +113 -0
- cdmclient-1.2.4/cdm_client/qbittorrent_adapter.py +132 -0
- cdmclient-1.2.4/cdm_client/torrent_client_adapter_base.py +32 -0
- cdmclient-1.2.4/cdm_client/torrent_client_factory.py +60 -0
- cdmclient-1.2.4/cdm_client/transmission_adapter.py +59 -0
- cdmclient-1.2.4/pyproject.toml +45 -0
- cdmclient-1.2.4/ruff.toml +2 -0
- cdmclient-1.2.4/uv.lock +759 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
.venv/
|
|
8
|
+
.ruff_cache/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.mypy_cache/
|
|
11
|
+
.coverage
|
|
12
|
+
.coverage.*
|
|
13
|
+
htmlcov/
|
|
14
|
+
celerybeat-schedule
|
|
15
|
+
celerybeat.pid
|
|
16
|
+
|
|
17
|
+
# Environment
|
|
18
|
+
.env
|
|
19
|
+
.env.sh
|
|
20
|
+
|
|
21
|
+
# Node / frontend
|
|
22
|
+
node_modules/
|
|
23
|
+
webapp/frontend/build/
|
|
24
|
+
|
|
25
|
+
# Webapp built assets (generated by frontend build)
|
|
26
|
+
webapp/assets/*
|
|
27
|
+
webapp/templates/*
|
|
28
|
+
!webapp/templates/email/
|
|
29
|
+
!webapp/templates/email/**
|
|
30
|
+
|
|
31
|
+
# Browser extension
|
|
32
|
+
browser-extension/cdm.zip
|
|
33
|
+
|
|
34
|
+
# Editor / OS
|
|
35
|
+
.idea/
|
|
36
|
+
.vscode/
|
|
37
|
+
.claude/
|
|
38
|
+
.DS_Store
|
|
39
|
+
*.log
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.9
|
cdmclient-1.2.4/LICENSE
ADDED
|
@@ -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.
|
cdmclient-1.2.4/Makefile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
format:
|
|
2
|
+
uv run ruff format cdm_client/
|
|
3
|
+
uv run ruff check --fix cdm_client/
|
|
4
|
+
|
|
5
|
+
check-format-ci:
|
|
6
|
+
uv run ruff format --check cdm_client/
|
|
7
|
+
uv run ruff check cdm_client/
|
|
8
|
+
|
|
9
|
+
lint:
|
|
10
|
+
uv run ruff check cdm_client/
|
|
11
|
+
uv run ty check cdm_client/
|
|
12
|
+
|
|
13
|
+
bump:
|
|
14
|
+
uv version --bump $(filter-out $@,$(MAKECMDGOALS))
|
cdmclient-1.2.4/PKG-INFO
ADDED
|
@@ -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,83 @@
|
|
|
1
|
+
# CDMClient
|
|
2
|
+
**CDM - Centralized Download Manager Client**
|
|
3
|
+
CDMClient is the client application for the [CDM Server](../README.md).
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
### Install or Upgrade the Package
|
|
10
|
+
Run the following command to install or upgrade CDMClient:
|
|
11
|
+
```shell
|
|
12
|
+
python3 -m pip install --upgrade cdmclient --user
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### Create a Systemd Service
|
|
16
|
+
To set up CDMClient as a systemd service, execute the following commands:
|
|
17
|
+
|
|
18
|
+
1. Create the service file:
|
|
19
|
+
```shell
|
|
20
|
+
echo "[Unit]
|
|
21
|
+
Description=cdm-client service
|
|
22
|
+
After=multi-user.target
|
|
23
|
+
Conflicts=getty@tty1.service
|
|
24
|
+
|
|
25
|
+
[Service]
|
|
26
|
+
User=${USER}
|
|
27
|
+
Type=simple
|
|
28
|
+
Environment=LC_ALL=C.UTF-8
|
|
29
|
+
Environment=LANG=C.UTF-8
|
|
30
|
+
ExecStart=${HOME}/.local/bin/cdm-client
|
|
31
|
+
Restart=on-failure
|
|
32
|
+
RestartSec=3
|
|
33
|
+
|
|
34
|
+
[Install]
|
|
35
|
+
WantedBy=multi-user.target" | sudo tee /etc/systemd/system/cdm-client.service
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
2. Reload systemd and enable the service:
|
|
39
|
+
```shell
|
|
40
|
+
sudo systemctl daemon-reload
|
|
41
|
+
sudo systemctl enable cdm-client.service
|
|
42
|
+
sudo systemctl start cdm-client.service
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Add automatic update (optional)
|
|
46
|
+
To keep CDMClient updated automatically, you can add a cron job. Run:
|
|
47
|
+
```shell
|
|
48
|
+
crontab -e
|
|
49
|
+
```
|
|
50
|
+
Then add the following line to the crontab file:
|
|
51
|
+
```shell
|
|
52
|
+
0 3 * * * /usr/bin/python3 -m pip install --upgrade cdmclient --user && systemctl restart cdm-client.service
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
The configuration file is located at:
|
|
57
|
+
```~/.config/cdm_client/config.ini```
|
|
58
|
+
|
|
59
|
+
This file is automatically generated when the service starts. Below is an example configuration:
|
|
60
|
+
|
|
61
|
+
```ini
|
|
62
|
+
[connection]
|
|
63
|
+
server_host = "https://cdmserver.com"
|
|
64
|
+
api_key = "1409d6ed79280aadcbe20f8f21981e89"
|
|
65
|
+
client_host =
|
|
66
|
+
client_port =
|
|
67
|
+
client_username =
|
|
68
|
+
client_password =
|
|
69
|
+
client_type= transmission # possible values: transmission or qbitorrent
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Configuration Details:
|
|
73
|
+
- **`server_host`**: The full URL of the CDM Server (e.g., `https://cdmserver.com`).
|
|
74
|
+
- **`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).
|
|
75
|
+
- **`client_username`** and **`client_password`**: Required authentication for the selected torrent client.
|
|
76
|
+
- **`client_type`**: Specify either `transmission` or `qbitorrent`, depending on the torrent client you are using.
|
|
77
|
+
- **`client_host`** and **`client_port`**: Optional. If not specified, defaults to `localhost` and the default port for the selected client type.
|
|
78
|
+
|
|
79
|
+
## Viewing Logs
|
|
80
|
+
To monitor the service logs, use the following command:
|
|
81
|
+
```shell
|
|
82
|
+
journalctl -fu cdm-client
|
|
83
|
+
```
|
|
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()
|
|
@@ -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]
|