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 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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
4
+
5
+
@@ -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
+