cdmctl 1.5.3__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.
- cdmctl-1.5.3/.gitignore +39 -0
- cdmctl-1.5.3/Makefile +14 -0
- cdmctl-1.5.3/PKG-INFO +8 -0
- cdmctl-1.5.3/README.md +46 -0
- cdmctl-1.5.3/cdmctl/__init__.py +0 -0
- cdmctl-1.5.3/cdmctl/client.py +91 -0
- cdmctl-1.5.3/cdmctl/config.py +50 -0
- cdmctl-1.5.3/cdmctl/main.py +761 -0
- cdmctl-1.5.3/pyproject.toml +28 -0
- cdmctl-1.5.3/ruff.toml +1 -0
- cdmctl-1.5.3/uv.lock +300 -0
cdmctl-1.5.3/.gitignore
ADDED
|
@@ -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
|
cdmctl-1.5.3/Makefile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
format:
|
|
2
|
+
uv run ruff format cdmctl/
|
|
3
|
+
uv run ruff check --fix cdmctl/
|
|
4
|
+
|
|
5
|
+
check-format-ci:
|
|
6
|
+
uv run ruff format --check cdmctl/
|
|
7
|
+
uv run ruff check cdmctl/
|
|
8
|
+
|
|
9
|
+
lint:
|
|
10
|
+
uv run ruff check cdmctl/
|
|
11
|
+
uv run ty check cdmctl/
|
|
12
|
+
|
|
13
|
+
bump:
|
|
14
|
+
uv version --bump $(filter-out $@,$(MAKECMDGOALS))
|
cdmctl-1.5.3/PKG-INFO
ADDED
cdmctl-1.5.3/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# CDM Server CLI
|
|
2
|
+
|
|
3
|
+
Command-line interface for [CDM Server](../README.md).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install cdmctl
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cdm login --server https://your-cdm-server.com
|
|
15
|
+
cdm search --pattern "inception"
|
|
16
|
+
cdm download --torrent-id 12345 --device-id 1
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
| Command | Description |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `cdm version` | Show CLI version |
|
|
24
|
+
| `cdm login` | Login and save credentials |
|
|
25
|
+
| `cdm logout` | Clear stored tokens |
|
|
26
|
+
| `cdm whoami` | Show current user |
|
|
27
|
+
| `cdm search` | Search torrents on nCore |
|
|
28
|
+
| `cdm download` | Queue a torrent for download |
|
|
29
|
+
| `cdm users` | User management (admin) |
|
|
30
|
+
| `cdm devices` | Device management |
|
|
31
|
+
| `cdm status` | Download status & control |
|
|
32
|
+
| `cdm tmdb` | Browse and search TMDB |
|
|
33
|
+
| `cdm wishlist` | Manage wishlist |
|
|
34
|
+
|
|
35
|
+
Run `cdm <command> --help` for options.
|
|
36
|
+
|
|
37
|
+
### Wishlist
|
|
38
|
+
|
|
39
|
+
Add movies by IMDB ID; the server retries weekly until the torrent appears on nCore.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
cdm wishlist types # list quality types
|
|
43
|
+
cdm wishlist add --imdb-id tt1375666 --device-id 1 --type hd_hun
|
|
44
|
+
cdm wishlist list
|
|
45
|
+
cdm wishlist delete --item-id 3
|
|
46
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from importlib.metadata import version
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from . import config
|
|
9
|
+
|
|
10
|
+
console = Console(stderr=True)
|
|
11
|
+
|
|
12
|
+
CLI_USER_AGENT = f"cdmctl/{version('cdmctl')}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CDMError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CDMClient:
|
|
20
|
+
def __init__(self, require_auth: bool = True):
|
|
21
|
+
server_url = config.get_server_url()
|
|
22
|
+
if not server_url:
|
|
23
|
+
console.print(
|
|
24
|
+
"[bold red]✗[/bold red] Not configured — run [bold]cdm login[/bold]"
|
|
25
|
+
)
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
self.base = server_url
|
|
28
|
+
self._access, self._refresh = config.get_tokens()
|
|
29
|
+
if require_auth and not self._refresh:
|
|
30
|
+
console.print(
|
|
31
|
+
"[bold red]✗[/bold red] Not logged in — run [bold]cdm login[/bold]"
|
|
32
|
+
)
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
self._http = httpx.Client(headers={"User-Agent": CLI_USER_AGENT})
|
|
35
|
+
|
|
36
|
+
def _auth_headers(self) -> dict:
|
|
37
|
+
h = {"Content-Type": "application/json"}
|
|
38
|
+
if self._access:
|
|
39
|
+
h["Authorization"] = f"Bearer {self._access}"
|
|
40
|
+
return h
|
|
41
|
+
|
|
42
|
+
def _do_refresh(self) -> bool:
|
|
43
|
+
if not self._refresh:
|
|
44
|
+
return False
|
|
45
|
+
try:
|
|
46
|
+
resp = self._http.post(
|
|
47
|
+
f"{self.base}/api/auth/refresh/",
|
|
48
|
+
json={"refresh_token": self._refresh},
|
|
49
|
+
timeout=10,
|
|
50
|
+
)
|
|
51
|
+
if resp.status_code == 200:
|
|
52
|
+
data = resp.json()
|
|
53
|
+
self._access = data["access_token"]
|
|
54
|
+
self._refresh = data.get("refresh_token", self._refresh)
|
|
55
|
+
config.save_tokens(self._access, self._refresh)
|
|
56
|
+
return True
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
62
|
+
url = f"{self.base}{path}"
|
|
63
|
+
kwargs.setdefault("timeout", 30)
|
|
64
|
+
resp = self._http.request(method, url, headers=self._auth_headers(), **kwargs)
|
|
65
|
+
if resp.status_code == 401:
|
|
66
|
+
if self._do_refresh():
|
|
67
|
+
resp = self._http.request(
|
|
68
|
+
method, url, headers=self._auth_headers(), **kwargs
|
|
69
|
+
)
|
|
70
|
+
if resp.status_code == 401:
|
|
71
|
+
config.clear_tokens()
|
|
72
|
+
console.print(
|
|
73
|
+
"[bold red]✗[/bold red] Session expired — run [bold]cdm login[/bold]"
|
|
74
|
+
)
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
return resp
|
|
77
|
+
|
|
78
|
+
def get(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
79
|
+
return self.request("GET", path, **kwargs)
|
|
80
|
+
|
|
81
|
+
def post(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
82
|
+
return self.request("POST", path, **kwargs)
|
|
83
|
+
|
|
84
|
+
def put(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
85
|
+
return self.request("PUT", path, **kwargs)
|
|
86
|
+
|
|
87
|
+
def patch(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
88
|
+
return self.request("PATCH", path, **kwargs)
|
|
89
|
+
|
|
90
|
+
def delete(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
91
|
+
return self.request("DELETE", path, **kwargs)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
CONFIG_DIR = Path.home() / ".config" / "cdm"
|
|
5
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load() -> dict:
|
|
9
|
+
if not CONFIG_FILE.exists():
|
|
10
|
+
return {}
|
|
11
|
+
try:
|
|
12
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
13
|
+
except Exception:
|
|
14
|
+
return {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def save(data: dict) -> None:
|
|
18
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_server_url() -> str | None:
|
|
23
|
+
return load().get("server_url")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_tokens() -> tuple[str | None, str | None]:
|
|
27
|
+
cfg = load()
|
|
28
|
+
return cfg.get("access_token"), cfg.get("refresh_token")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def save_login(server_url: str, access_token: str, refresh_token: str) -> None:
|
|
32
|
+
cfg = load()
|
|
33
|
+
cfg["server_url"] = server_url.rstrip("/")
|
|
34
|
+
cfg["access_token"] = access_token
|
|
35
|
+
cfg["refresh_token"] = refresh_token
|
|
36
|
+
save(cfg)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_tokens(access_token: str, refresh_token: str) -> None:
|
|
40
|
+
cfg = load()
|
|
41
|
+
cfg["access_token"] = access_token
|
|
42
|
+
cfg["refresh_token"] = refresh_token
|
|
43
|
+
save(cfg)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def clear_tokens() -> None:
|
|
47
|
+
cfg = load()
|
|
48
|
+
cfg.pop("access_token", None)
|
|
49
|
+
cfg.pop("refresh_token", None)
|
|
50
|
+
save(cfg)
|