cdmctl 1.5.3__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.
- cdmctl/__init__.py +0 -0
- cdmctl/client.py +91 -0
- cdmctl/config.py +50 -0
- cdmctl/main.py +761 -0
- cdmctl-1.5.3.dist-info/METADATA +8 -0
- cdmctl-1.5.3.dist-info/RECORD +8 -0
- cdmctl-1.5.3.dist-info/WHEEL +4 -0
- cdmctl-1.5.3.dist-info/entry_points.txt +2 -0
cdmctl/__init__.py
ADDED
|
File without changes
|
cdmctl/client.py
ADDED
|
@@ -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)
|
cdmctl/config.py
ADDED
|
@@ -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)
|
cdmctl/main.py
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from importlib.metadata import version as _pkg_version
|
|
6
|
+
from typing import Annotated, Never, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import typer
|
|
10
|
+
from rich import box
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from . import config
|
|
17
|
+
from .client import ( # CLI_USER_AGENT used for pre-auth requests
|
|
18
|
+
CLI_USER_AGENT,
|
|
19
|
+
CDMClient,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="cdm",
|
|
24
|
+
help="CDM Server CLI",
|
|
25
|
+
add_completion=False,
|
|
26
|
+
rich_markup_mode="rich",
|
|
27
|
+
pretty_exceptions_show_locals=False,
|
|
28
|
+
)
|
|
29
|
+
users_app = typer.Typer(help="User management", rich_markup_mode="rich")
|
|
30
|
+
devices_app = typer.Typer(help="Device management", rich_markup_mode="rich")
|
|
31
|
+
status_app = typer.Typer(help="Download status & control", rich_markup_mode="rich")
|
|
32
|
+
tmdb_app = typer.Typer(help="TMDB browse & search", rich_markup_mode="rich")
|
|
33
|
+
wishlist_app = typer.Typer(help="Wishlist management", rich_markup_mode="rich")
|
|
34
|
+
|
|
35
|
+
app.add_typer(users_app, name="users")
|
|
36
|
+
app.add_typer(devices_app, name="devices")
|
|
37
|
+
app.add_typer(status_app, name="status")
|
|
38
|
+
app.add_typer(tmdb_app, name="tmdb")
|
|
39
|
+
app.add_typer(wishlist_app, name="wishlist")
|
|
40
|
+
|
|
41
|
+
out = Console()
|
|
42
|
+
err = Console(stderr=True)
|
|
43
|
+
|
|
44
|
+
BANNER = "[dim]CDM Server CLI[/dim]"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _banner() -> None:
|
|
48
|
+
out.print(Panel(BANNER, border_style="cyan", padding=(0, 2)), height=3)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ok(msg: str) -> None:
|
|
52
|
+
err.print(f"[bold green]✓[/bold green] {msg}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fail(msg: str, code: int = 1) -> Never:
|
|
56
|
+
err.print(f"[bold red]✗[/bold red] {msg}")
|
|
57
|
+
raise SystemExit(code)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _progress_bar(pct: int, width: int = 18) -> str:
|
|
61
|
+
filled = round(width * pct / 100)
|
|
62
|
+
bar = "█" * filled + "░" * (width - filled)
|
|
63
|
+
color = "green" if pct == 100 else "yellow" if pct > 50 else "red"
|
|
64
|
+
return f"[{color}]{bar}[/{color}] [dim]{pct}%[/dim]"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _human_size(b: int) -> str:
|
|
68
|
+
if b == 0:
|
|
69
|
+
return "0 B"
|
|
70
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
71
|
+
i = int(math.floor(math.log(b, 1024)))
|
|
72
|
+
return f"{b / 1024**i:.1f} {units[i]}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _format_local_datetime(value: str) -> str:
|
|
76
|
+
"""Render an ISO timestamp from the API in the local time zone."""
|
|
77
|
+
return datetime.fromisoformat(value).astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _status_color(status: str) -> str:
|
|
81
|
+
s = status.lower()
|
|
82
|
+
if "seeding" in s or "complete" in s:
|
|
83
|
+
return "green"
|
|
84
|
+
if "error" in s:
|
|
85
|
+
return "red"
|
|
86
|
+
if "stopped" in s:
|
|
87
|
+
return "dim"
|
|
88
|
+
if "download" in s or "active" in s:
|
|
89
|
+
return "cyan"
|
|
90
|
+
return "yellow"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ─── Version ─────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command()
|
|
97
|
+
def version() -> None:
|
|
98
|
+
"""Show the CLI version."""
|
|
99
|
+
ver = _pkg_version("cdmctl")
|
|
100
|
+
out.print(f"[bold cyan]cdm[/bold cyan] [bold]{ver}[/bold]")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ─── Auth ────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def login(
|
|
108
|
+
server: Annotated[
|
|
109
|
+
Optional[str], typer.Option("--server", "-s", help="Server URL")
|
|
110
|
+
] = None,
|
|
111
|
+
email: Annotated[Optional[str], typer.Option("--email", "-e")] = None,
|
|
112
|
+
password: Annotated[
|
|
113
|
+
Optional[str], typer.Option("--password", "-p", hide_input=True)
|
|
114
|
+
] = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Login to CDM Server and save credentials."""
|
|
117
|
+
_banner()
|
|
118
|
+
server_url = server or config.get_server_url()
|
|
119
|
+
if not server_url:
|
|
120
|
+
server_url = typer.prompt("Server URL")
|
|
121
|
+
server_url = server_url.rstrip("/")
|
|
122
|
+
|
|
123
|
+
if not email:
|
|
124
|
+
email = typer.prompt("Email")
|
|
125
|
+
if not password:
|
|
126
|
+
password = typer.prompt("Password", hide_input=True)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
resp = httpx.post(
|
|
130
|
+
f"{server_url}/api/auth/login/",
|
|
131
|
+
json={"email": email, "password": password},
|
|
132
|
+
headers={"User-Agent": CLI_USER_AGENT},
|
|
133
|
+
timeout=10,
|
|
134
|
+
)
|
|
135
|
+
except httpx.ConnectError:
|
|
136
|
+
_fail(f"Cannot connect to [bold]{server_url}[/bold]")
|
|
137
|
+
|
|
138
|
+
if resp.status_code == 200:
|
|
139
|
+
data = resp.json()
|
|
140
|
+
config.save_login(server_url, data["access_token"], data["refresh_token"])
|
|
141
|
+
_ok(f"Logged in as [bold]{email}[/bold] → [dim]{server_url}[/dim]")
|
|
142
|
+
elif resp.status_code == 401:
|
|
143
|
+
_fail("Invalid credentials")
|
|
144
|
+
else:
|
|
145
|
+
_fail(f"Login failed ({resp.status_code})")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.command()
|
|
149
|
+
def logout() -> None:
|
|
150
|
+
"""Logout and clear stored tokens."""
|
|
151
|
+
c = CDMClient()
|
|
152
|
+
c.post("/api/auth/logout/")
|
|
153
|
+
config.clear_tokens()
|
|
154
|
+
_ok("Logged out")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.command()
|
|
158
|
+
def whoami() -> None:
|
|
159
|
+
"""Show current user info."""
|
|
160
|
+
c = CDMClient()
|
|
161
|
+
resp = c.get("/api/users/me/")
|
|
162
|
+
if resp.status_code != 200:
|
|
163
|
+
_fail(f"Failed ({resp.status_code})")
|
|
164
|
+
d = resp.json()
|
|
165
|
+
admin_str = "[green]yes[/green]" if d.get("isAdmin") else "[dim]no[/dim]"
|
|
166
|
+
ncore_str = (
|
|
167
|
+
"[green]set[/green]" if d.get("isNcoreCredentialSet") else "[dim]not set[/dim]"
|
|
168
|
+
)
|
|
169
|
+
panel = Panel(
|
|
170
|
+
f"[bold]{d.get('name', '')}[/bold]\n"
|
|
171
|
+
f"[dim]Email:[/dim] {d.get('email', '')}\n"
|
|
172
|
+
f"[dim]Admin:[/dim] {admin_str}\n"
|
|
173
|
+
f"[dim]nCore:[/dim] {ncore_str}",
|
|
174
|
+
title="[bold cyan]Current User[/bold cyan]",
|
|
175
|
+
border_style="cyan",
|
|
176
|
+
padding=(0, 2),
|
|
177
|
+
)
|
|
178
|
+
out.print(panel)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ─── Users ────────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@users_app.command("list")
|
|
185
|
+
def users_list() -> None:
|
|
186
|
+
"""List all users (admin only)."""
|
|
187
|
+
c = CDMClient()
|
|
188
|
+
resp = c.get("/api/users/")
|
|
189
|
+
if resp.status_code == 403:
|
|
190
|
+
_fail("Admin access required")
|
|
191
|
+
if resp.status_code != 200:
|
|
192
|
+
_fail(f"Failed ({resp.status_code})")
|
|
193
|
+
|
|
194
|
+
users = resp.json()["data"]["users"]
|
|
195
|
+
t = Table(box=box.ROUNDED, border_style="cyan", header_style="bold cyan")
|
|
196
|
+
t.add_column("ID", style="dim", width=6)
|
|
197
|
+
t.add_column("Name")
|
|
198
|
+
t.add_column("Email")
|
|
199
|
+
for u in users:
|
|
200
|
+
t.add_row(str(u["id"]), u["name"], u["email"])
|
|
201
|
+
out.print(t)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@users_app.command("add")
|
|
205
|
+
def users_add(
|
|
206
|
+
email: Annotated[str, typer.Option("--email", "-e", prompt=True)],
|
|
207
|
+
name: Annotated[str, typer.Option("--name", "-n", prompt=True)],
|
|
208
|
+
password: Annotated[
|
|
209
|
+
str,
|
|
210
|
+
typer.Option(
|
|
211
|
+
"--password", "-p", prompt=True, hide_input=True, confirmation_prompt=True
|
|
212
|
+
),
|
|
213
|
+
],
|
|
214
|
+
admin: Annotated[bool, typer.Option("--admin/--no-admin")] = False,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Create a new user (admin only)."""
|
|
217
|
+
c = CDMClient()
|
|
218
|
+
resp = c.post(
|
|
219
|
+
"/api/users/",
|
|
220
|
+
json={"email": email, "name": name, "password": password, "isAdmin": admin},
|
|
221
|
+
)
|
|
222
|
+
if resp.status_code == 403:
|
|
223
|
+
_fail("Admin access required")
|
|
224
|
+
if resp.status_code == 200:
|
|
225
|
+
_ok(f"User [bold]{email}[/bold] created")
|
|
226
|
+
else:
|
|
227
|
+
_fail(f"Failed: {resp.json().get('message', resp.status_code)}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@users_app.command("delete")
|
|
231
|
+
def users_delete(
|
|
232
|
+
user_id: Annotated[int, typer.Option("--user-id", "-u", prompt=True)],
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Delete a user by ID (admin only)."""
|
|
235
|
+
c = CDMClient()
|
|
236
|
+
resp = c.delete(f"/api/users/{user_id}/")
|
|
237
|
+
if resp.status_code == 403:
|
|
238
|
+
_fail("Admin access required")
|
|
239
|
+
if resp.status_code == 200:
|
|
240
|
+
_ok(f"User {user_id} deleted")
|
|
241
|
+
else:
|
|
242
|
+
_fail(f"Failed: {resp.json().get('message', resp.status_code)}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@users_app.command("passwd")
|
|
246
|
+
def users_passwd(
|
|
247
|
+
user_id: Annotated[int, typer.Option("--user-id", "-u", prompt=True)],
|
|
248
|
+
password: Annotated[
|
|
249
|
+
str,
|
|
250
|
+
typer.Option(
|
|
251
|
+
"--password", "-p", prompt=True, hide_input=True, confirmation_prompt=True
|
|
252
|
+
),
|
|
253
|
+
],
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Change a user's password (admin only)."""
|
|
256
|
+
c = CDMClient()
|
|
257
|
+
resp = c.patch(f"/api/users/{user_id}/", json={"password": password})
|
|
258
|
+
if resp.status_code == 403:
|
|
259
|
+
_fail("Admin access required")
|
|
260
|
+
if resp.status_code == 200:
|
|
261
|
+
_ok(f"Password updated for user {user_id}")
|
|
262
|
+
else:
|
|
263
|
+
_fail(f"Failed: {resp.json().get('message', resp.status_code)}")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@users_app.command("me")
|
|
267
|
+
def users_me(
|
|
268
|
+
password: Annotated[
|
|
269
|
+
Optional[str], typer.Option("--password", "-p", hide_input=True)
|
|
270
|
+
] = None,
|
|
271
|
+
name: Annotated[Optional[str], typer.Option("--name", "-n")] = None,
|
|
272
|
+
ncore_user: Annotated[Optional[str], typer.Option("--ncore-user")] = None,
|
|
273
|
+
ncore_pass: Annotated[
|
|
274
|
+
Optional[str], typer.Option("--ncore-pass", hide_input=True)
|
|
275
|
+
] = None,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Update own profile."""
|
|
278
|
+
payload: dict = {}
|
|
279
|
+
if password:
|
|
280
|
+
payload["password"] = password
|
|
281
|
+
if name:
|
|
282
|
+
payload["name"] = name
|
|
283
|
+
if ncore_user is not None:
|
|
284
|
+
payload["ncoreUser"] = ncore_user
|
|
285
|
+
if ncore_pass is not None:
|
|
286
|
+
payload["ncorePass"] = ncore_pass
|
|
287
|
+
if not payload:
|
|
288
|
+
_fail("Nothing to update. Provide at least one option.")
|
|
289
|
+
c = CDMClient()
|
|
290
|
+
resp = c.patch("/api/users/me/", json=payload)
|
|
291
|
+
if resp.status_code == 200:
|
|
292
|
+
_ok("Profile updated")
|
|
293
|
+
else:
|
|
294
|
+
_fail(f"Failed: {resp.json().get('message', resp.status_code)}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ─── Devices ─────────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@devices_app.command("list")
|
|
301
|
+
def devices_list() -> None:
|
|
302
|
+
"""List all devices."""
|
|
303
|
+
c = CDMClient()
|
|
304
|
+
resp = c.get("/api/devices/")
|
|
305
|
+
if resp.status_code != 200:
|
|
306
|
+
_fail(f"Failed ({resp.status_code})")
|
|
307
|
+
|
|
308
|
+
devices = resp.json()["data"]["devices"]
|
|
309
|
+
if not devices:
|
|
310
|
+
out.print("[dim]No devices.[/dim]")
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
t = Table(box=box.ROUNDED, border_style="cyan", header_style="bold cyan")
|
|
314
|
+
t.add_column("ID", style="dim", width=6)
|
|
315
|
+
t.add_column("Name")
|
|
316
|
+
t.add_column("Status", width=10)
|
|
317
|
+
t.add_column("Users")
|
|
318
|
+
for d in devices:
|
|
319
|
+
status = "[green]● active[/green]" if d["active"] else "[red]○ inactive[/red]"
|
|
320
|
+
users = ", ".join(d.get("userEmails", [])) or "[dim]—[/dim]"
|
|
321
|
+
t.add_row(str(d["id"]), d["name"], status, users)
|
|
322
|
+
out.print(t)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@devices_app.command("add")
|
|
326
|
+
def devices_add(
|
|
327
|
+
name: Annotated[str, typer.Option("--name", "-n", prompt=True)],
|
|
328
|
+
) -> None:
|
|
329
|
+
"""Add a new device."""
|
|
330
|
+
c = CDMClient()
|
|
331
|
+
resp = c.post("/api/devices/", json={"name": name})
|
|
332
|
+
if resp.status_code == 200:
|
|
333
|
+
_ok(f"Device [bold]{name}[/bold] created")
|
|
334
|
+
elif resp.status_code == 409:
|
|
335
|
+
_fail("Device name already exists")
|
|
336
|
+
else:
|
|
337
|
+
_fail(f"Failed ({resp.status_code})")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@devices_app.command("delete")
|
|
341
|
+
def devices_delete(
|
|
342
|
+
device_id: Annotated[int, typer.Option("--device-id", "-d", prompt=True)],
|
|
343
|
+
) -> None:
|
|
344
|
+
"""Delete a device by ID."""
|
|
345
|
+
c = CDMClient()
|
|
346
|
+
resp = c.delete(f"/api/devices/{device_id}/")
|
|
347
|
+
if resp.status_code == 200:
|
|
348
|
+
_ok(f"Device {device_id} deleted")
|
|
349
|
+
else:
|
|
350
|
+
_fail(f"Failed ({resp.status_code})")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@devices_app.command("token")
|
|
354
|
+
def devices_token(
|
|
355
|
+
device_id: Annotated[int, typer.Option("--device-id", "-d", prompt=True)],
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Show the API token for a device."""
|
|
358
|
+
c = CDMClient()
|
|
359
|
+
resp = c.get("/api/devices/")
|
|
360
|
+
devices = resp.json()["data"]["devices"]
|
|
361
|
+
device = next((d for d in devices if d["id"] == device_id), None)
|
|
362
|
+
if not device:
|
|
363
|
+
_fail(f"Device {device_id} not found")
|
|
364
|
+
out.print(
|
|
365
|
+
Panel(
|
|
366
|
+
f"[bold yellow]{device['token']}[/bold yellow]",
|
|
367
|
+
title=f"[bold cyan]Token — {device['name']}[/bold cyan]",
|
|
368
|
+
border_style="cyan",
|
|
369
|
+
padding=(0, 2),
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ─── Status ──────────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@status_app.callback(invoke_without_command=True)
|
|
378
|
+
def status_default(
|
|
379
|
+
ctx: typer.Context,
|
|
380
|
+
device_id: Annotated[Optional[int], typer.Option("--device", "-d")] = None,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Show download status. [dim]Uses first device if --device omitted.[/dim]"""
|
|
383
|
+
if ctx.invoked_subcommand:
|
|
384
|
+
return
|
|
385
|
+
c = CDMClient()
|
|
386
|
+
resp = c.get("/api/devices/")
|
|
387
|
+
devices = resp.json()["data"]["devices"]
|
|
388
|
+
if not devices:
|
|
389
|
+
out.print("[dim]No devices.[/dim]")
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
if device_id is None:
|
|
393
|
+
device_id = devices[0]["id"]
|
|
394
|
+
dev = next((d for d in devices if d["id"] == device_id), None)
|
|
395
|
+
device_name = dev["name"] if dev else str(device_id)
|
|
396
|
+
|
|
397
|
+
resp = c.get(f"/api/status/{device_id}/")
|
|
398
|
+
if resp.status_code != 200:
|
|
399
|
+
_fail(f"Failed ({resp.status_code})")
|
|
400
|
+
|
|
401
|
+
torrents = resp.json()["data"]["torrents"]
|
|
402
|
+
if not torrents:
|
|
403
|
+
out.print(f"[dim]No active downloads on [bold]{device_name}[/bold].[/dim]")
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
t = Table(
|
|
407
|
+
box=box.ROUNDED,
|
|
408
|
+
border_style="cyan",
|
|
409
|
+
header_style="bold cyan",
|
|
410
|
+
title=f"[bold]{device_name}[/bold]",
|
|
411
|
+
)
|
|
412
|
+
t.add_column("ID", style="dim", width=8)
|
|
413
|
+
t.add_column("Name", min_width=30)
|
|
414
|
+
t.add_column("Progress", min_width=24)
|
|
415
|
+
t.add_column("Size", width=10)
|
|
416
|
+
t.add_column("Status")
|
|
417
|
+
|
|
418
|
+
for tor in torrents:
|
|
419
|
+
color = _status_color(tor["status"])
|
|
420
|
+
t.add_row(
|
|
421
|
+
str(tor["id"]),
|
|
422
|
+
tor["name"],
|
|
423
|
+
Text.from_markup(_progress_bar(tor["progress"])),
|
|
424
|
+
_human_size(tor.get("totalSize", 0)),
|
|
425
|
+
f"[{color}]{tor['status']}[/{color}]",
|
|
426
|
+
)
|
|
427
|
+
out.print(t)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _resolve_device_id(c: CDMClient, device_id: Optional[int]) -> int:
|
|
431
|
+
if device_id is not None:
|
|
432
|
+
return device_id
|
|
433
|
+
devices = c.get("/api/devices/").json()["data"]["devices"]
|
|
434
|
+
if not devices:
|
|
435
|
+
_fail("No devices found")
|
|
436
|
+
return devices[0]["id"]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _send_instruction(
|
|
440
|
+
c: CDMClient,
|
|
441
|
+
device_id: int,
|
|
442
|
+
instruction: str,
|
|
443
|
+
torrent_id: Optional[int] = None,
|
|
444
|
+
paths: Optional[list] = None,
|
|
445
|
+
) -> None:
|
|
446
|
+
body: dict = {"instructions": {}}
|
|
447
|
+
if torrent_id is not None:
|
|
448
|
+
body["instructions"][instruction] = {"torrent_id": torrent_id}
|
|
449
|
+
elif paths is not None:
|
|
450
|
+
body["instructions"][instruction] = {"paths": paths}
|
|
451
|
+
resp = c.post(f"/api/status/{device_id}/instructions/", json=body)
|
|
452
|
+
if resp.status_code == 200:
|
|
453
|
+
_ok(f"[bold]{instruction.capitalize()}[/bold] sent to device {device_id}")
|
|
454
|
+
else:
|
|
455
|
+
_fail(f"Failed ({resp.status_code})")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@status_app.command("start")
|
|
459
|
+
def status_start(
|
|
460
|
+
torrent_id: Annotated[int, typer.Option("--torrent-id", "-t", prompt=True)],
|
|
461
|
+
device_id: Annotated[Optional[int], typer.Option("--device", "-d")] = None,
|
|
462
|
+
) -> None:
|
|
463
|
+
"""Resume a torrent. [dim]Uses first device if --device omitted.[/dim]"""
|
|
464
|
+
c = CDMClient()
|
|
465
|
+
_send_instruction(
|
|
466
|
+
c, _resolve_device_id(c, device_id), "start", torrent_id=torrent_id
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@status_app.command("stop")
|
|
471
|
+
def status_stop(
|
|
472
|
+
torrent_id: Annotated[int, typer.Option("--torrent-id", "-t", prompt=True)],
|
|
473
|
+
device_id: Annotated[Optional[int], typer.Option("--device", "-d")] = None,
|
|
474
|
+
) -> None:
|
|
475
|
+
"""Pause a torrent. [dim]Uses first device if --device omitted.[/dim]"""
|
|
476
|
+
c = CDMClient()
|
|
477
|
+
_send_instruction(
|
|
478
|
+
c, _resolve_device_id(c, device_id), "stop", torrent_id=torrent_id
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@status_app.command("delete")
|
|
483
|
+
def status_delete(
|
|
484
|
+
torrent_id: Annotated[int, typer.Option("--torrent-id", "-t", prompt=True)],
|
|
485
|
+
device_id: Annotated[Optional[int], typer.Option("--device", "-d")] = None,
|
|
486
|
+
) -> None:
|
|
487
|
+
"""Delete a torrent. [dim]Uses first device if --device omitted.[/dim]"""
|
|
488
|
+
c = CDMClient()
|
|
489
|
+
resolved = _resolve_device_id(c, device_id)
|
|
490
|
+
_send_instruction(c, resolved, "delete", torrent_id=torrent_id)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@status_app.command("clean")
|
|
494
|
+
def status_clean(
|
|
495
|
+
device_id: Annotated[Optional[int], typer.Option("--device", "-d")] = None,
|
|
496
|
+
) -> None:
|
|
497
|
+
"""Send clean instruction to device.
|
|
498
|
+
|
|
499
|
+
[dim]Uses first device if --device omitted.[/dim]"""
|
|
500
|
+
c = CDMClient()
|
|
501
|
+
resolved = _resolve_device_id(c, device_id)
|
|
502
|
+
devices = c.get("/api/devices/").json()["data"]["devices"]
|
|
503
|
+
dev = next((d for d in devices if d["id"] == resolved), None)
|
|
504
|
+
if not dev:
|
|
505
|
+
_fail(f"Device {resolved} not found")
|
|
506
|
+
paths = list({v for v in dev["settings"].values() if v})
|
|
507
|
+
_send_instruction(c, resolved, "clean", paths=paths)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# ─── Search / Download ───────────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@app.command()
|
|
514
|
+
def search(
|
|
515
|
+
pattern: Annotated[str, typer.Option("--pattern", "-p", prompt=True)],
|
|
516
|
+
where: Annotated[
|
|
517
|
+
str, typer.Option("--where", "-w", help="name|leiras|imdb|cimke")
|
|
518
|
+
] = "name",
|
|
519
|
+
category: Annotated[
|
|
520
|
+
str, typer.Option("--category", "-c", help="all_own|hd|hd_hun|xvid|...")
|
|
521
|
+
] = "all_own",
|
|
522
|
+
page: Annotated[int, typer.Option("--page")] = 1,
|
|
523
|
+
) -> None:
|
|
524
|
+
"""Search for torrents."""
|
|
525
|
+
c = CDMClient()
|
|
526
|
+
resp = c.get(
|
|
527
|
+
"/api/download/search/",
|
|
528
|
+
params={"pattern": pattern, "where": where, "category": category, "page": page},
|
|
529
|
+
)
|
|
530
|
+
if resp.status_code != 200:
|
|
531
|
+
_fail(f"Search failed ({resp.status_code})")
|
|
532
|
+
|
|
533
|
+
data = resp.json()
|
|
534
|
+
torrents = data["data"]["torrents"]
|
|
535
|
+
total_pages = data["meta"]["totalPages"]
|
|
536
|
+
|
|
537
|
+
if not torrents:
|
|
538
|
+
out.print("[dim]No results.[/dim]")
|
|
539
|
+
return
|
|
540
|
+
|
|
541
|
+
t = Table(
|
|
542
|
+
box=box.ROUNDED,
|
|
543
|
+
border_style="cyan",
|
|
544
|
+
header_style="bold cyan",
|
|
545
|
+
title=f"[bold]Results[/bold] [dim](page {page}/{total_pages})[/dim]",
|
|
546
|
+
)
|
|
547
|
+
t.add_column("ID", style="dim", width=10)
|
|
548
|
+
t.add_column("Title", min_width=35)
|
|
549
|
+
t.add_column("Category", width=14)
|
|
550
|
+
t.add_column("Size", width=10)
|
|
551
|
+
t.add_column("S/L", width=8)
|
|
552
|
+
|
|
553
|
+
for tor in torrents:
|
|
554
|
+
t.add_row(
|
|
555
|
+
str(tor["id"]),
|
|
556
|
+
tor["title"],
|
|
557
|
+
tor.get("category", ""),
|
|
558
|
+
tor.get("size", ""),
|
|
559
|
+
(
|
|
560
|
+
f"[green]{tor.get('seeders', 0)}[/green]"
|
|
561
|
+
f"/[red]{tor.get('leechers', 0)}[/red]"
|
|
562
|
+
),
|
|
563
|
+
)
|
|
564
|
+
out.print(t)
|
|
565
|
+
|
|
566
|
+
if total_pages > 1:
|
|
567
|
+
out.print(
|
|
568
|
+
f"[dim]Use [bold]--page[/bold] to navigate."
|
|
569
|
+
f" Total: {total_pages} pages.[/dim]"
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@app.command()
|
|
574
|
+
def download(
|
|
575
|
+
torrent_id: Annotated[int, typer.Option("--torrent-id", "-t", prompt=True)],
|
|
576
|
+
device_id: Annotated[int, typer.Option("--device-id", "-d", prompt=True)],
|
|
577
|
+
) -> None:
|
|
578
|
+
"""Add a torrent to a device's download queue."""
|
|
579
|
+
c = CDMClient()
|
|
580
|
+
resp = c.post(
|
|
581
|
+
"/api/download/", json={"torrentId": torrent_id, "deviceId": device_id}
|
|
582
|
+
)
|
|
583
|
+
if resp.status_code == 200:
|
|
584
|
+
_ok(
|
|
585
|
+
f"Torrent [bold]{torrent_id}[/bold]"
|
|
586
|
+
f" queued on device [bold]{device_id}[/bold]"
|
|
587
|
+
)
|
|
588
|
+
else:
|
|
589
|
+
_fail(f"Failed ({resp.status_code})")
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# ─── TMDB ────────────────────────────────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _print_tmdb_table(items: list, title: str) -> None:
|
|
596
|
+
if not items:
|
|
597
|
+
out.print(f"[dim]No {title.lower()}.[/dim]")
|
|
598
|
+
return
|
|
599
|
+
t = Table(
|
|
600
|
+
box=box.ROUNDED,
|
|
601
|
+
border_style="magenta",
|
|
602
|
+
header_style="bold magenta",
|
|
603
|
+
title=f"[bold]{title}[/bold]",
|
|
604
|
+
)
|
|
605
|
+
t.add_column("TMDB ID", style="dim", width=10)
|
|
606
|
+
t.add_column("IMDB ID", style="dim", width=10)
|
|
607
|
+
t.add_column("Title", min_width=30)
|
|
608
|
+
t.add_column("Year", width=6)
|
|
609
|
+
t.add_column("Type", width=8)
|
|
610
|
+
t.add_column("Rating", width=7)
|
|
611
|
+
for item in items:
|
|
612
|
+
rating = f"{item['rating']:.1f}" if item.get("rating") else "—"
|
|
613
|
+
t.add_row(
|
|
614
|
+
str(item["tmdbId"]),
|
|
615
|
+
str(item["imdbId"] or "-"),
|
|
616
|
+
item["title"],
|
|
617
|
+
str(item.get("year") or "—"),
|
|
618
|
+
item.get("mediaType", ""),
|
|
619
|
+
f"[yellow]★ {rating}[/yellow]",
|
|
620
|
+
)
|
|
621
|
+
out.print(t)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
@tmdb_app.command("search")
|
|
625
|
+
def tmdb_search(
|
|
626
|
+
pattern: Annotated[str, typer.Option("--pattern", "-p", prompt=True)],
|
|
627
|
+
language: Annotated[str, typer.Option("--lang", "-l")] = "en",
|
|
628
|
+
page: Annotated[int, typer.Option("--page")] = 1,
|
|
629
|
+
) -> None:
|
|
630
|
+
"""Search TMDB."""
|
|
631
|
+
c = CDMClient()
|
|
632
|
+
resp = c.get(
|
|
633
|
+
"/api/tmdb/search/",
|
|
634
|
+
params={"pattern": pattern, "page": page, "language": language},
|
|
635
|
+
)
|
|
636
|
+
if resp.status_code != 200:
|
|
637
|
+
_fail(f"Failed ({resp.status_code})")
|
|
638
|
+
data = resp.json()
|
|
639
|
+
total = data["meta"]["totalPages"]
|
|
640
|
+
_print_tmdb_table(data["data"], f"TMDB: {pattern} (page {page}/{total})")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@tmdb_app.command("popular")
|
|
644
|
+
def tmdb_popular(language: Annotated[str, typer.Option("--lang", "-l")] = "en") -> None:
|
|
645
|
+
"""Show popular movies and series."""
|
|
646
|
+
c = CDMClient()
|
|
647
|
+
resp = c.get("/api/tmdb/popular/", params={"language": language})
|
|
648
|
+
if resp.status_code != 200:
|
|
649
|
+
_fail(f"Failed ({resp.status_code})")
|
|
650
|
+
data = resp.json()["data"]
|
|
651
|
+
_print_tmdb_table(data["movies"], "Popular Movies")
|
|
652
|
+
out.print()
|
|
653
|
+
_print_tmdb_table(data["tvs"], "Popular Series")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ─── Wishlist ─────────────────────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@wishlist_app.command("list")
|
|
660
|
+
def wishlist_list() -> None:
|
|
661
|
+
"""List wishlist items."""
|
|
662
|
+
c = CDMClient()
|
|
663
|
+
resp = c.get("/api/wishlist/")
|
|
664
|
+
if resp.status_code != 200:
|
|
665
|
+
_fail(f"Failed ({resp.status_code})")
|
|
666
|
+
|
|
667
|
+
items = resp.json()["data"]
|
|
668
|
+
if not items:
|
|
669
|
+
out.print("[dim]Wishlist is empty.[/dim]")
|
|
670
|
+
return
|
|
671
|
+
|
|
672
|
+
t = Table(box=box.ROUNDED, border_style="magenta", header_style="bold magenta")
|
|
673
|
+
t.add_column("ID", style="dim", width=6)
|
|
674
|
+
t.add_column("Title", min_width=30)
|
|
675
|
+
t.add_column("IMDB ID", width=12)
|
|
676
|
+
t.add_column("Device", width=14)
|
|
677
|
+
t.add_column("Type", width=12)
|
|
678
|
+
t.add_column("Added", width=20)
|
|
679
|
+
for item in items:
|
|
680
|
+
t.add_row(
|
|
681
|
+
str(item["id"]),
|
|
682
|
+
item["title"],
|
|
683
|
+
item["imdbId"],
|
|
684
|
+
item["deviceName"],
|
|
685
|
+
item["torrentType"],
|
|
686
|
+
_format_local_datetime(item["createdAt"]),
|
|
687
|
+
)
|
|
688
|
+
out.print(t)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@wishlist_app.command("types")
|
|
692
|
+
def wishlist_types() -> None:
|
|
693
|
+
"""List available torrent types for wishlist."""
|
|
694
|
+
c = CDMClient()
|
|
695
|
+
resp = c.get("/api/wishlist/types/")
|
|
696
|
+
if resp.status_code != 200:
|
|
697
|
+
_fail(f"Failed ({resp.status_code})")
|
|
698
|
+
types = resp.json()["data"]
|
|
699
|
+
for t in types:
|
|
700
|
+
out.print(f" [bold cyan]{t}[/bold cyan]")
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
@wishlist_app.command("add")
|
|
704
|
+
def wishlist_add(
|
|
705
|
+
imdb_id: Annotated[
|
|
706
|
+
str,
|
|
707
|
+
typer.Option("--imdb-id", "-i", prompt=True, help="IMDB ID (e.g. tt1234567)"),
|
|
708
|
+
],
|
|
709
|
+
device_id: Annotated[int, typer.Option("--device-id", "-d", prompt=True)],
|
|
710
|
+
torrent_type: Annotated[
|
|
711
|
+
str,
|
|
712
|
+
typer.Option(
|
|
713
|
+
"--type",
|
|
714
|
+
"-t",
|
|
715
|
+
prompt=True,
|
|
716
|
+
help="Torrent type (run 'wishlist types' to list)",
|
|
717
|
+
),
|
|
718
|
+
],
|
|
719
|
+
) -> None:
|
|
720
|
+
"""Add a movie to the wishlist by IMDB ID."""
|
|
721
|
+
c = CDMClient()
|
|
722
|
+
resp = c.post(
|
|
723
|
+
"/api/wishlist/",
|
|
724
|
+
json={"imdb_id": imdb_id, "device_id": device_id, "torrent_type": torrent_type},
|
|
725
|
+
)
|
|
726
|
+
if resp.status_code == 200:
|
|
727
|
+
_ok(f"[bold]{imdb_id}[/bold] added to wishlist")
|
|
728
|
+
elif resp.status_code == 409:
|
|
729
|
+
_fail("Already in wishlist")
|
|
730
|
+
elif resp.status_code == 404:
|
|
731
|
+
_fail("Device not found")
|
|
732
|
+
elif resp.status_code == 400:
|
|
733
|
+
_fail(f"Bad request: {resp.json().get('message', '')}")
|
|
734
|
+
else:
|
|
735
|
+
_fail(f"Failed ({resp.status_code})")
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
@wishlist_app.command("start")
|
|
739
|
+
def wishlist_start() -> None:
|
|
740
|
+
"""Start the download task for the whole wishlist."""
|
|
741
|
+
c = CDMClient()
|
|
742
|
+
resp = c.post("/api/wishlist/start/")
|
|
743
|
+
if resp.status_code == 200:
|
|
744
|
+
_ok("Wishlist task started")
|
|
745
|
+
else:
|
|
746
|
+
_fail(f"Failed ({resp.status_code})")
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
@wishlist_app.command("delete")
|
|
750
|
+
def wishlist_delete(
|
|
751
|
+
item_id: Annotated[int, typer.Option("--item-id", "-i", prompt=True)],
|
|
752
|
+
) -> None:
|
|
753
|
+
"""Remove an item from the wishlist by ID."""
|
|
754
|
+
c = CDMClient()
|
|
755
|
+
resp = c.delete(f"/api/wishlist/{item_id}/")
|
|
756
|
+
if resp.status_code == 200:
|
|
757
|
+
_ok(f"Wishlist item {item_id} removed")
|
|
758
|
+
elif resp.status_code == 404:
|
|
759
|
+
_fail("Item not found")
|
|
760
|
+
else:
|
|
761
|
+
_fail(f"Failed ({resp.status_code})")
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
cdmctl/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
cdmctl/client.py,sha256=_1r5CNMdn_RqP2A072d7p6En-t3ECFEp_dFFComtGZw,3029
|
|
3
|
+
cdmctl/config.py,sha256=kyvTcumYufEuNy_aqJBNsXzNjzTNvkZQCtR6Tyy2sNI,1186
|
|
4
|
+
cdmctl/main.py,sha256=GaJAsgm_YtOzWRb_pMelz5Z21R18Uv5uZMnPPNYLpFU,24750
|
|
5
|
+
cdmctl-1.5.3.dist-info/METADATA,sha256=YTCYM_Yif8fAOlryx0h1Ut_IHOHMHjpOhpISH9IfFZE,206
|
|
6
|
+
cdmctl-1.5.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
cdmctl-1.5.3.dist-info/entry_points.txt,sha256=3j0X3P3w5zTmxDueOSQpusMvvtDvzMR_6unXbTYbV84,40
|
|
8
|
+
cdmctl-1.5.3.dist-info/RECORD,,
|