qBitrr2 5.5.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/__init__.py +14 -0
- qBitrr/arss.py +7100 -0
- qBitrr/auto_update.py +382 -0
- qBitrr/bundled_data.py +7 -0
- qBitrr/config.py +192 -0
- qBitrr/config_version.py +144 -0
- qBitrr/db_lock.py +400 -0
- qBitrr/db_recovery.py +202 -0
- qBitrr/env_config.py +73 -0
- qBitrr/errors.py +41 -0
- qBitrr/ffprobe.py +105 -0
- qBitrr/gen_config.py +1331 -0
- qBitrr/home_path.py +23 -0
- qBitrr/logger.py +235 -0
- qBitrr/main.py +790 -0
- qBitrr/search_activity_store.py +92 -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 +2 -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/live-streaming.svg +8 -0
- qBitrr/static/assets/log.svg +3 -0
- qBitrr/static/assets/logo.svg +48 -0
- qBitrr/static/assets/plus.svg +4 -0
- qBitrr/static/assets/process.svg +15 -0
- qBitrr/static/assets/react-select.esm.js +7 -0
- qBitrr/static/assets/react-select.esm.js.map +1 -0
- qBitrr/static/assets/refresh-arrow.svg +3 -0
- qBitrr/static/assets/table.js +5 -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 +2 -0
- qBitrr/static/assets/vendor.js.map +1 -0
- qBitrr/static/assets/visibility.svg +9 -0
- qBitrr/static/index.html +33 -0
- qBitrr/static/logov2-clean.svg +48 -0
- qBitrr/static/manifest.json +23 -0
- qBitrr/static/sw.js +87 -0
- qBitrr/static/vite.svg +1 -0
- qBitrr/tables.py +143 -0
- qBitrr/utils.py +274 -0
- qBitrr/versioning.py +136 -0
- qBitrr/webui.py +3114 -0
- qbitrr2-5.5.5.dist-info/METADATA +1191 -0
- qbitrr2-5.5.5.dist-info/RECORD +64 -0
- qbitrr2-5.5.5.dist-info/WHEEL +5 -0
- qbitrr2-5.5.5.dist-info/entry_points.txt +2 -0
- qbitrr2-5.5.5.dist-info/licenses/LICENSE +21 -0
- qbitrr2-5.5.5.dist-info/top_level.txt +1 -0
qBitrr/main.py
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import contextlib
|
|
5
|
+
import glob
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
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
|
|
15
|
+
|
|
16
|
+
import pathos
|
|
17
|
+
import qbittorrentapi
|
|
18
|
+
import requests
|
|
19
|
+
from packaging import version as version_parser
|
|
20
|
+
from packaging.version import Version as VersionClass
|
|
21
|
+
from qbittorrentapi import APINames
|
|
22
|
+
|
|
23
|
+
from qBitrr.auto_update import AutoUpdater, perform_self_update
|
|
24
|
+
from qBitrr.bundled_data import patched_version
|
|
25
|
+
from qBitrr.config import (
|
|
26
|
+
CONFIG,
|
|
27
|
+
CONFIG_EXISTS,
|
|
28
|
+
QBIT_DISABLED,
|
|
29
|
+
SEARCH_ONLY,
|
|
30
|
+
get_auto_update_settings,
|
|
31
|
+
process_flags,
|
|
32
|
+
)
|
|
33
|
+
from qBitrr.env_config import ENVIRO_CONFIG
|
|
34
|
+
from qBitrr.ffprobe import FFprobeDownloader
|
|
35
|
+
from qBitrr.home_path import APPDATA_FOLDER
|
|
36
|
+
from qBitrr.logger import run_logs
|
|
37
|
+
from qBitrr.utils import ExpiringSet
|
|
38
|
+
from qBitrr.versioning import fetch_latest_release
|
|
39
|
+
from qBitrr.webui import WebUI
|
|
40
|
+
|
|
41
|
+
if CONFIG_EXISTS:
|
|
42
|
+
from qBitrr.arss import ArrManager
|
|
43
|
+
else:
|
|
44
|
+
sys.exit(0)
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger("qBitrr")
|
|
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")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class qBitManager:
|
|
81
|
+
min_supported_version = VersionClass("4.3.9")
|
|
82
|
+
soft_not_supported_supported_version = VersionClass("4.4.4")
|
|
83
|
+
# max_supported_version = VersionClass("5.1.2")
|
|
84
|
+
_head_less_mode = False
|
|
85
|
+
|
|
86
|
+
def __init__(self):
|
|
87
|
+
self._name = "Manager"
|
|
88
|
+
self.shutdown_event = Event()
|
|
89
|
+
self.qBit_Host = CONFIG.get("qBit.Host", fallback="localhost")
|
|
90
|
+
self.qBit_Port = CONFIG.get("qBit.Port", fallback=8105)
|
|
91
|
+
self.qBit_UserName = CONFIG.get("qBit.UserName", fallback=None)
|
|
92
|
+
self.qBit_Password = CONFIG.get("qBit.Password", fallback=None)
|
|
93
|
+
self.logger = logging.getLogger(f"qBitrr.{self._name}")
|
|
94
|
+
run_logs(self.logger, self._name)
|
|
95
|
+
self.logger.debug(
|
|
96
|
+
"qBitTorrent Config: Host: %s Port: %s, Username: %s, Password: %s",
|
|
97
|
+
self.qBit_Host,
|
|
98
|
+
self.qBit_Port,
|
|
99
|
+
self.qBit_UserName,
|
|
100
|
+
_mask_secret(self.qBit_Password),
|
|
101
|
+
)
|
|
102
|
+
self._validated_version = False
|
|
103
|
+
self.client = None
|
|
104
|
+
self.current_qbit_version = None
|
|
105
|
+
if not (QBIT_DISABLED or SEARCH_ONLY):
|
|
106
|
+
self.client = qbittorrentapi.Client(
|
|
107
|
+
host=self.qBit_Host,
|
|
108
|
+
port=self.qBit_Port,
|
|
109
|
+
username=self.qBit_UserName,
|
|
110
|
+
password=self.qBit_Password,
|
|
111
|
+
SIMPLE_RESPONSES=False,
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
self.current_qbit_version = version_parser.parse(self.client.app_version())
|
|
115
|
+
self._validated_version = True
|
|
116
|
+
except Exception as e:
|
|
117
|
+
self.current_qbit_version = self.min_supported_version
|
|
118
|
+
self.logger.error(
|
|
119
|
+
"Could not establish qBitTorrent version (%s). You may experience errors; please report this.",
|
|
120
|
+
e,
|
|
121
|
+
)
|
|
122
|
+
self._version_validator()
|
|
123
|
+
self.expiring_bool = ExpiringSet(max_age_seconds=10)
|
|
124
|
+
self.cache = {}
|
|
125
|
+
self.name_cache = {}
|
|
126
|
+
self.should_delay_torrent_scan = False # If true torrent scan is delayed by 5 minutes.
|
|
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
|
|
135
|
+
self.ffprobe_downloader = FFprobeDownloader()
|
|
136
|
+
# Process auto-restart tracking
|
|
137
|
+
self._process_restart_counts: dict[tuple[str, str], list[float]] = (
|
|
138
|
+
{}
|
|
139
|
+
) # (category, role) -> [timestamps]
|
|
140
|
+
self.auto_restart_enabled = CONFIG.get("Settings.AutoRestartProcesses", fallback=True)
|
|
141
|
+
self.max_process_restarts = CONFIG.get("Settings.MaxProcessRestarts", fallback=5)
|
|
142
|
+
self.process_restart_window = CONFIG.get("Settings.ProcessRestartWindow", fallback=300)
|
|
143
|
+
self.process_restart_delay = CONFIG.get("Settings.ProcessRestartDelay", fallback=5)
|
|
144
|
+
try:
|
|
145
|
+
if not (QBIT_DISABLED or SEARCH_ONLY):
|
|
146
|
+
self.ffprobe_downloader.update()
|
|
147
|
+
except Exception as e:
|
|
148
|
+
self.logger.error(
|
|
149
|
+
"FFprobe manager error: %s while attempting to download/update FFprobe", e
|
|
150
|
+
)
|
|
151
|
+
# Start WebUI as early as possible
|
|
152
|
+
try:
|
|
153
|
+
web_port = int(CONFIG.get("WebUI.Port", fallback=6969) or 6969)
|
|
154
|
+
except Exception:
|
|
155
|
+
web_port = 6969
|
|
156
|
+
web_host = CONFIG.get("WebUI.Host", fallback="127.0.0.1") or "127.0.0.1"
|
|
157
|
+
if os.environ.get("QBITRR_DOCKER_RUNNING") == "69420" and web_host in {
|
|
158
|
+
"127.0.0.1",
|
|
159
|
+
"localhost",
|
|
160
|
+
}:
|
|
161
|
+
web_host = "0.0.0.0"
|
|
162
|
+
if web_host in {"0.0.0.0", "::"}:
|
|
163
|
+
self.logger.warning(
|
|
164
|
+
"WebUI host configured for %s; ensure exposure is intentional and protected.",
|
|
165
|
+
web_host,
|
|
166
|
+
)
|
|
167
|
+
self.webui = WebUI(self, host=web_host, port=web_port)
|
|
168
|
+
self.webui.start()
|
|
169
|
+
|
|
170
|
+
# Finish bootstrap tasks (Arr manager, workers, auto-update) in the background
|
|
171
|
+
self._startup_thread = Thread(
|
|
172
|
+
target=self._complete_startup, name="qBitrr-Startup", daemon=True
|
|
173
|
+
)
|
|
174
|
+
self._startup_thread.start()
|
|
175
|
+
|
|
176
|
+
def configure_auto_update(self) -> None:
|
|
177
|
+
enabled, cron = get_auto_update_settings()
|
|
178
|
+
if self.auto_updater:
|
|
179
|
+
self.auto_updater.stop()
|
|
180
|
+
self.auto_updater = None
|
|
181
|
+
if not enabled:
|
|
182
|
+
self.logger.debug("Auto update is disabled")
|
|
183
|
+
return
|
|
184
|
+
updater = AutoUpdater(cron, self._perform_auto_update, self.logger)
|
|
185
|
+
if updater.start():
|
|
186
|
+
self.auto_updater = updater
|
|
187
|
+
else:
|
|
188
|
+
self.logger.error("Auto update could not be scheduled; leaving it disabled")
|
|
189
|
+
|
|
190
|
+
def _perform_auto_update(self) -> None:
|
|
191
|
+
"""Check for updates and apply if available."""
|
|
192
|
+
self.logger.notice("Checking for updates...")
|
|
193
|
+
|
|
194
|
+
# Fetch latest release info from GitHub
|
|
195
|
+
release_info = fetch_latest_release()
|
|
196
|
+
|
|
197
|
+
if release_info.get("error"):
|
|
198
|
+
self.logger.error("Auto update skipped: %s", release_info["error"])
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
# Use normalized version for comparison, raw tag for display
|
|
202
|
+
target_version = release_info.get("normalized")
|
|
203
|
+
raw_tag = release_info.get("raw_tag")
|
|
204
|
+
|
|
205
|
+
if not release_info.get("update_available"):
|
|
206
|
+
if target_version:
|
|
207
|
+
self.logger.info(
|
|
208
|
+
"Auto update skipped: already running the latest release (%s).",
|
|
209
|
+
raw_tag or target_version,
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
self.logger.info("Auto update skipped: no new release detected.")
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
# Detect installation type
|
|
216
|
+
from qBitrr.auto_update import get_installation_type
|
|
217
|
+
|
|
218
|
+
install_type = get_installation_type()
|
|
219
|
+
|
|
220
|
+
self.logger.notice(
|
|
221
|
+
"Update available: %s -> %s (installation: %s)",
|
|
222
|
+
patched_version,
|
|
223
|
+
raw_tag or target_version,
|
|
224
|
+
install_type,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Perform the update with specific version
|
|
228
|
+
updated = perform_self_update(self.logger, target_version=target_version)
|
|
229
|
+
|
|
230
|
+
if not updated:
|
|
231
|
+
if install_type == "binary":
|
|
232
|
+
# Binary installations require manual update, this is expected
|
|
233
|
+
self.logger.info("Manual update required for binary installation")
|
|
234
|
+
else:
|
|
235
|
+
self.logger.error("Auto update failed; manual intervention may be required.")
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
# Verify update success (git/pip only)
|
|
239
|
+
if target_version and install_type != "binary":
|
|
240
|
+
from qBitrr.auto_update import verify_update_success
|
|
241
|
+
|
|
242
|
+
if verify_update_success(target_version, self.logger):
|
|
243
|
+
self.logger.notice("Update verified successfully")
|
|
244
|
+
else:
|
|
245
|
+
self.logger.warning(
|
|
246
|
+
"Update completed but version verification failed. "
|
|
247
|
+
"The system may not be running the expected version."
|
|
248
|
+
)
|
|
249
|
+
# Continue with restart anyway (Phase 1 approach)
|
|
250
|
+
|
|
251
|
+
self.logger.notice("Update applied successfully; restarting to load the new version.")
|
|
252
|
+
self.request_restart()
|
|
253
|
+
|
|
254
|
+
def request_restart(self, delay: float = 3.0) -> None:
|
|
255
|
+
if self._restart_requested:
|
|
256
|
+
return
|
|
257
|
+
self._restart_requested = True
|
|
258
|
+
|
|
259
|
+
def _restart():
|
|
260
|
+
if delay > 0:
|
|
261
|
+
time.sleep(delay)
|
|
262
|
+
self.logger.notice("Restarting qBitrr...")
|
|
263
|
+
|
|
264
|
+
# Set shutdown event to signal all loops to stop
|
|
265
|
+
try:
|
|
266
|
+
self.shutdown_event.set()
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
# Wait for child processes to exit gracefully
|
|
271
|
+
for proc in list(self.child_processes):
|
|
272
|
+
with contextlib.suppress(Exception):
|
|
273
|
+
proc.join(timeout=5)
|
|
274
|
+
|
|
275
|
+
# Force kill any remaining child processes
|
|
276
|
+
for proc in list(self.child_processes):
|
|
277
|
+
with contextlib.suppress(Exception):
|
|
278
|
+
proc.kill()
|
|
279
|
+
with contextlib.suppress(Exception):
|
|
280
|
+
proc.terminate()
|
|
281
|
+
|
|
282
|
+
# Close database connections explicitly
|
|
283
|
+
try:
|
|
284
|
+
if hasattr(self, "arr_manager") and self.arr_manager:
|
|
285
|
+
for arr in self.arr_manager.managed_objects.values():
|
|
286
|
+
if hasattr(arr, "db") and arr.db:
|
|
287
|
+
with contextlib.suppress(Exception):
|
|
288
|
+
arr.db.close()
|
|
289
|
+
except Exception:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
# Flush all log handlers
|
|
293
|
+
try:
|
|
294
|
+
for handler in logging.root.handlers[:]:
|
|
295
|
+
with contextlib.suppress(Exception):
|
|
296
|
+
handler.flush()
|
|
297
|
+
handler.close()
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
# Prepare restart arguments
|
|
302
|
+
python = sys.executable
|
|
303
|
+
args = [python] + sys.argv
|
|
304
|
+
|
|
305
|
+
self.logger.notice("Executing restart: %s", " ".join(args))
|
|
306
|
+
|
|
307
|
+
# Flush logs one final time before exec
|
|
308
|
+
try:
|
|
309
|
+
for handler in self.logger.handlers[:]:
|
|
310
|
+
with contextlib.suppress(Exception):
|
|
311
|
+
handler.flush()
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
# Replace current process with new instance
|
|
316
|
+
# This works in Docker, native installs, and systemd
|
|
317
|
+
try:
|
|
318
|
+
os.execv(python, args)
|
|
319
|
+
except Exception as e:
|
|
320
|
+
# If execv fails, fall back to exit and hope external supervisor restarts us
|
|
321
|
+
self.logger.critical("Failed to restart via execv: %s. Exiting instead.", e)
|
|
322
|
+
os._exit(1)
|
|
323
|
+
|
|
324
|
+
self._restart_thread = Thread(target=_restart, name="qBitrr-Restart", daemon=True)
|
|
325
|
+
self._restart_thread.start()
|
|
326
|
+
|
|
327
|
+
def _prepare_arr_processes(self, arr, timeout_seconds: int = 30) -> None:
|
|
328
|
+
timeout = max(
|
|
329
|
+
1, int(CONFIG.get("Settings.ProcessSpawnTimeoutSeconds", fallback=timeout_seconds))
|
|
330
|
+
)
|
|
331
|
+
result_queue: SimpleQueue = SimpleQueue()
|
|
332
|
+
|
|
333
|
+
def _stage():
|
|
334
|
+
try:
|
|
335
|
+
result_queue.put((True, arr.spawn_child_processes()))
|
|
336
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
337
|
+
result_queue.put((False, exc))
|
|
338
|
+
|
|
339
|
+
spawn_thread = Thread(
|
|
340
|
+
target=_stage,
|
|
341
|
+
name=f"spawn-{getattr(arr, 'category', getattr(arr, '_name', 'arr'))}",
|
|
342
|
+
daemon=True,
|
|
343
|
+
)
|
|
344
|
+
spawn_thread.start()
|
|
345
|
+
spawn_thread.join(timeout)
|
|
346
|
+
if spawn_thread.is_alive():
|
|
347
|
+
self.logger.error(
|
|
348
|
+
"Timed out initialising worker processes for %s after %ss; skipping this instance.",
|
|
349
|
+
getattr(arr, "_name", getattr(arr, "category", "unknown")),
|
|
350
|
+
timeout,
|
|
351
|
+
)
|
|
352
|
+
return
|
|
353
|
+
if result_queue.empty():
|
|
354
|
+
self.logger.error(
|
|
355
|
+
"No startup result returned for %s; skipping this instance.",
|
|
356
|
+
getattr(arr, "_name", getattr(arr, "category", "unknown")),
|
|
357
|
+
)
|
|
358
|
+
return
|
|
359
|
+
success, payload = result_queue.get()
|
|
360
|
+
if not success:
|
|
361
|
+
self.logger.exception(
|
|
362
|
+
"Failed to initialise worker processes for %s",
|
|
363
|
+
getattr(arr, "_name", getattr(arr, "category", "unknown")),
|
|
364
|
+
exc_info=payload,
|
|
365
|
+
)
|
|
366
|
+
return
|
|
367
|
+
worker_count, processes = payload
|
|
368
|
+
if not worker_count:
|
|
369
|
+
return
|
|
370
|
+
for proc in processes:
|
|
371
|
+
role = "search" if getattr(arr, "process_search_loop", None) is proc else "torrent"
|
|
372
|
+
self._process_registry[proc] = {
|
|
373
|
+
"category": getattr(arr, "category", ""),
|
|
374
|
+
"name": getattr(arr, "_name", getattr(arr, "category", "")),
|
|
375
|
+
"role": role or "worker",
|
|
376
|
+
}
|
|
377
|
+
self.logger.debug(
|
|
378
|
+
"Prepared %s worker(s) for %s",
|
|
379
|
+
worker_count,
|
|
380
|
+
getattr(arr, "_name", getattr(arr, "category", "unknown")),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def _complete_startup(self) -> None:
|
|
384
|
+
started_at = monotonic()
|
|
385
|
+
try:
|
|
386
|
+
arr_manager = ArrManager(self)
|
|
387
|
+
self.arr_manager = arr_manager
|
|
388
|
+
arr_manager.build_arr_instances()
|
|
389
|
+
run_logs(self.logger)
|
|
390
|
+
for arr in arr_manager.managed_objects.values():
|
|
391
|
+
self._prepare_arr_processes(arr)
|
|
392
|
+
self.configure_auto_update()
|
|
393
|
+
elapsed = monotonic() - started_at
|
|
394
|
+
self.logger.info("Background startup completed in %.1fs", elapsed)
|
|
395
|
+
except Exception:
|
|
396
|
+
self.logger.exception(
|
|
397
|
+
"Background startup encountered an error; continuing with partial functionality."
|
|
398
|
+
)
|
|
399
|
+
finally:
|
|
400
|
+
self._bootstrap_ready.set()
|
|
401
|
+
|
|
402
|
+
def _version_validator(self):
|
|
403
|
+
validated = False
|
|
404
|
+
if (
|
|
405
|
+
self.min_supported_version
|
|
406
|
+
<= self.current_qbit_version
|
|
407
|
+
# <= self.max_supported_version
|
|
408
|
+
):
|
|
409
|
+
validated = True
|
|
410
|
+
|
|
411
|
+
if self._validated_version and validated:
|
|
412
|
+
self.logger.info(
|
|
413
|
+
"Current qBitTorrent version is supported: %s", self.current_qbit_version
|
|
414
|
+
)
|
|
415
|
+
elif not self._validated_version and validated:
|
|
416
|
+
self.logger.warning(
|
|
417
|
+
"Could not validate current qBitTorrent version, assuming: %s",
|
|
418
|
+
self.current_qbit_version,
|
|
419
|
+
)
|
|
420
|
+
else:
|
|
421
|
+
self.logger.critical(
|
|
422
|
+
"You are currently running qBitTorrent version %s which is not supported by qBitrr.",
|
|
423
|
+
# "Supported version range is %s to < %s",
|
|
424
|
+
self.current_qbit_version,
|
|
425
|
+
# self.min_supported_version,
|
|
426
|
+
# self.max_supported_version,
|
|
427
|
+
)
|
|
428
|
+
sys.exit(1)
|
|
429
|
+
|
|
430
|
+
# @response_text(str)
|
|
431
|
+
# @login_required
|
|
432
|
+
def app_version(self, **kwargs):
|
|
433
|
+
return self.client._get(
|
|
434
|
+
_name=APINames.Application,
|
|
435
|
+
_method="version",
|
|
436
|
+
_retries=0,
|
|
437
|
+
_retry_backoff_factor=0,
|
|
438
|
+
**kwargs,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def transfer_info(self, **kwargs):
|
|
442
|
+
"""Proxy transfer info requests to the underlying qBittorrent client."""
|
|
443
|
+
if self.client is None:
|
|
444
|
+
return {"connection_status": "disconnected"}
|
|
445
|
+
return self.client.transfer_info(**kwargs)
|
|
446
|
+
|
|
447
|
+
@property
|
|
448
|
+
def is_alive(self) -> bool:
|
|
449
|
+
try:
|
|
450
|
+
if self.client is None:
|
|
451
|
+
return False
|
|
452
|
+
if 1 in self.expiring_bool:
|
|
453
|
+
return True
|
|
454
|
+
self.client.app_version()
|
|
455
|
+
self.logger.trace("Successfully connected to %s:%s", self.qBit_Host, self.qBit_Port)
|
|
456
|
+
self.expiring_bool.add(1)
|
|
457
|
+
return True
|
|
458
|
+
except requests.RequestException:
|
|
459
|
+
self.logger.warning("Could not connect to %s:%s", self.qBit_Host, self.qBit_Port)
|
|
460
|
+
self.should_delay_torrent_scan = True
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
def get_child_processes(self, timeout: float = 60.0) -> list[pathos.helpers.mp.Process]:
|
|
464
|
+
if not self._bootstrap_ready.wait(timeout):
|
|
465
|
+
self.logger.warning(
|
|
466
|
+
"Background startup did not finish within %.1fs. Continuing with the services currently available.",
|
|
467
|
+
timeout,
|
|
468
|
+
)
|
|
469
|
+
return list(self.child_processes)
|
|
470
|
+
|
|
471
|
+
def run(self) -> None:
|
|
472
|
+
try:
|
|
473
|
+
if not self._bootstrap_ready.wait(60.0):
|
|
474
|
+
self.logger.warning(
|
|
475
|
+
"Startup thread still running after 60s; managing available workers."
|
|
476
|
+
)
|
|
477
|
+
for proc in list(self.child_processes):
|
|
478
|
+
try:
|
|
479
|
+
# Check if process has already been started
|
|
480
|
+
if proc.is_alive() or proc.exitcode is not None:
|
|
481
|
+
meta = self._process_registry.get(proc, {})
|
|
482
|
+
self.logger.warning(
|
|
483
|
+
"Skipping start of already-started %s worker for category '%s' (alive=%s, exitcode=%s)",
|
|
484
|
+
meta.get("role", "worker"),
|
|
485
|
+
meta.get("category", "unknown"),
|
|
486
|
+
proc.is_alive(),
|
|
487
|
+
proc.exitcode,
|
|
488
|
+
)
|
|
489
|
+
continue
|
|
490
|
+
|
|
491
|
+
proc.start()
|
|
492
|
+
meta = self._process_registry.get(proc, {})
|
|
493
|
+
self.logger.debug(
|
|
494
|
+
"Started %s worker for category '%s'",
|
|
495
|
+
meta.get("role", "worker"),
|
|
496
|
+
meta.get("category", "unknown"),
|
|
497
|
+
)
|
|
498
|
+
except Exception as exc:
|
|
499
|
+
self.logger.exception(
|
|
500
|
+
"Failed to start worker process %s",
|
|
501
|
+
getattr(proc, "name", repr(proc)),
|
|
502
|
+
exc_info=exc,
|
|
503
|
+
)
|
|
504
|
+
while not self.shutdown_event.is_set():
|
|
505
|
+
any_alive = False
|
|
506
|
+
for proc in list(self.child_processes):
|
|
507
|
+
if proc.is_alive():
|
|
508
|
+
any_alive = True
|
|
509
|
+
continue
|
|
510
|
+
exit_code = proc.exitcode
|
|
511
|
+
if exit_code is None:
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
meta = self._process_registry.get(proc, {})
|
|
515
|
+
category = meta.get("category", "unknown")
|
|
516
|
+
role = meta.get("role", "unknown")
|
|
517
|
+
|
|
518
|
+
self.logger.warning(
|
|
519
|
+
"Worker process exited (role=%s, category=%s, code=%s)",
|
|
520
|
+
role,
|
|
521
|
+
category,
|
|
522
|
+
exit_code,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# Attempt auto-restart if enabled and process crashed (non-zero exit)
|
|
526
|
+
if self.auto_restart_enabled and exit_code != 0:
|
|
527
|
+
if self._should_restart_process(category, role):
|
|
528
|
+
self.logger.info(
|
|
529
|
+
"Attempting to restart %s worker for category '%s'",
|
|
530
|
+
role,
|
|
531
|
+
category,
|
|
532
|
+
)
|
|
533
|
+
if self._restart_process(proc, meta):
|
|
534
|
+
continue # Keep process in list, skip removal
|
|
535
|
+
else:
|
|
536
|
+
self.logger.error(
|
|
537
|
+
"Failed to restart %s worker for category '%s'",
|
|
538
|
+
role,
|
|
539
|
+
category,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# Remove process if not restarted
|
|
543
|
+
self._process_registry.pop(proc, None)
|
|
544
|
+
with contextlib.suppress(ValueError):
|
|
545
|
+
self.child_processes.remove(proc)
|
|
546
|
+
|
|
547
|
+
if not self.child_processes:
|
|
548
|
+
if not any_alive:
|
|
549
|
+
break
|
|
550
|
+
self.shutdown_event.wait(timeout=5)
|
|
551
|
+
if not any(proc.is_alive() for proc in self.child_processes):
|
|
552
|
+
if self.child_processes:
|
|
553
|
+
continue
|
|
554
|
+
break
|
|
555
|
+
except KeyboardInterrupt:
|
|
556
|
+
self.logger.info("Detected Ctrl+C - Terminating process")
|
|
557
|
+
sys.exit(0)
|
|
558
|
+
except BaseException as e:
|
|
559
|
+
self.logger.info("Detected unexpected error, shutting down: %r", e)
|
|
560
|
+
sys.exit(1)
|
|
561
|
+
finally:
|
|
562
|
+
for proc in list(self.child_processes):
|
|
563
|
+
if proc.is_alive():
|
|
564
|
+
proc.join(timeout=1)
|
|
565
|
+
|
|
566
|
+
def _should_restart_process(self, category: str, role: str) -> bool:
|
|
567
|
+
"""
|
|
568
|
+
Determine if a process should be restarted based on restart count and window.
|
|
569
|
+
|
|
570
|
+
Tracks restart attempts per (category, role) combination and prevents
|
|
571
|
+
crash loops by enforcing maximum restart limits within a time window.
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
category: The Arr category (e.g., "radarr", "sonarr")
|
|
575
|
+
role: The process role ("search" or "torrent")
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
bool: True if process should be restarted, False otherwise
|
|
579
|
+
"""
|
|
580
|
+
key = (category, role)
|
|
581
|
+
now = time.time()
|
|
582
|
+
|
|
583
|
+
# Get restart history for this process type
|
|
584
|
+
if key not in self._process_restart_counts:
|
|
585
|
+
self._process_restart_counts[key] = []
|
|
586
|
+
|
|
587
|
+
restart_times = self._process_restart_counts[key]
|
|
588
|
+
|
|
589
|
+
# Remove timestamps outside the restart window
|
|
590
|
+
restart_times[:] = [t for t in restart_times if now - t < self.process_restart_window]
|
|
591
|
+
|
|
592
|
+
# Check if we've exceeded max restarts
|
|
593
|
+
if len(restart_times) >= self.max_process_restarts:
|
|
594
|
+
self.logger.error(
|
|
595
|
+
"Process %s/%s has failed %d times in %d seconds. Auto-restart disabled for this process.",
|
|
596
|
+
category,
|
|
597
|
+
role,
|
|
598
|
+
len(restart_times),
|
|
599
|
+
self.process_restart_window,
|
|
600
|
+
)
|
|
601
|
+
return False
|
|
602
|
+
|
|
603
|
+
return True
|
|
604
|
+
|
|
605
|
+
def _restart_process(
|
|
606
|
+
self, failed_proc: pathos.helpers.mp.Process, meta: dict[str, str]
|
|
607
|
+
) -> bool:
|
|
608
|
+
"""
|
|
609
|
+
Restart a failed worker process.
|
|
610
|
+
|
|
611
|
+
Creates a new process instance with the same target function, starts it,
|
|
612
|
+
and updates all tracking structures to reference the new process.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
failed_proc: The failed process object
|
|
616
|
+
meta: Process metadata dict with keys: category, name, role
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
bool: True if restart successful, False otherwise
|
|
620
|
+
"""
|
|
621
|
+
category = meta.get("category", "")
|
|
622
|
+
role = meta.get("role", "worker")
|
|
623
|
+
meta.get("name", "")
|
|
624
|
+
|
|
625
|
+
try:
|
|
626
|
+
# Wait before restarting
|
|
627
|
+
if self.process_restart_delay > 0:
|
|
628
|
+
self.logger.debug(
|
|
629
|
+
"Waiting %ds before restarting %s worker for '%s'",
|
|
630
|
+
self.process_restart_delay,
|
|
631
|
+
role,
|
|
632
|
+
category,
|
|
633
|
+
)
|
|
634
|
+
time.sleep(self.process_restart_delay)
|
|
635
|
+
|
|
636
|
+
# Find the corresponding Arr instance
|
|
637
|
+
if not self.arr_manager:
|
|
638
|
+
self.logger.error("ArrManager not available for process restart")
|
|
639
|
+
return False
|
|
640
|
+
|
|
641
|
+
arr = self.arr_manager.managed_objects.get(category)
|
|
642
|
+
if not arr:
|
|
643
|
+
self.logger.error("Cannot find Arr instance for category '%s'", category)
|
|
644
|
+
return False
|
|
645
|
+
|
|
646
|
+
# Recreate the process based on role
|
|
647
|
+
new_proc = None
|
|
648
|
+
if role == "search" and hasattr(arr, "run_search_loop"):
|
|
649
|
+
new_proc = pathos.helpers.mp.Process(target=arr.run_search_loop, daemon=False)
|
|
650
|
+
if hasattr(arr, "process_search_loop"):
|
|
651
|
+
arr.process_search_loop = new_proc
|
|
652
|
+
elif role == "torrent" and hasattr(arr, "run_torrent_loop"):
|
|
653
|
+
new_proc = pathos.helpers.mp.Process(target=arr.run_torrent_loop, daemon=False)
|
|
654
|
+
if hasattr(arr, "process_torrent_loop"):
|
|
655
|
+
arr.process_torrent_loop = new_proc
|
|
656
|
+
else:
|
|
657
|
+
self.logger.error(
|
|
658
|
+
"Unknown role '%s' for category '%s' or target method not found",
|
|
659
|
+
role,
|
|
660
|
+
category,
|
|
661
|
+
)
|
|
662
|
+
return False
|
|
663
|
+
|
|
664
|
+
if not new_proc:
|
|
665
|
+
return False
|
|
666
|
+
|
|
667
|
+
# Start the new process
|
|
668
|
+
new_proc.start()
|
|
669
|
+
|
|
670
|
+
# Update restart tracking
|
|
671
|
+
key = (category, role)
|
|
672
|
+
self._process_restart_counts.setdefault(key, []).append(time.time())
|
|
673
|
+
|
|
674
|
+
# Replace in child_processes list
|
|
675
|
+
with contextlib.suppress(ValueError):
|
|
676
|
+
self.child_processes.remove(failed_proc)
|
|
677
|
+
self.child_processes.append(new_proc)
|
|
678
|
+
|
|
679
|
+
# Update registry
|
|
680
|
+
self._process_registry.pop(failed_proc, None)
|
|
681
|
+
self._process_registry[new_proc] = meta
|
|
682
|
+
|
|
683
|
+
self.logger.notice(
|
|
684
|
+
"Successfully restarted %s worker for category '%s' (restarts in window: %d/%d)",
|
|
685
|
+
role,
|
|
686
|
+
category,
|
|
687
|
+
len(self._process_restart_counts[key]),
|
|
688
|
+
self.max_process_restarts,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
return True
|
|
692
|
+
|
|
693
|
+
except Exception as e:
|
|
694
|
+
self.logger.exception(
|
|
695
|
+
"Failed to restart %s worker for category '%s': %s", role, category, e
|
|
696
|
+
)
|
|
697
|
+
return False
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _report_config_issues():
|
|
701
|
+
try:
|
|
702
|
+
issues = []
|
|
703
|
+
# Check required settings
|
|
704
|
+
from qBitrr.config import COMPLETED_DOWNLOAD_FOLDER, CONFIG, FREE_SPACE, FREE_SPACE_FOLDER
|
|
705
|
+
|
|
706
|
+
if not COMPLETED_DOWNLOAD_FOLDER or str(COMPLETED_DOWNLOAD_FOLDER).upper() == "CHANGE_ME":
|
|
707
|
+
issues.append("Settings.CompletedDownloadFolder is missing or set to CHANGE_ME")
|
|
708
|
+
if FREE_SPACE != "-1":
|
|
709
|
+
if not FREE_SPACE_FOLDER or str(FREE_SPACE_FOLDER).upper() == "CHANGE_ME":
|
|
710
|
+
issues.append("Settings.FreeSpaceFolder must be set when FreeSpace is enabled")
|
|
711
|
+
# Check Arr sections
|
|
712
|
+
for key in CONFIG.sections():
|
|
713
|
+
import re
|
|
714
|
+
|
|
715
|
+
m = re.match(r"radarr.*", key, re.IGNORECASE)
|
|
716
|
+
if not m:
|
|
717
|
+
continue
|
|
718
|
+
managed = CONFIG.get(f"{key}.Managed", fallback=False)
|
|
719
|
+
if not managed:
|
|
720
|
+
continue
|
|
721
|
+
uri = CONFIG.get(f"{key}.URI", fallback=None)
|
|
722
|
+
apikey = CONFIG.get(f"{key}.APIKey", fallback=None)
|
|
723
|
+
if not uri or str(uri).upper() == "CHANGE_ME":
|
|
724
|
+
issues.append(f"{key}.URI is missing or set to CHANGE_ME")
|
|
725
|
+
if not apikey or str(apikey).upper() == "CHANGE_ME":
|
|
726
|
+
issues.append(f"{key}.APIKey is missing or set to CHANGE_ME")
|
|
727
|
+
if issues:
|
|
728
|
+
logger.error("Configuration issues detected:")
|
|
729
|
+
for i in issues:
|
|
730
|
+
logger.error(" - %s", i)
|
|
731
|
+
except Exception as e:
|
|
732
|
+
logger.debug("Config validation skipped due to error: %s", e)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def run():
|
|
736
|
+
early_exit = process_flags()
|
|
737
|
+
if early_exit is True:
|
|
738
|
+
sys.exit(0)
|
|
739
|
+
logger.info("Starting qBitrr: Version: %s.", patched_version)
|
|
740
|
+
|
|
741
|
+
# Delete all databases on startup
|
|
742
|
+
_delete_all_databases()
|
|
743
|
+
|
|
744
|
+
try:
|
|
745
|
+
manager = qBitManager()
|
|
746
|
+
except NameError:
|
|
747
|
+
sys.exit(0)
|
|
748
|
+
run_logs(logger)
|
|
749
|
+
# Early consolidated config validation feedback
|
|
750
|
+
_report_config_issues()
|
|
751
|
+
logger.debug("Environment variables: %r", ENVIRO_CONFIG)
|
|
752
|
+
try:
|
|
753
|
+
manager.get_child_processes()
|
|
754
|
+
|
|
755
|
+
# Register cleanup for child processes when the main process exits
|
|
756
|
+
def _cleanup():
|
|
757
|
+
# Signal loops to shutdown gracefully
|
|
758
|
+
try:
|
|
759
|
+
manager.shutdown_event.set()
|
|
760
|
+
except Exception:
|
|
761
|
+
pass
|
|
762
|
+
# Give processes a chance to exit
|
|
763
|
+
for p in manager.child_processes:
|
|
764
|
+
with contextlib.suppress(Exception):
|
|
765
|
+
p.join(timeout=5)
|
|
766
|
+
for p in manager.child_processes:
|
|
767
|
+
with contextlib.suppress(Exception):
|
|
768
|
+
p.kill()
|
|
769
|
+
with contextlib.suppress(Exception):
|
|
770
|
+
p.terminate()
|
|
771
|
+
|
|
772
|
+
atexit.register(_cleanup)
|
|
773
|
+
if manager.child_processes:
|
|
774
|
+
manager.run()
|
|
775
|
+
else:
|
|
776
|
+
logger.warning(
|
|
777
|
+
"No tasks to perform, if this is unintended double check your config file."
|
|
778
|
+
)
|
|
779
|
+
except KeyboardInterrupt:
|
|
780
|
+
logger.info("Detected Ctrl+C - Terminating process")
|
|
781
|
+
sys.exit(0)
|
|
782
|
+
except Exception:
|
|
783
|
+
logger.info("Attempting to terminate child processes, please wait a moment.")
|
|
784
|
+
for child in manager.child_processes:
|
|
785
|
+
child.kill()
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
if __name__ == "__main__":
|
|
789
|
+
freeze_support()
|
|
790
|
+
run()
|