torsh 0.1.0__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.
- torsh-0.1.0/PKG-INFO +51 -0
- torsh-0.1.0/README.md +38 -0
- torsh-0.1.0/pyproject.toml +30 -0
- torsh-0.1.0/setup.cfg +4 -0
- torsh-0.1.0/torsh/__init__.py +5 -0
- torsh-0.1.0/torsh/cli.py +62 -0
- torsh-0.1.0/torsh/client.py +175 -0
- torsh-0.1.0/torsh/config.py +139 -0
- torsh-0.1.0/torsh/daemon.py +158 -0
- torsh-0.1.0/torsh/logging.py +37 -0
- torsh-0.1.0/torsh/ui/__init__.py +3 -0
- torsh-0.1.0/torsh/ui/app.py +812 -0
- torsh-0.1.0/torsh/ui/styles.tcss +90 -0
- torsh-0.1.0/torsh.egg-info/PKG-INFO +51 -0
- torsh-0.1.0/torsh.egg-info/SOURCES.txt +17 -0
- torsh-0.1.0/torsh.egg-info/dependency_links.txt +1 -0
- torsh-0.1.0/torsh.egg-info/entry_points.txt +2 -0
- torsh-0.1.0/torsh.egg-info/requires.txt +5 -0
- torsh-0.1.0/torsh.egg-info/top_level.txt +3 -0
torsh-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: torsh
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Transmission TUI client
|
|
5
|
+
Author: torsh authors
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: textual>=0.60.0
|
|
9
|
+
Requires-Dist: transmission-rpc>=4.4.3
|
|
10
|
+
Requires-Dist: click>=8.1.7
|
|
11
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
12
|
+
Requires-Dist: humanize>=4.9.0
|
|
13
|
+
|
|
14
|
+
# torsh — Transmission TUI
|
|
15
|
+
|
|
16
|
+
One-command Transmission client (btop-style). Runs `torsh`; it will start Transmission (and can auto-install it) or connect to an existing RPC.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
- pipx (recommended): `pipx install torsh`
|
|
20
|
+
- pip: `python3 -m pip install torsh`
|
|
21
|
+
|
|
22
|
+
Needs Python 3.10+. If `transmission-daemon` is missing, torsh will try to install it via the available package manager (can be disabled).
|
|
23
|
+
|
|
24
|
+
## Run
|
|
25
|
+
```bash
|
|
26
|
+
torsh
|
|
27
|
+
# optional:
|
|
28
|
+
# torsh --host localhost --port 9091 --download-dir ~/Downloads/torrents --no-autostart --no-install-missing
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Keys
|
|
32
|
+
- `a` add magnet/.torrent (choose download dir)
|
|
33
|
+
- `space` pause/resume
|
|
34
|
+
- `d` delete (ask about data)
|
|
35
|
+
- `g` change download dir
|
|
36
|
+
- `s` set speed limits
|
|
37
|
+
- `p` set file priorities
|
|
38
|
+
- `l` view daemon log (auto-refresh tail)
|
|
39
|
+
- `/` filter by name, `]`/`[` adjust refresh rate, `r` manual refresh
|
|
40
|
+
- `?` help, `q` quit
|
|
41
|
+
- UI remembers filters/sort/refresh interval between sessions.
|
|
42
|
+
|
|
43
|
+
## Config
|
|
44
|
+
Auto-created at `~/.config/torsh/config.yaml`. Env/flags override:
|
|
45
|
+
`TORSH_HOST`, `TORSH_PORT`, `TORSH_USER`, `TORSH_PASSWORD`, `TORSH_TIMEOUT`,
|
|
46
|
+
`TORSH_DOWNLOAD_DIR`, `TORSH_INSTALL_MISSING`, `TORSH_RESTART_ON_FAIL`, `TORSH_LOG`.
|
|
47
|
+
|
|
48
|
+
Packaging notes: see `PACKAGING.md` (sdist/wheel, deb/rpm via fpm, brew formula template).
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
MIT.
|
torsh-0.1.0/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# torsh — Transmission TUI
|
|
2
|
+
|
|
3
|
+
One-command Transmission client (btop-style). Runs `torsh`; it will start Transmission (and can auto-install it) or connect to an existing RPC.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
- pipx (recommended): `pipx install torsh`
|
|
7
|
+
- pip: `python3 -m pip install torsh`
|
|
8
|
+
|
|
9
|
+
Needs Python 3.10+. If `transmission-daemon` is missing, torsh will try to install it via the available package manager (can be disabled).
|
|
10
|
+
|
|
11
|
+
## Run
|
|
12
|
+
```bash
|
|
13
|
+
torsh
|
|
14
|
+
# optional:
|
|
15
|
+
# torsh --host localhost --port 9091 --download-dir ~/Downloads/torrents --no-autostart --no-install-missing
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Keys
|
|
19
|
+
- `a` add magnet/.torrent (choose download dir)
|
|
20
|
+
- `space` pause/resume
|
|
21
|
+
- `d` delete (ask about data)
|
|
22
|
+
- `g` change download dir
|
|
23
|
+
- `s` set speed limits
|
|
24
|
+
- `p` set file priorities
|
|
25
|
+
- `l` view daemon log (auto-refresh tail)
|
|
26
|
+
- `/` filter by name, `]`/`[` adjust refresh rate, `r` manual refresh
|
|
27
|
+
- `?` help, `q` quit
|
|
28
|
+
- UI remembers filters/sort/refresh interval between sessions.
|
|
29
|
+
|
|
30
|
+
## Config
|
|
31
|
+
Auto-created at `~/.config/torsh/config.yaml`. Env/flags override:
|
|
32
|
+
`TORSH_HOST`, `TORSH_PORT`, `TORSH_USER`, `TORSH_PASSWORD`, `TORSH_TIMEOUT`,
|
|
33
|
+
`TORSH_DOWNLOAD_DIR`, `TORSH_INSTALL_MISSING`, `TORSH_RESTART_ON_FAIL`, `TORSH_LOG`.
|
|
34
|
+
|
|
35
|
+
Packaging notes: see `PACKAGING.md` (sdist/wheel, deb/rpm via fpm, brew formula template).
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
MIT.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "torsh"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Transmission TUI client"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "torsh authors" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"textual>=0.60.0",
|
|
14
|
+
"transmission-rpc>=4.4.3",
|
|
15
|
+
"click>=8.1.7",
|
|
16
|
+
"pyyaml>=6.0.1",
|
|
17
|
+
"humanize>=4.9.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
torsh = "torsh.cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["."]
|
|
25
|
+
exclude = ["tests*"]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.package-data]
|
|
28
|
+
"torsh.ui" = ["styles.tcss"]
|
|
29
|
+
|
|
30
|
+
|
torsh-0.1.0/setup.cfg
ADDED
torsh-0.1.0/torsh/cli.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .config import AppConfig, load_config, save_config
|
|
9
|
+
from .daemon import maybe_start_daemon
|
|
10
|
+
from .logging import get_logger
|
|
11
|
+
from .ui.app import TorshApp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
LOG = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _apply_overrides(config: AppConfig, host: str, port: int, user: str | None, password: str | None, download_dir: str | None):
|
|
18
|
+
if host:
|
|
19
|
+
config.rpc.host = host
|
|
20
|
+
if port is not None:
|
|
21
|
+
config.rpc.port = port
|
|
22
|
+
if user is not None:
|
|
23
|
+
config.rpc.username = user
|
|
24
|
+
if password is not None:
|
|
25
|
+
config.rpc.password = password
|
|
26
|
+
if download_dir:
|
|
27
|
+
config.paths.download_dir = Path(download_dir).expanduser()
|
|
28
|
+
save_config(config)
|
|
29
|
+
return config
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
33
|
+
@click.option("--host", default=None, help="Transmission RPC host (default: localhost)")
|
|
34
|
+
@click.option("--port", default=None, type=int, help="RPC port (default: 9091)")
|
|
35
|
+
@click.option("--user", default=None, help="RPC username")
|
|
36
|
+
@click.option("--password", default=None, help="RPC password")
|
|
37
|
+
@click.option("--download-dir", default=None, help="Default download directory")
|
|
38
|
+
@click.option("--no-autostart", is_flag=True, help="Do not auto-start transmission-daemon")
|
|
39
|
+
@click.option("--no-install-missing", is_flag=True, help="Skip auto-install of transmission")
|
|
40
|
+
@click.version_option(__version__, "-v", "--version", message="torsh %(version)s")
|
|
41
|
+
def main(host: Optional[str], port: Optional[int], user: Optional[str], password: Optional[str], download_dir: Optional[str], no_autostart: bool, no_install_missing: bool):
|
|
42
|
+
"""Run torsh TUI."""
|
|
43
|
+
config = load_config()
|
|
44
|
+
config = _apply_overrides(config, host, port, user, password, download_dir)
|
|
45
|
+
if no_autostart:
|
|
46
|
+
config.daemon.autostart = False
|
|
47
|
+
if no_install_missing:
|
|
48
|
+
config.daemon.install_missing = False
|
|
49
|
+
|
|
50
|
+
maybe_start_daemon(config)
|
|
51
|
+
|
|
52
|
+
app = TorshApp(config=config)
|
|
53
|
+
try:
|
|
54
|
+
asyncio.run(app.run_async())
|
|
55
|
+
except KeyboardInterrupt:
|
|
56
|
+
LOG.info("Interrupted by user (Ctrl+C)")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
main()
|
|
61
|
+
|
|
62
|
+
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Iterable, List, Optional
|
|
5
|
+
|
|
6
|
+
import humanize
|
|
7
|
+
from transmission_rpc import Client, TransmissionError, Torrent
|
|
8
|
+
|
|
9
|
+
from .config import AppConfig
|
|
10
|
+
from .logging import get_logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
LOG = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TorrentView:
|
|
18
|
+
id: int
|
|
19
|
+
name: str
|
|
20
|
+
percent_done: float
|
|
21
|
+
status: str
|
|
22
|
+
eta: str
|
|
23
|
+
rate_down: str
|
|
24
|
+
rate_up: str
|
|
25
|
+
ratio: float
|
|
26
|
+
size: str
|
|
27
|
+
added: datetime | None
|
|
28
|
+
download_dir: str
|
|
29
|
+
peers: int
|
|
30
|
+
seeders: int
|
|
31
|
+
leechers: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TransmissionController:
|
|
35
|
+
def __init__(self, config: AppConfig):
|
|
36
|
+
self.config = config
|
|
37
|
+
self._client: Client | None = None
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def client(self) -> Client:
|
|
41
|
+
if self._client is None:
|
|
42
|
+
self._client = Client(
|
|
43
|
+
host=self.config.rpc.host,
|
|
44
|
+
port=self.config.rpc.port,
|
|
45
|
+
username=self.config.rpc.username,
|
|
46
|
+
password=self.config.rpc.password,
|
|
47
|
+
timeout=self.config.rpc.timeout,
|
|
48
|
+
)
|
|
49
|
+
return self._client
|
|
50
|
+
|
|
51
|
+
def reset(self) -> None:
|
|
52
|
+
self._client = None
|
|
53
|
+
|
|
54
|
+
async def _to_thread(self, func, *args, **kwargs):
|
|
55
|
+
return await asyncio.to_thread(func, *args, **kwargs)
|
|
56
|
+
|
|
57
|
+
async def ensure_connected(self) -> None:
|
|
58
|
+
try:
|
|
59
|
+
await self._to_thread(self.client.get_session)
|
|
60
|
+
except TransmissionError:
|
|
61
|
+
self.reset()
|
|
62
|
+
raise
|
|
63
|
+
except Exception:
|
|
64
|
+
self.reset()
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
async def list_torrents(self) -> List[TorrentView]:
|
|
68
|
+
torrents = await self._to_thread(self.client.get_torrents)
|
|
69
|
+
views: list[TorrentView] = []
|
|
70
|
+
for t in torrents:
|
|
71
|
+
views.append(self._map_torrent(t))
|
|
72
|
+
return views
|
|
73
|
+
|
|
74
|
+
async def session_stats(self):
|
|
75
|
+
return await self._to_thread(self.client.get_session_stats)
|
|
76
|
+
|
|
77
|
+
async def add(self, link: str, download_dir: Optional[str] = None) -> Torrent:
|
|
78
|
+
return await self._to_thread(
|
|
79
|
+
self.client.add_torrent,
|
|
80
|
+
link,
|
|
81
|
+
download_dir=download_dir or str(self.config.paths.download_dir),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def start(self, ids: Iterable[int]):
|
|
85
|
+
return await self._to_thread(self.client.start_torrent, ids)
|
|
86
|
+
|
|
87
|
+
async def stop(self, ids: Iterable[int]):
|
|
88
|
+
return await self._to_thread(self.client.stop_torrent, ids)
|
|
89
|
+
|
|
90
|
+
async def remove(self, ids: Iterable[int], delete_data: bool = False):
|
|
91
|
+
return await self._to_thread(self.client.remove_torrent, ids, delete_data=delete_data)
|
|
92
|
+
|
|
93
|
+
async def move(self, ids: Iterable[int], location: str, move_data: bool = True):
|
|
94
|
+
return await self._to_thread(self.client.move_torrent_data, ids, location=location, move=move_data)
|
|
95
|
+
|
|
96
|
+
async def verify(self, ids: Iterable[int]):
|
|
97
|
+
return await self._to_thread(self.client.verify_torrent, ids)
|
|
98
|
+
|
|
99
|
+
async def get_speed_limits(self) -> dict:
|
|
100
|
+
session = await self._to_thread(self.client.get_session)
|
|
101
|
+
return {
|
|
102
|
+
"down": session.download_speed_limit if session.speed_limit_down_enabled else 0,
|
|
103
|
+
"up": session.upload_speed_limit if session.speed_limit_up_enabled else 0,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async def set_speed_limits(self, down_kib: int | None, up_kib: int | None):
|
|
107
|
+
kwargs = {}
|
|
108
|
+
if down_kib is not None:
|
|
109
|
+
kwargs["speed_limit_down_enabled"] = down_kib > 0
|
|
110
|
+
kwargs["download_speed_limit"] = max(0, down_kib)
|
|
111
|
+
if up_kib is not None:
|
|
112
|
+
kwargs["speed_limit_up_enabled"] = up_kib > 0
|
|
113
|
+
kwargs["upload_speed_limit"] = max(0, up_kib)
|
|
114
|
+
if kwargs:
|
|
115
|
+
await self._to_thread(self.client.set_session, **kwargs)
|
|
116
|
+
|
|
117
|
+
async def get_files(self, torrent_id: int) -> dict[int, dict]:
|
|
118
|
+
torrent = await self._to_thread(self.client.get_torrent, torrent_id)
|
|
119
|
+
files_attr = getattr(torrent, "files", {})
|
|
120
|
+
files = files_attr() if callable(files_attr) else files_attr # type: ignore[misc]
|
|
121
|
+
return files or {}
|
|
122
|
+
|
|
123
|
+
async def set_priority(self, torrent_id: int, high: list[int], normal: list[int], low: list[int]):
|
|
124
|
+
await self._to_thread(
|
|
125
|
+
self.client.set_torrent,
|
|
126
|
+
torrent_id,
|
|
127
|
+
priority_high=high or None,
|
|
128
|
+
priority_normal=normal or None,
|
|
129
|
+
priority_low=low or None,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
async def set_torrent_speed(self, torrent_id: int, down_kib: int | None, up_kib: int | None):
|
|
133
|
+
kwargs = {}
|
|
134
|
+
if down_kib is not None:
|
|
135
|
+
kwargs["downloadLimit"] = max(0, down_kib)
|
|
136
|
+
kwargs["downloadLimited"] = down_kib > 0
|
|
137
|
+
if up_kib is not None:
|
|
138
|
+
kwargs["uploadLimit"] = max(0, up_kib)
|
|
139
|
+
kwargs["uploadLimited"] = up_kib > 0
|
|
140
|
+
if kwargs:
|
|
141
|
+
await self._to_thread(self.client.set_torrent, torrent_id, **kwargs)
|
|
142
|
+
|
|
143
|
+
async def get_torrent_speed(self, torrent_id: int) -> dict[str, int]:
|
|
144
|
+
torrent = await self._to_thread(self.client.get_torrent, torrent_id)
|
|
145
|
+
down = getattr(torrent, "download_limit", 0) or 0
|
|
146
|
+
up = getattr(torrent, "upload_limit", 0) or 0
|
|
147
|
+
if getattr(torrent, "download_limited", False) is False:
|
|
148
|
+
down = 0
|
|
149
|
+
if getattr(torrent, "upload_limited", False) is False:
|
|
150
|
+
up = 0
|
|
151
|
+
return {"down": int(down), "up": int(up)}
|
|
152
|
+
|
|
153
|
+
def _map_torrent(self, t: Torrent) -> TorrentView:
|
|
154
|
+
eta = "—"
|
|
155
|
+
if t.eta and t.eta > 0:
|
|
156
|
+
eta = humanize.naturaldelta(t.eta)
|
|
157
|
+
|
|
158
|
+
return TorrentView(
|
|
159
|
+
id=t.id,
|
|
160
|
+
name=t.name,
|
|
161
|
+
percent_done=float(t.progress),
|
|
162
|
+
status=str(t.status),
|
|
163
|
+
eta=eta,
|
|
164
|
+
rate_down=humanize.naturalsize(t.rate_download) + "/s",
|
|
165
|
+
rate_up=humanize.naturalsize(t.rate_upload) + "/s",
|
|
166
|
+
ratio=float(t.ratio or 0),
|
|
167
|
+
size=humanize.naturalsize(t.total_size or 0),
|
|
168
|
+
added=t.date_added,
|
|
169
|
+
download_dir=t.download_dir,
|
|
170
|
+
peers=len(t.peers) if t.peers else 0,
|
|
171
|
+
seeders=t.seeders or 0,
|
|
172
|
+
leechers=t.leechers or 0,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
CONFIG_DIR = Path(os.environ.get("TORSH_CONFIG_DIR", "~/.config/torsh")).expanduser()
|
|
10
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RpcConfig:
|
|
15
|
+
host: str = os.environ.get("TORSH_HOST", "localhost")
|
|
16
|
+
port: int = int(os.environ.get("TORSH_PORT", "9091"))
|
|
17
|
+
username: str | None = os.environ.get("TORSH_USER") or None
|
|
18
|
+
password: str | None = os.environ.get("TORSH_PASSWORD") or None
|
|
19
|
+
timeout: float = float(os.environ.get("TORSH_TIMEOUT", "10.0"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class DaemonConfig:
|
|
24
|
+
autostart: bool = os.environ.get("TORSH_AUTOSTART", "true").lower() != "false"
|
|
25
|
+
binary: str = os.environ.get("TORSH_DAEMON", "transmission-daemon")
|
|
26
|
+
extra_args: list[str] = field(default_factory=list)
|
|
27
|
+
install_missing: bool = os.environ.get("TORSH_INSTALL_MISSING", "true").lower() != "false"
|
|
28
|
+
restart_on_fail: bool = os.environ.get("TORSH_RESTART_ON_FAIL", "true").lower() != "false"
|
|
29
|
+
log_path: Path = Path(os.environ.get("TORSH_LOG", "") or (CONFIG_DIR / "daemon.log")).expanduser()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class PathConfig:
|
|
34
|
+
download_dir: Path = Path(os.environ.get("TORSH_DOWNLOAD_DIR", "~/Downloads/torrents")).expanduser()
|
|
35
|
+
config_dir: Path = CONFIG_DIR
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class UIConfig:
|
|
40
|
+
refresh_interval: float = 2.5
|
|
41
|
+
sort_column: int | None = None
|
|
42
|
+
sort_desc: bool = False
|
|
43
|
+
filter_text: str = ""
|
|
44
|
+
status_filter: str = "any"
|
|
45
|
+
progress_filter: str = "any"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class AppConfig:
|
|
50
|
+
rpc: RpcConfig = field(default_factory=RpcConfig)
|
|
51
|
+
daemon: DaemonConfig = field(default_factory=DaemonConfig)
|
|
52
|
+
paths: PathConfig = field(default_factory=PathConfig)
|
|
53
|
+
ui: UIConfig = field(default_factory=UIConfig)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ensure_config_dir() -> None:
|
|
57
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_config() -> AppConfig:
|
|
61
|
+
ensure_config_dir()
|
|
62
|
+
if CONFIG_FILE.exists():
|
|
63
|
+
data = yaml.safe_load(CONFIG_FILE.read_text()) or {}
|
|
64
|
+
else:
|
|
65
|
+
data = {}
|
|
66
|
+
|
|
67
|
+
rpc_data: Dict[str, Any] = data.get("rpc", {})
|
|
68
|
+
daemon_data: Dict[str, Any] = data.get("daemon", {})
|
|
69
|
+
paths_data: Dict[str, Any] = data.get("paths", {})
|
|
70
|
+
ui_data: Dict[str, Any] = data.get("ui", {})
|
|
71
|
+
|
|
72
|
+
config = AppConfig(
|
|
73
|
+
rpc=RpcConfig(
|
|
74
|
+
host=rpc_data.get("host", RpcConfig().host),
|
|
75
|
+
port=int(rpc_data.get("port", RpcConfig().port)),
|
|
76
|
+
username=rpc_data.get("username", RpcConfig().username),
|
|
77
|
+
password=rpc_data.get("password", RpcConfig().password),
|
|
78
|
+
timeout=float(rpc_data.get("timeout", RpcConfig().timeout)),
|
|
79
|
+
),
|
|
80
|
+
daemon=DaemonConfig(
|
|
81
|
+
autostart=bool(daemon_data.get("autostart", DaemonConfig().autostart)),
|
|
82
|
+
binary=daemon_data.get("binary", DaemonConfig().binary),
|
|
83
|
+
extra_args=daemon_data.get("extra_args", DaemonConfig().extra_args),
|
|
84
|
+
install_missing=bool(daemon_data.get("install_missing", DaemonConfig().install_missing)),
|
|
85
|
+
restart_on_fail=bool(daemon_data.get("restart_on_fail", DaemonConfig().restart_on_fail)),
|
|
86
|
+
log_path=Path(daemon_data.get("log_path", DaemonConfig().log_path)).expanduser(),
|
|
87
|
+
),
|
|
88
|
+
paths=PathConfig(
|
|
89
|
+
download_dir=Path(paths_data.get("download_dir", PathConfig().download_dir)).expanduser(),
|
|
90
|
+
config_dir=Path(paths_data.get("config_dir", PathConfig().config_dir)).expanduser(),
|
|
91
|
+
),
|
|
92
|
+
ui=UIConfig(
|
|
93
|
+
refresh_interval=float(ui_data.get("refresh_interval", UIConfig().refresh_interval)),
|
|
94
|
+
sort_column=ui_data.get("sort_column", UIConfig().sort_column),
|
|
95
|
+
sort_desc=bool(ui_data.get("sort_desc", UIConfig().sort_desc)),
|
|
96
|
+
filter_text=ui_data.get("filter_text", UIConfig().filter_text),
|
|
97
|
+
status_filter=ui_data.get("status_filter", UIConfig().status_filter),
|
|
98
|
+
progress_filter=ui_data.get("progress_filter", UIConfig().progress_filter),
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
save_config(config)
|
|
103
|
+
return config
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def save_config(config: AppConfig) -> None:
|
|
107
|
+
ensure_config_dir()
|
|
108
|
+
payload = {
|
|
109
|
+
"rpc": {
|
|
110
|
+
"host": config.rpc.host,
|
|
111
|
+
"port": config.rpc.port,
|
|
112
|
+
"username": config.rpc.username or "",
|
|
113
|
+
"password": config.rpc.password or "",
|
|
114
|
+
"timeout": config.rpc.timeout,
|
|
115
|
+
},
|
|
116
|
+
"daemon": {
|
|
117
|
+
"autostart": config.daemon.autostart,
|
|
118
|
+
"binary": config.daemon.binary,
|
|
119
|
+
"extra_args": config.daemon.extra_args,
|
|
120
|
+
"install_missing": config.daemon.install_missing,
|
|
121
|
+
"restart_on_fail": config.daemon.restart_on_fail,
|
|
122
|
+
"log_path": str(config.daemon.log_path),
|
|
123
|
+
},
|
|
124
|
+
"paths": {
|
|
125
|
+
"download_dir": str(config.paths.download_dir),
|
|
126
|
+
"config_dir": str(config.paths.config_dir),
|
|
127
|
+
},
|
|
128
|
+
"ui": {
|
|
129
|
+
"refresh_interval": config.ui.refresh_interval,
|
|
130
|
+
"sort_column": config.ui.sort_column,
|
|
131
|
+
"sort_desc": config.ui.sort_desc,
|
|
132
|
+
"filter_text": config.ui.filter_text,
|
|
133
|
+
"status_filter": config.ui.status_filter,
|
|
134
|
+
"progress_filter": config.ui.progress_filter,
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
CONFIG_FILE.write_text(yaml.safe_dump(payload, sort_keys=False, allow_unicode=False))
|
|
138
|
+
|
|
139
|
+
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
|
|
9
|
+
from .config import AppConfig
|
|
10
|
+
from .logging import get_logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
LOG = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_daemon_running() -> bool:
|
|
17
|
+
try:
|
|
18
|
+
result = subprocess.run(
|
|
19
|
+
["pgrep", "-x", "transmission-daemon"],
|
|
20
|
+
stdout=subprocess.DEVNULL,
|
|
21
|
+
stderr=subprocess.DEVNULL,
|
|
22
|
+
check=False,
|
|
23
|
+
)
|
|
24
|
+
return result.returncode == 0
|
|
25
|
+
except FileNotFoundError:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _detect_package_manager() -> str | None:
|
|
30
|
+
managers = ["apt-get", "apt", "brew", "dnf", "yum", "pacman", "zypper"]
|
|
31
|
+
for mgr in managers:
|
|
32
|
+
if shutil.which(mgr):
|
|
33
|
+
return mgr
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _install_transmission(manager: str) -> bool:
|
|
38
|
+
commands = {
|
|
39
|
+
"apt-get": ["sudo", "apt-get", "update", "&&", "sudo", "apt-get", "-y", "install", "transmission-daemon"],
|
|
40
|
+
"apt": ["sudo", "apt", "update", "&&", "sudo", "apt", "-y", "install", "transmission-daemon"],
|
|
41
|
+
"brew": ["brew", "install", "transmission"],
|
|
42
|
+
"dnf": ["sudo", "dnf", "-y", "install", "transmission-daemon"],
|
|
43
|
+
"yum": ["sudo", "yum", "-y", "install", "transmission-daemon"],
|
|
44
|
+
"pacman": ["sudo", "pacman", "-Sy", "--noconfirm", "transmission-cli"],
|
|
45
|
+
"zypper": ["sudo", "zypper", "--non-interactive", "install", "transmission-daemon"],
|
|
46
|
+
}
|
|
47
|
+
cmd = commands.get(manager)
|
|
48
|
+
if not cmd:
|
|
49
|
+
return False
|
|
50
|
+
LOG.info("Trying to install transmission via %s", manager)
|
|
51
|
+
try:
|
|
52
|
+
# emulate '&&' without shell
|
|
53
|
+
if "&&" in cmd:
|
|
54
|
+
# apt/apt-get branch
|
|
55
|
+
update = cmd[:3]
|
|
56
|
+
install = cmd[4:]
|
|
57
|
+
subprocess.run(update, check=False)
|
|
58
|
+
result = subprocess.run(install, check=False)
|
|
59
|
+
else:
|
|
60
|
+
result = subprocess.run(cmd, check=False)
|
|
61
|
+
return result.returncode == 0
|
|
62
|
+
except Exception as exc: # pragma: no cover - safeguard
|
|
63
|
+
LOG.error("Auto-install failed: %s", exc)
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ensure_transmission_available(config: AppConfig) -> bool:
|
|
68
|
+
"""Ensure transmission binary exists; optionally try to install it."""
|
|
69
|
+
if shutil.which(config.daemon.binary):
|
|
70
|
+
return True
|
|
71
|
+
if not config.daemon.install_missing:
|
|
72
|
+
LOG.warning("Binary %s not found, auto-install is disabled", config.daemon.binary)
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
mgr = _detect_package_manager()
|
|
76
|
+
if not mgr:
|
|
77
|
+
LOG.warning("No supported package manager found for auto-install")
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
ok = _install_transmission(mgr)
|
|
81
|
+
if not ok:
|
|
82
|
+
LOG.warning("Auto-install via %s failed", mgr)
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
return shutil.which(config.daemon.binary) is not None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _build_daemon_args(config: AppConfig) -> list[str]:
|
|
89
|
+
cfg_dir = config.paths.config_dir
|
|
90
|
+
download_dir = config.paths.download_dir
|
|
91
|
+
cfg_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
download_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
args = [
|
|
95
|
+
config.daemon.binary,
|
|
96
|
+
"--foreground",
|
|
97
|
+
"--config-dir",
|
|
98
|
+
str(cfg_dir),
|
|
99
|
+
"--download-dir",
|
|
100
|
+
str(download_dir),
|
|
101
|
+
"--log-info",
|
|
102
|
+
]
|
|
103
|
+
args.extend(config.daemon.extra_args or [])
|
|
104
|
+
return args
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def maybe_start_daemon(config: AppConfig, wait_seconds: float = 2.5) -> None:
|
|
108
|
+
if not config.daemon.autostart:
|
|
109
|
+
LOG.info("Daemon autostart disabled")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if not ensure_transmission_available(config):
|
|
113
|
+
LOG.error("Transmission unavailable. Install manually or enable auto-install.")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
if _is_daemon_running():
|
|
117
|
+
LOG.debug("transmission-daemon is already running")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
binary = shutil.which(config.daemon.binary)
|
|
121
|
+
if not binary:
|
|
122
|
+
LOG.warning("Binary transmission-daemon not found: %s", config.daemon.binary)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
args = _build_daemon_args(config)
|
|
126
|
+
log_file = config.daemon.log_path
|
|
127
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
log = log_file.open("a", encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
LOG.info("Starting transmission-daemon: %s", " ".join(args))
|
|
131
|
+
try:
|
|
132
|
+
subprocess.Popen(
|
|
133
|
+
args,
|
|
134
|
+
stdout=log,
|
|
135
|
+
stderr=log,
|
|
136
|
+
close_fds=True,
|
|
137
|
+
start_new_session=True,
|
|
138
|
+
)
|
|
139
|
+
except Exception as exc: # pragma: no cover - safeguard
|
|
140
|
+
LOG.error("Failed to start transmission-daemon: %s", exc)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
# give daemon time to start
|
|
144
|
+
time.sleep(wait_seconds)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def stop_daemon(process_names: Iterable[str] = ("transmission-daemon",)) -> None:
|
|
148
|
+
for name in process_names:
|
|
149
|
+
try:
|
|
150
|
+
subprocess.run(["pkill", "-x", name], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
151
|
+
except FileNotFoundError:
|
|
152
|
+
LOG.debug("pkill is not available on this system")
|
|
153
|
+
break
|
|
154
|
+
|
|
155
|
+
# small delay to let daemon exit
|
|
156
|
+
time.sleep(0.5)
|
|
157
|
+
|
|
158
|
+
|