qBitrr2 4.10.9__py3-none-any.whl → 5.4.5__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.
- qBitrr/arss.py +2165 -889
- qBitrr/auto_update.py +382 -0
- qBitrr/bundled_data.py +3 -2
- qBitrr/config.py +20 -3
- qBitrr/db_lock.py +79 -0
- qBitrr/env_config.py +19 -7
- qBitrr/gen_config.py +287 -26
- qBitrr/logger.py +87 -3
- qBitrr/main.py +453 -101
- qBitrr/search_activity_store.py +88 -0
- qBitrr/static/assets/ArrView.js +2 -0
- qBitrr/static/assets/ArrView.js.map +1 -0
- qBitrr/static/assets/ConfigView.js +4 -0
- qBitrr/static/assets/ConfigView.js.map +1 -0
- qBitrr/static/assets/LogsView.js +230 -0
- qBitrr/static/assets/LogsView.js.map +1 -0
- qBitrr/static/assets/ProcessesView.js +2 -0
- qBitrr/static/assets/ProcessesView.js.map +1 -0
- qBitrr/static/assets/app.css +1 -0
- qBitrr/static/assets/app.js +11 -0
- qBitrr/static/assets/app.js.map +1 -0
- qBitrr/static/assets/build.svg +3 -0
- qBitrr/static/assets/check-mark.svg +5 -0
- qBitrr/static/assets/close.svg +4 -0
- qBitrr/static/assets/download.svg +5 -0
- qBitrr/static/assets/gear.svg +5 -0
- qBitrr/static/assets/lidarr.svg +1 -0
- qBitrr/static/assets/live-streaming.svg +8 -0
- qBitrr/static/assets/log.svg +3 -0
- qBitrr/static/assets/plus.svg +4 -0
- qBitrr/static/assets/process.svg +15 -0
- qBitrr/static/assets/react-select.esm.js +14 -0
- qBitrr/static/assets/react-select.esm.js.map +1 -0
- qBitrr/static/assets/refresh-arrow.svg +3 -0
- qBitrr/static/assets/table.js +23 -0
- qBitrr/static/assets/table.js.map +1 -0
- qBitrr/static/assets/trash.svg +8 -0
- qBitrr/static/assets/up-arrow.svg +3 -0
- qBitrr/static/assets/useInterval.js +2 -0
- qBitrr/static/assets/useInterval.js.map +1 -0
- qBitrr/static/assets/vendor.js +33 -0
- qBitrr/static/assets/vendor.js.map +1 -0
- qBitrr/static/assets/visibility.svg +9 -0
- qBitrr/static/index.html +47 -0
- qBitrr/static/manifest.json +23 -0
- qBitrr/static/sw.js +105 -0
- qBitrr/static/vite.svg +1 -0
- qBitrr/tables.py +44 -0
- qBitrr/utils.py +82 -15
- qBitrr/versioning.py +136 -0
- qBitrr/webui.py +2612 -0
- qbitrr2-5.4.5.dist-info/METADATA +1116 -0
- qbitrr2-5.4.5.dist-info/RECORD +61 -0
- {qBitrr2-4.10.9.dist-info → qbitrr2-5.4.5.dist-info}/WHEEL +1 -1
- qBitrr2-4.10.9.dist-info/METADATA +0 -233
- qBitrr2-4.10.9.dist-info/RECORD +0 -19
- {qBitrr2-4.10.9.dist-info → qbitrr2-5.4.5.dist-info}/entry_points.txt +0 -0
- {qBitrr2-4.10.9.dist-info → qbitrr2-5.4.5.dist-info/licenses}/LICENSE +0 -0
- {qBitrr2-4.10.9.dist-info → qbitrr2-5.4.5.dist-info}/top_level.txt +0 -0
qBitrr/main.py
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import atexit
|
|
4
|
-
import
|
|
4
|
+
import contextlib
|
|
5
|
+
import glob
|
|
5
6
|
import logging
|
|
6
|
-
import
|
|
7
|
+
import os
|
|
7
8
|
import sys
|
|
8
9
|
import time
|
|
9
|
-
from multiprocessing import freeze_support
|
|
10
|
+
from multiprocessing import Event, freeze_support
|
|
11
|
+
from queue import SimpleQueue
|
|
12
|
+
from threading import Event as ThreadEvent
|
|
13
|
+
from threading import Thread
|
|
14
|
+
from time import monotonic
|
|
10
15
|
|
|
11
16
|
import pathos
|
|
12
17
|
import qbittorrentapi
|
|
@@ -14,86 +19,90 @@ import requests
|
|
|
14
19
|
from packaging import version as version_parser
|
|
15
20
|
from packaging.version import Version as VersionClass
|
|
16
21
|
from qbittorrentapi import APINames
|
|
17
|
-
from qbittorrentapi.decorators import login_required # , response_text
|
|
18
22
|
|
|
19
|
-
from qBitrr.
|
|
23
|
+
from qBitrr.auto_update import AutoUpdater, perform_self_update
|
|
20
24
|
from qBitrr.bundled_data import patched_version
|
|
21
25
|
from qBitrr.config import (
|
|
22
|
-
APPDATA_FOLDER,
|
|
23
26
|
CONFIG,
|
|
24
27
|
CONFIG_EXISTS,
|
|
25
|
-
ENABLE_LOGS,
|
|
26
28
|
QBIT_DISABLED,
|
|
27
29
|
SEARCH_ONLY,
|
|
30
|
+
get_auto_update_settings,
|
|
28
31
|
process_flags,
|
|
29
32
|
)
|
|
30
33
|
from qBitrr.env_config import ENVIRO_CONFIG
|
|
31
34
|
from qBitrr.ffprobe import FFprobeDownloader
|
|
32
|
-
from qBitrr.home_path import
|
|
35
|
+
from qBitrr.home_path import APPDATA_FOLDER
|
|
33
36
|
from qBitrr.logger import run_logs
|
|
34
|
-
from qBitrr.utils import ExpiringSet
|
|
37
|
+
from qBitrr.utils import ExpiringSet
|
|
38
|
+
from qBitrr.versioning import fetch_latest_release
|
|
39
|
+
from qBitrr.webui import WebUI
|
|
35
40
|
|
|
36
41
|
if CONFIG_EXISTS:
|
|
37
42
|
from qBitrr.arss import ArrManager
|
|
38
43
|
else:
|
|
39
44
|
sys.exit(0)
|
|
40
45
|
|
|
41
|
-
CHILD_PROCESSES = []
|
|
42
|
-
|
|
43
46
|
logger = logging.getLogger("qBitrr")
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
47
|
+
run_logs(logger, "Main")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _mask_secret(value: str | None) -> str:
|
|
51
|
+
return "[redacted]" if value else ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _delete_all_databases() -> None:
|
|
55
|
+
"""
|
|
56
|
+
Delete all database files from the APPDATA_FOLDER on startup.
|
|
57
|
+
|
|
58
|
+
This includes:
|
|
59
|
+
- All .db files (SQLite databases)
|
|
60
|
+
- All .db-wal files (Write-Ahead Log files)
|
|
61
|
+
- All .db-shm files (Shared Memory files)
|
|
62
|
+
"""
|
|
63
|
+
db_patterns = ["*.db", "*.db-wal", "*.db-shm"]
|
|
64
|
+
deleted_files = []
|
|
65
|
+
|
|
66
|
+
for pattern in db_patterns:
|
|
67
|
+
for db_file in glob.glob(str(APPDATA_FOLDER.joinpath(pattern))):
|
|
68
|
+
try:
|
|
69
|
+
os.remove(db_file)
|
|
70
|
+
deleted_files.append(os.path.basename(db_file))
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.error("Failed to delete database file %s: %s", db_file, e)
|
|
73
|
+
|
|
74
|
+
if deleted_files:
|
|
75
|
+
logger.info("Deleted database files on startup: %s", ", ".join(deleted_files))
|
|
76
|
+
else:
|
|
77
|
+
logger.debug("No database files found to delete on startup")
|
|
57
78
|
|
|
58
79
|
|
|
59
80
|
class qBitManager:
|
|
60
81
|
min_supported_version = VersionClass("4.3.9")
|
|
61
82
|
soft_not_supported_supported_version = VersionClass("4.4.4")
|
|
62
|
-
max_supported_version = VersionClass("
|
|
83
|
+
# max_supported_version = VersionClass("5.1.2")
|
|
63
84
|
_head_less_mode = False
|
|
64
85
|
|
|
65
86
|
def __init__(self):
|
|
66
87
|
self._name = "Manager"
|
|
88
|
+
self.shutdown_event = Event()
|
|
67
89
|
self.qBit_Host = CONFIG.get("qBit.Host", fallback="localhost")
|
|
68
90
|
self.qBit_Port = CONFIG.get("qBit.Port", fallback=8105)
|
|
69
91
|
self.qBit_UserName = CONFIG.get("qBit.UserName", fallback=None)
|
|
70
92
|
self.qBit_Password = CONFIG.get("qBit.Password", fallback=None)
|
|
71
|
-
self.qBit_v5 = CONFIG.get("qBit.v5", fallback=False)
|
|
72
93
|
self.logger = logging.getLogger(f"qBitrr.{self._name}")
|
|
73
|
-
|
|
74
|
-
logs_folder = HOME_PATH.joinpath("logs")
|
|
75
|
-
logs_folder.mkdir(parents=True, exist_ok=True)
|
|
76
|
-
logs_folder.chmod(mode=0o777)
|
|
77
|
-
logfile = logs_folder.joinpath(self._name + ".log")
|
|
78
|
-
if pathlib.Path(logfile).is_file():
|
|
79
|
-
logold = logs_folder.joinpath(self._name + ".log.old")
|
|
80
|
-
if pathlib.Path(logold).exists():
|
|
81
|
-
logold.unlink()
|
|
82
|
-
logfile.rename(logold)
|
|
83
|
-
fh = logging.FileHandler(logfile)
|
|
84
|
-
self.logger.addHandler(fh)
|
|
85
|
-
run_logs(self.logger)
|
|
94
|
+
run_logs(self.logger, self._name)
|
|
86
95
|
self.logger.debug(
|
|
87
96
|
"qBitTorrent Config: Host: %s Port: %s, Username: %s, Password: %s",
|
|
88
97
|
self.qBit_Host,
|
|
89
98
|
self.qBit_Port,
|
|
90
99
|
self.qBit_UserName,
|
|
91
|
-
self.qBit_Password,
|
|
100
|
+
_mask_secret(self.qBit_Password),
|
|
92
101
|
)
|
|
93
102
|
self._validated_version = False
|
|
94
103
|
self.client = None
|
|
95
104
|
self.current_qbit_version = None
|
|
96
|
-
if not
|
|
105
|
+
if not (QBIT_DISABLED or SEARCH_ONLY):
|
|
97
106
|
self.client = qbittorrentapi.Client(
|
|
98
107
|
host=self.qBit_Host,
|
|
99
108
|
port=self.qBit_Port,
|
|
@@ -104,41 +113,292 @@ class qBitManager:
|
|
|
104
113
|
try:
|
|
105
114
|
self.current_qbit_version = version_parser.parse(self.client.app_version())
|
|
106
115
|
self._validated_version = True
|
|
107
|
-
except
|
|
116
|
+
except Exception as e:
|
|
108
117
|
self.current_qbit_version = self.min_supported_version
|
|
109
118
|
self.logger.error(
|
|
110
|
-
"Could not establish qBitTorrent version
|
|
111
|
-
|
|
119
|
+
"Could not establish qBitTorrent version (%s). You may experience errors; please report this.",
|
|
120
|
+
e,
|
|
112
121
|
)
|
|
113
122
|
self._version_validator()
|
|
114
123
|
self.expiring_bool = ExpiringSet(max_age_seconds=10)
|
|
115
124
|
self.cache = {}
|
|
116
125
|
self.name_cache = {}
|
|
117
126
|
self.should_delay_torrent_scan = False # If true torrent scan is delayed by 5 minutes.
|
|
118
|
-
self.child_processes = []
|
|
127
|
+
self.child_processes: list[pathos.helpers.mp.Process] = []
|
|
128
|
+
self._process_registry: dict[pathos.helpers.mp.Process, dict[str, str]] = {}
|
|
129
|
+
self.auto_updater = None
|
|
130
|
+
self.arr_manager = None
|
|
131
|
+
self._bootstrap_ready = ThreadEvent()
|
|
132
|
+
self._startup_thread: Thread | None = None
|
|
133
|
+
self._restart_requested = False
|
|
134
|
+
self._restart_thread: Thread | None = None
|
|
119
135
|
self.ffprobe_downloader = FFprobeDownloader()
|
|
120
136
|
try:
|
|
121
|
-
if not
|
|
137
|
+
if not (QBIT_DISABLED or SEARCH_ONLY):
|
|
122
138
|
self.ffprobe_downloader.update()
|
|
123
139
|
except Exception as e:
|
|
124
140
|
self.logger.error(
|
|
125
141
|
"FFprobe manager error: %s while attempting to download/update FFprobe", e
|
|
126
142
|
)
|
|
127
|
-
|
|
128
|
-
|
|
143
|
+
# Start WebUI as early as possible
|
|
144
|
+
try:
|
|
145
|
+
web_port = int(CONFIG.get("WebUI.Port", fallback=6969) or 6969)
|
|
146
|
+
except Exception:
|
|
147
|
+
web_port = 6969
|
|
148
|
+
web_host = CONFIG.get("WebUI.Host", fallback="127.0.0.1") or "127.0.0.1"
|
|
149
|
+
if os.environ.get("QBITRR_DOCKER_RUNNING") == "69420" and web_host in {
|
|
150
|
+
"127.0.0.1",
|
|
151
|
+
"localhost",
|
|
152
|
+
}:
|
|
153
|
+
web_host = "0.0.0.0"
|
|
154
|
+
if web_host in {"0.0.0.0", "::"}:
|
|
155
|
+
self.logger.warning(
|
|
156
|
+
"WebUI host configured for %s; ensure exposure is intentional and protected.",
|
|
157
|
+
web_host,
|
|
158
|
+
)
|
|
159
|
+
self.webui = WebUI(self, host=web_host, port=web_port)
|
|
160
|
+
self.webui.start()
|
|
161
|
+
|
|
162
|
+
# Finish bootstrap tasks (Arr manager, workers, auto-update) in the background
|
|
163
|
+
self._startup_thread = Thread(
|
|
164
|
+
target=self._complete_startup, name="qBitrr-Startup", daemon=True
|
|
165
|
+
)
|
|
166
|
+
self._startup_thread.start()
|
|
167
|
+
|
|
168
|
+
def configure_auto_update(self) -> None:
|
|
169
|
+
enabled, cron = get_auto_update_settings()
|
|
170
|
+
if self.auto_updater:
|
|
171
|
+
self.auto_updater.stop()
|
|
172
|
+
self.auto_updater = None
|
|
173
|
+
if not enabled:
|
|
174
|
+
self.logger.debug("Auto update is disabled")
|
|
175
|
+
return
|
|
176
|
+
updater = AutoUpdater(cron, self._perform_auto_update, self.logger)
|
|
177
|
+
if updater.start():
|
|
178
|
+
self.auto_updater = updater
|
|
179
|
+
else:
|
|
180
|
+
self.logger.error("Auto update could not be scheduled; leaving it disabled")
|
|
181
|
+
|
|
182
|
+
def _perform_auto_update(self) -> None:
|
|
183
|
+
"""Check for updates and apply if available."""
|
|
184
|
+
self.logger.notice("Checking for updates...")
|
|
185
|
+
|
|
186
|
+
# Fetch latest release info from GitHub
|
|
187
|
+
release_info = fetch_latest_release()
|
|
188
|
+
|
|
189
|
+
if release_info.get("error"):
|
|
190
|
+
self.logger.error("Auto update skipped: %s", release_info["error"])
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
# Use normalized version for comparison, raw tag for display
|
|
194
|
+
target_version = release_info.get("normalized")
|
|
195
|
+
raw_tag = release_info.get("raw_tag")
|
|
196
|
+
|
|
197
|
+
if not release_info.get("update_available"):
|
|
198
|
+
if target_version:
|
|
199
|
+
self.logger.info(
|
|
200
|
+
"Auto update skipped: already running the latest release (%s).",
|
|
201
|
+
raw_tag or target_version,
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
self.logger.info("Auto update skipped: no new release detected.")
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
# Detect installation type
|
|
208
|
+
from qBitrr.auto_update import get_installation_type
|
|
209
|
+
|
|
210
|
+
install_type = get_installation_type()
|
|
211
|
+
|
|
212
|
+
self.logger.notice(
|
|
213
|
+
"Update available: %s -> %s (installation: %s)",
|
|
214
|
+
patched_version,
|
|
215
|
+
raw_tag or target_version,
|
|
216
|
+
install_type,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Perform the update with specific version
|
|
220
|
+
updated = perform_self_update(self.logger, target_version=target_version)
|
|
221
|
+
|
|
222
|
+
if not updated:
|
|
223
|
+
if install_type == "binary":
|
|
224
|
+
# Binary installations require manual update, this is expected
|
|
225
|
+
self.logger.info("Manual update required for binary installation")
|
|
226
|
+
else:
|
|
227
|
+
self.logger.error("Auto update failed; manual intervention may be required.")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
# Verify update success (git/pip only)
|
|
231
|
+
if target_version and install_type != "binary":
|
|
232
|
+
from qBitrr.auto_update import verify_update_success
|
|
233
|
+
|
|
234
|
+
if verify_update_success(target_version, self.logger):
|
|
235
|
+
self.logger.notice("Update verified successfully")
|
|
236
|
+
else:
|
|
237
|
+
self.logger.warning(
|
|
238
|
+
"Update completed but version verification failed. "
|
|
239
|
+
"The system may not be running the expected version."
|
|
240
|
+
)
|
|
241
|
+
# Continue with restart anyway (Phase 1 approach)
|
|
242
|
+
|
|
243
|
+
self.logger.notice("Update applied successfully; restarting to load the new version.")
|
|
244
|
+
self.request_restart()
|
|
245
|
+
|
|
246
|
+
def request_restart(self, delay: float = 3.0) -> None:
|
|
247
|
+
if self._restart_requested:
|
|
248
|
+
return
|
|
249
|
+
self._restart_requested = True
|
|
250
|
+
|
|
251
|
+
def _restart():
|
|
252
|
+
if delay > 0:
|
|
253
|
+
time.sleep(delay)
|
|
254
|
+
self.logger.notice("Restarting qBitrr...")
|
|
255
|
+
|
|
256
|
+
# Set shutdown event to signal all loops to stop
|
|
257
|
+
try:
|
|
258
|
+
self.shutdown_event.set()
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
# Wait for child processes to exit gracefully
|
|
263
|
+
for proc in list(self.child_processes):
|
|
264
|
+
with contextlib.suppress(Exception):
|
|
265
|
+
proc.join(timeout=5)
|
|
266
|
+
|
|
267
|
+
# Force kill any remaining child processes
|
|
268
|
+
for proc in list(self.child_processes):
|
|
269
|
+
with contextlib.suppress(Exception):
|
|
270
|
+
proc.kill()
|
|
271
|
+
with contextlib.suppress(Exception):
|
|
272
|
+
proc.terminate()
|
|
273
|
+
|
|
274
|
+
# Close database connections explicitly
|
|
275
|
+
try:
|
|
276
|
+
if hasattr(self, "arr_manager") and self.arr_manager:
|
|
277
|
+
for arr in self.arr_manager.managed_objects.values():
|
|
278
|
+
if hasattr(arr, "db") and arr.db:
|
|
279
|
+
with contextlib.suppress(Exception):
|
|
280
|
+
arr.db.close()
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
# Flush all log handlers
|
|
285
|
+
try:
|
|
286
|
+
for handler in logging.root.handlers[:]:
|
|
287
|
+
with contextlib.suppress(Exception):
|
|
288
|
+
handler.flush()
|
|
289
|
+
handler.close()
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
# Prepare restart arguments
|
|
294
|
+
python = sys.executable
|
|
295
|
+
args = [python] + sys.argv
|
|
296
|
+
|
|
297
|
+
self.logger.notice("Executing restart: %s", " ".join(args))
|
|
298
|
+
|
|
299
|
+
# Flush logs one final time before exec
|
|
300
|
+
try:
|
|
301
|
+
for handler in self.logger.handlers[:]:
|
|
302
|
+
with contextlib.suppress(Exception):
|
|
303
|
+
handler.flush()
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
# Replace current process with new instance
|
|
308
|
+
# This works in Docker, native installs, and systemd
|
|
309
|
+
try:
|
|
310
|
+
os.execv(python, args)
|
|
311
|
+
except Exception as e:
|
|
312
|
+
# If execv fails, fall back to exit and hope external supervisor restarts us
|
|
313
|
+
self.logger.critical("Failed to restart via execv: %s. Exiting instead.", e)
|
|
314
|
+
os._exit(1)
|
|
315
|
+
|
|
316
|
+
self._restart_thread = Thread(target=_restart, name="qBitrr-Restart", daemon=True)
|
|
317
|
+
self._restart_thread.start()
|
|
318
|
+
|
|
319
|
+
def _prepare_arr_processes(self, arr, timeout_seconds: int = 30) -> None:
|
|
320
|
+
timeout = max(
|
|
321
|
+
1, int(CONFIG.get("Settings.ProcessSpawnTimeoutSeconds", fallback=timeout_seconds))
|
|
322
|
+
)
|
|
323
|
+
result_queue: SimpleQueue = SimpleQueue()
|
|
324
|
+
|
|
325
|
+
def _stage():
|
|
326
|
+
try:
|
|
327
|
+
result_queue.put((True, arr.spawn_child_processes()))
|
|
328
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
329
|
+
result_queue.put((False, exc))
|
|
330
|
+
|
|
331
|
+
spawn_thread = Thread(
|
|
332
|
+
target=_stage,
|
|
333
|
+
name=f"spawn-{getattr(arr, 'category', getattr(arr, '_name', 'arr'))}",
|
|
334
|
+
daemon=True,
|
|
335
|
+
)
|
|
336
|
+
spawn_thread.start()
|
|
337
|
+
spawn_thread.join(timeout)
|
|
338
|
+
if spawn_thread.is_alive():
|
|
339
|
+
self.logger.error(
|
|
340
|
+
"Timed out initialising worker processes for %s after %ss; skipping this instance.",
|
|
341
|
+
getattr(arr, "_name", getattr(arr, "category", "unknown")),
|
|
342
|
+
timeout,
|
|
343
|
+
)
|
|
344
|
+
return
|
|
345
|
+
if result_queue.empty():
|
|
346
|
+
self.logger.error(
|
|
347
|
+
"No startup result returned for %s; skipping this instance.",
|
|
348
|
+
getattr(arr, "_name", getattr(arr, "category", "unknown")),
|
|
349
|
+
)
|
|
350
|
+
return
|
|
351
|
+
success, payload = result_queue.get()
|
|
352
|
+
if not success:
|
|
353
|
+
self.logger.exception(
|
|
354
|
+
"Failed to initialise worker processes for %s",
|
|
355
|
+
getattr(arr, "_name", getattr(arr, "category", "unknown")),
|
|
356
|
+
exc_info=payload,
|
|
357
|
+
)
|
|
358
|
+
return
|
|
359
|
+
worker_count, processes = payload
|
|
360
|
+
if not worker_count:
|
|
361
|
+
return
|
|
362
|
+
for proc in processes:
|
|
363
|
+
role = "search" if getattr(arr, "process_search_loop", None) is proc else "torrent"
|
|
364
|
+
self._process_registry[proc] = {
|
|
365
|
+
"category": getattr(arr, "category", ""),
|
|
366
|
+
"name": getattr(arr, "_name", getattr(arr, "category", "")),
|
|
367
|
+
"role": role or "worker",
|
|
368
|
+
}
|
|
369
|
+
self.logger.debug(
|
|
370
|
+
"Prepared %s worker(s) for %s",
|
|
371
|
+
worker_count,
|
|
372
|
+
getattr(arr, "_name", getattr(arr, "category", "unknown")),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def _complete_startup(self) -> None:
|
|
376
|
+
started_at = monotonic()
|
|
377
|
+
try:
|
|
378
|
+
arr_manager = ArrManager(self)
|
|
379
|
+
self.arr_manager = arr_manager
|
|
380
|
+
arr_manager.build_arr_instances()
|
|
381
|
+
run_logs(self.logger)
|
|
382
|
+
for arr in arr_manager.managed_objects.values():
|
|
383
|
+
self._prepare_arr_processes(arr)
|
|
384
|
+
self.configure_auto_update()
|
|
385
|
+
elapsed = monotonic() - started_at
|
|
386
|
+
self.logger.info("Background startup completed in %.1fs", elapsed)
|
|
387
|
+
except Exception:
|
|
388
|
+
self.logger.exception(
|
|
389
|
+
"Background startup encountered an error; continuing with partial functionality."
|
|
390
|
+
)
|
|
391
|
+
finally:
|
|
392
|
+
self._bootstrap_ready.set()
|
|
129
393
|
|
|
130
394
|
def _version_validator(self):
|
|
131
395
|
validated = False
|
|
132
|
-
if
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
<= self.current_qbit_version
|
|
139
|
-
<= self.max_supported_version
|
|
140
|
-
):
|
|
141
|
-
validated = True
|
|
396
|
+
if (
|
|
397
|
+
self.min_supported_version
|
|
398
|
+
<= self.current_qbit_version
|
|
399
|
+
# <= self.max_supported_version
|
|
400
|
+
):
|
|
401
|
+
validated = True
|
|
142
402
|
|
|
143
403
|
if self._validated_version and validated:
|
|
144
404
|
self.logger.info(
|
|
@@ -149,19 +409,18 @@ class qBitManager:
|
|
|
149
409
|
"Could not validate current qBitTorrent version, assuming: %s",
|
|
150
410
|
self.current_qbit_version,
|
|
151
411
|
)
|
|
152
|
-
time.sleep(10)
|
|
153
412
|
else:
|
|
154
413
|
self.logger.critical(
|
|
155
|
-
"You are currently running qBitTorrent version %s
|
|
156
|
-
"Supported version range is %s to < %s",
|
|
414
|
+
"You are currently running qBitTorrent version %s which is not supported by qBitrr.",
|
|
415
|
+
# "Supported version range is %s to < %s",
|
|
157
416
|
self.current_qbit_version,
|
|
158
|
-
self.min_supported_version,
|
|
159
|
-
self.max_supported_version,
|
|
417
|
+
# self.min_supported_version,
|
|
418
|
+
# self.max_supported_version,
|
|
160
419
|
)
|
|
161
420
|
sys.exit(1)
|
|
162
421
|
|
|
163
422
|
# @response_text(str)
|
|
164
|
-
@login_required
|
|
423
|
+
# @login_required
|
|
165
424
|
def app_version(self, **kwargs):
|
|
166
425
|
return self.client._get(
|
|
167
426
|
_name=APINames.Application,
|
|
@@ -171,10 +430,18 @@ class qBitManager:
|
|
|
171
430
|
**kwargs,
|
|
172
431
|
)
|
|
173
432
|
|
|
433
|
+
def transfer_info(self, **kwargs):
|
|
434
|
+
"""Proxy transfer info requests to the underlying qBittorrent client."""
|
|
435
|
+
if self.client is None:
|
|
436
|
+
return {"connection_status": "disconnected"}
|
|
437
|
+
return self.client.transfer_info(**kwargs)
|
|
438
|
+
|
|
174
439
|
@property
|
|
175
440
|
def is_alive(self) -> bool:
|
|
176
441
|
try:
|
|
177
|
-
if
|
|
442
|
+
if self.client is None:
|
|
443
|
+
return False
|
|
444
|
+
if 1 in self.expiring_bool:
|
|
178
445
|
return True
|
|
179
446
|
self.client.app_version()
|
|
180
447
|
self.logger.trace("Successfully connected to %s:%s", self.qBit_Host, self.qBit_Port)
|
|
@@ -185,44 +452,147 @@ class qBitManager:
|
|
|
185
452
|
self.should_delay_torrent_scan = True
|
|
186
453
|
return False
|
|
187
454
|
|
|
188
|
-
def get_child_processes(self) -> list[pathos.helpers.mp.Process]:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
return procs
|
|
198
|
-
|
|
199
|
-
def run(self):
|
|
455
|
+
def get_child_processes(self, timeout: float = 60.0) -> list[pathos.helpers.mp.Process]:
|
|
456
|
+
if not self._bootstrap_ready.wait(timeout):
|
|
457
|
+
self.logger.warning(
|
|
458
|
+
"Background startup did not finish within %.1fs. Continuing with the services currently available.",
|
|
459
|
+
timeout,
|
|
460
|
+
)
|
|
461
|
+
return list(self.child_processes)
|
|
462
|
+
|
|
463
|
+
def run(self) -> None:
|
|
200
464
|
try:
|
|
201
|
-
self.
|
|
202
|
-
|
|
203
|
-
|
|
465
|
+
if not self._bootstrap_ready.wait(60.0):
|
|
466
|
+
self.logger.warning(
|
|
467
|
+
"Startup thread still running after 60s; managing available workers."
|
|
468
|
+
)
|
|
469
|
+
for proc in list(self.child_processes):
|
|
470
|
+
try:
|
|
471
|
+
proc.start()
|
|
472
|
+
meta = self._process_registry.get(proc, {})
|
|
473
|
+
self.logger.debug(
|
|
474
|
+
"Started %s worker for category '%s'",
|
|
475
|
+
meta.get("role", "worker"),
|
|
476
|
+
meta.get("category", "unknown"),
|
|
477
|
+
)
|
|
478
|
+
except Exception as exc:
|
|
479
|
+
self.logger.exception(
|
|
480
|
+
"Failed to start worker process %s",
|
|
481
|
+
getattr(proc, "name", repr(proc)),
|
|
482
|
+
exc_info=exc,
|
|
483
|
+
)
|
|
484
|
+
while not self.shutdown_event.is_set():
|
|
485
|
+
any_alive = False
|
|
486
|
+
for proc in list(self.child_processes):
|
|
487
|
+
if proc.is_alive():
|
|
488
|
+
any_alive = True
|
|
489
|
+
continue
|
|
490
|
+
exit_code = proc.exitcode
|
|
491
|
+
if exit_code is None:
|
|
492
|
+
continue
|
|
493
|
+
meta = self._process_registry.pop(proc, {})
|
|
494
|
+
with contextlib.suppress(ValueError):
|
|
495
|
+
self.child_processes.remove(proc)
|
|
496
|
+
self.logger.warning(
|
|
497
|
+
"Worker process exited (role=%s, category=%s, code=%s)",
|
|
498
|
+
meta.get("role", "unknown"),
|
|
499
|
+
meta.get("category", "unknown"),
|
|
500
|
+
exit_code,
|
|
501
|
+
)
|
|
502
|
+
if not self.child_processes:
|
|
503
|
+
if not any_alive:
|
|
504
|
+
break
|
|
505
|
+
self.shutdown_event.wait(timeout=5)
|
|
506
|
+
if not any(proc.is_alive() for proc in self.child_processes):
|
|
507
|
+
if self.child_processes:
|
|
508
|
+
continue
|
|
509
|
+
break
|
|
204
510
|
except KeyboardInterrupt:
|
|
205
511
|
self.logger.info("Detected Ctrl+C - Terminating process")
|
|
206
512
|
sys.exit(0)
|
|
207
513
|
except BaseException as e:
|
|
208
|
-
self.logger.info("Detected
|
|
514
|
+
self.logger.info("Detected unexpected error, shutting down: %r", e)
|
|
209
515
|
sys.exit(1)
|
|
516
|
+
finally:
|
|
517
|
+
for proc in list(self.child_processes):
|
|
518
|
+
if proc.is_alive():
|
|
519
|
+
proc.join(timeout=1)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _report_config_issues():
|
|
523
|
+
try:
|
|
524
|
+
issues = []
|
|
525
|
+
# Check required settings
|
|
526
|
+
from qBitrr.config import COMPLETED_DOWNLOAD_FOLDER, CONFIG, FREE_SPACE, FREE_SPACE_FOLDER
|
|
527
|
+
|
|
528
|
+
if not COMPLETED_DOWNLOAD_FOLDER or str(COMPLETED_DOWNLOAD_FOLDER).upper() == "CHANGE_ME":
|
|
529
|
+
issues.append("Settings.CompletedDownloadFolder is missing or set to CHANGE_ME")
|
|
530
|
+
if FREE_SPACE != "-1":
|
|
531
|
+
if not FREE_SPACE_FOLDER or str(FREE_SPACE_FOLDER).upper() == "CHANGE_ME":
|
|
532
|
+
issues.append("Settings.FreeSpaceFolder must be set when FreeSpace is enabled")
|
|
533
|
+
# Check Arr sections
|
|
534
|
+
for key in CONFIG.sections():
|
|
535
|
+
import re
|
|
536
|
+
|
|
537
|
+
m = re.match(r"radarr.*", key, re.IGNORECASE)
|
|
538
|
+
if not m:
|
|
539
|
+
continue
|
|
540
|
+
managed = CONFIG.get(f"{key}.Managed", fallback=False)
|
|
541
|
+
if not managed:
|
|
542
|
+
continue
|
|
543
|
+
uri = CONFIG.get(f"{key}.URI", fallback=None)
|
|
544
|
+
apikey = CONFIG.get(f"{key}.APIKey", fallback=None)
|
|
545
|
+
if not uri or str(uri).upper() == "CHANGE_ME":
|
|
546
|
+
issues.append(f"{key}.URI is missing or set to CHANGE_ME")
|
|
547
|
+
if not apikey or str(apikey).upper() == "CHANGE_ME":
|
|
548
|
+
issues.append(f"{key}.APIKey is missing or set to CHANGE_ME")
|
|
549
|
+
if issues:
|
|
550
|
+
logger.error("Configuration issues detected:")
|
|
551
|
+
for i in issues:
|
|
552
|
+
logger.error(" - %s", i)
|
|
553
|
+
except Exception as e:
|
|
554
|
+
logger.debug("Config validation skipped due to error: %s", e)
|
|
210
555
|
|
|
211
556
|
|
|
212
557
|
def run():
|
|
213
|
-
global CHILD_PROCESSES
|
|
214
558
|
early_exit = process_flags()
|
|
215
559
|
if early_exit is True:
|
|
216
560
|
sys.exit(0)
|
|
217
561
|
logger.info("Starting qBitrr: Version: %s.", patched_version)
|
|
562
|
+
|
|
563
|
+
# Delete all databases on startup
|
|
564
|
+
_delete_all_databases()
|
|
565
|
+
|
|
218
566
|
try:
|
|
219
567
|
manager = qBitManager()
|
|
220
568
|
except NameError:
|
|
221
569
|
sys.exit(0)
|
|
222
570
|
run_logs(logger)
|
|
571
|
+
# Early consolidated config validation feedback
|
|
572
|
+
_report_config_issues()
|
|
223
573
|
logger.debug("Environment variables: %r", ENVIRO_CONFIG)
|
|
224
574
|
try:
|
|
225
|
-
|
|
575
|
+
manager.get_child_processes()
|
|
576
|
+
|
|
577
|
+
# Register cleanup for child processes when the main process exits
|
|
578
|
+
def _cleanup():
|
|
579
|
+
# Signal loops to shutdown gracefully
|
|
580
|
+
try:
|
|
581
|
+
manager.shutdown_event.set()
|
|
582
|
+
except Exception:
|
|
583
|
+
pass
|
|
584
|
+
# Give processes a chance to exit
|
|
585
|
+
for p in manager.child_processes:
|
|
586
|
+
with contextlib.suppress(Exception):
|
|
587
|
+
p.join(timeout=5)
|
|
588
|
+
for p in manager.child_processes:
|
|
589
|
+
with contextlib.suppress(Exception):
|
|
590
|
+
p.kill()
|
|
591
|
+
with contextlib.suppress(Exception):
|
|
592
|
+
p.terminate()
|
|
593
|
+
|
|
594
|
+
atexit.register(_cleanup)
|
|
595
|
+
if manager.child_processes:
|
|
226
596
|
manager.run()
|
|
227
597
|
else:
|
|
228
598
|
logger.warning(
|
|
@@ -237,24 +607,6 @@ def run():
|
|
|
237
607
|
child.kill()
|
|
238
608
|
|
|
239
609
|
|
|
240
|
-
def cleanup():
|
|
241
|
-
for p in CHILD_PROCESSES:
|
|
242
|
-
p.kill()
|
|
243
|
-
p.terminate()
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def file_cleanup():
|
|
247
|
-
extensions = [".db", ".db-shm", ".db-wal"]
|
|
248
|
-
all_files_in_folder = list(absolute_file_paths(APPDATA_FOLDER))
|
|
249
|
-
for file, ext in itertools.product(all_files_in_folder, extensions):
|
|
250
|
-
if file.name.endswith(ext):
|
|
251
|
-
APPDATA_FOLDER.joinpath(file).unlink(missing_ok=True)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
atexit.register(cleanup)
|
|
255
|
-
|
|
256
|
-
|
|
257
610
|
if __name__ == "__main__":
|
|
258
611
|
freeze_support()
|
|
259
|
-
file_cleanup()
|
|
260
612
|
run()
|