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.
Files changed (64) hide show
  1. qBitrr/__init__.py +14 -0
  2. qBitrr/arss.py +7100 -0
  3. qBitrr/auto_update.py +382 -0
  4. qBitrr/bundled_data.py +7 -0
  5. qBitrr/config.py +192 -0
  6. qBitrr/config_version.py +144 -0
  7. qBitrr/db_lock.py +400 -0
  8. qBitrr/db_recovery.py +202 -0
  9. qBitrr/env_config.py +73 -0
  10. qBitrr/errors.py +41 -0
  11. qBitrr/ffprobe.py +105 -0
  12. qBitrr/gen_config.py +1331 -0
  13. qBitrr/home_path.py +23 -0
  14. qBitrr/logger.py +235 -0
  15. qBitrr/main.py +790 -0
  16. qBitrr/search_activity_store.py +92 -0
  17. qBitrr/static/assets/ArrView.js +2 -0
  18. qBitrr/static/assets/ArrView.js.map +1 -0
  19. qBitrr/static/assets/ConfigView.js +4 -0
  20. qBitrr/static/assets/ConfigView.js.map +1 -0
  21. qBitrr/static/assets/LogsView.js +2 -0
  22. qBitrr/static/assets/LogsView.js.map +1 -0
  23. qBitrr/static/assets/ProcessesView.js +2 -0
  24. qBitrr/static/assets/ProcessesView.js.map +1 -0
  25. qBitrr/static/assets/app.css +1 -0
  26. qBitrr/static/assets/app.js +11 -0
  27. qBitrr/static/assets/app.js.map +1 -0
  28. qBitrr/static/assets/build.svg +3 -0
  29. qBitrr/static/assets/check-mark.svg +5 -0
  30. qBitrr/static/assets/close.svg +4 -0
  31. qBitrr/static/assets/download.svg +5 -0
  32. qBitrr/static/assets/gear.svg +5 -0
  33. qBitrr/static/assets/live-streaming.svg +8 -0
  34. qBitrr/static/assets/log.svg +3 -0
  35. qBitrr/static/assets/logo.svg +48 -0
  36. qBitrr/static/assets/plus.svg +4 -0
  37. qBitrr/static/assets/process.svg +15 -0
  38. qBitrr/static/assets/react-select.esm.js +7 -0
  39. qBitrr/static/assets/react-select.esm.js.map +1 -0
  40. qBitrr/static/assets/refresh-arrow.svg +3 -0
  41. qBitrr/static/assets/table.js +5 -0
  42. qBitrr/static/assets/table.js.map +1 -0
  43. qBitrr/static/assets/trash.svg +8 -0
  44. qBitrr/static/assets/up-arrow.svg +3 -0
  45. qBitrr/static/assets/useInterval.js +2 -0
  46. qBitrr/static/assets/useInterval.js.map +1 -0
  47. qBitrr/static/assets/vendor.js +2 -0
  48. qBitrr/static/assets/vendor.js.map +1 -0
  49. qBitrr/static/assets/visibility.svg +9 -0
  50. qBitrr/static/index.html +33 -0
  51. qBitrr/static/logov2-clean.svg +48 -0
  52. qBitrr/static/manifest.json +23 -0
  53. qBitrr/static/sw.js +87 -0
  54. qBitrr/static/vite.svg +1 -0
  55. qBitrr/tables.py +143 -0
  56. qBitrr/utils.py +274 -0
  57. qBitrr/versioning.py +136 -0
  58. qBitrr/webui.py +3114 -0
  59. qbitrr2-5.5.5.dist-info/METADATA +1191 -0
  60. qbitrr2-5.5.5.dist-info/RECORD +64 -0
  61. qbitrr2-5.5.5.dist-info/WHEEL +5 -0
  62. qbitrr2-5.5.5.dist-info/entry_points.txt +2 -0
  63. qbitrr2-5.5.5.dist-info/licenses/LICENSE +21 -0
  64. 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()