qBitrr2 4.10.15__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.
Files changed (59) hide show
  1. qBitrr/arss.py +2127 -850
  2. qBitrr/auto_update.py +382 -0
  3. qBitrr/bundled_data.py +3 -2
  4. qBitrr/config.py +20 -3
  5. qBitrr/db_lock.py +79 -0
  6. qBitrr/env_config.py +19 -7
  7. qBitrr/gen_config.py +286 -26
  8. qBitrr/logger.py +87 -3
  9. qBitrr/main.py +453 -101
  10. qBitrr/search_activity_store.py +88 -0
  11. qBitrr/static/assets/ArrView.js +2 -0
  12. qBitrr/static/assets/ArrView.js.map +1 -0
  13. qBitrr/static/assets/ConfigView.js +4 -0
  14. qBitrr/static/assets/ConfigView.js.map +1 -0
  15. qBitrr/static/assets/LogsView.js +230 -0
  16. qBitrr/static/assets/LogsView.js.map +1 -0
  17. qBitrr/static/assets/ProcessesView.js +2 -0
  18. qBitrr/static/assets/ProcessesView.js.map +1 -0
  19. qBitrr/static/assets/app.css +1 -0
  20. qBitrr/static/assets/app.js +11 -0
  21. qBitrr/static/assets/app.js.map +1 -0
  22. qBitrr/static/assets/build.svg +3 -0
  23. qBitrr/static/assets/check-mark.svg +5 -0
  24. qBitrr/static/assets/close.svg +4 -0
  25. qBitrr/static/assets/download.svg +5 -0
  26. qBitrr/static/assets/gear.svg +5 -0
  27. qBitrr/static/assets/lidarr.svg +1 -0
  28. qBitrr/static/assets/live-streaming.svg +8 -0
  29. qBitrr/static/assets/log.svg +3 -0
  30. qBitrr/static/assets/plus.svg +4 -0
  31. qBitrr/static/assets/process.svg +15 -0
  32. qBitrr/static/assets/react-select.esm.js +14 -0
  33. qBitrr/static/assets/react-select.esm.js.map +1 -0
  34. qBitrr/static/assets/refresh-arrow.svg +3 -0
  35. qBitrr/static/assets/table.js +23 -0
  36. qBitrr/static/assets/table.js.map +1 -0
  37. qBitrr/static/assets/trash.svg +8 -0
  38. qBitrr/static/assets/up-arrow.svg +3 -0
  39. qBitrr/static/assets/useInterval.js +2 -0
  40. qBitrr/static/assets/useInterval.js.map +1 -0
  41. qBitrr/static/assets/vendor.js +33 -0
  42. qBitrr/static/assets/vendor.js.map +1 -0
  43. qBitrr/static/assets/visibility.svg +9 -0
  44. qBitrr/static/index.html +47 -0
  45. qBitrr/static/manifest.json +23 -0
  46. qBitrr/static/sw.js +105 -0
  47. qBitrr/static/vite.svg +1 -0
  48. qBitrr/tables.py +44 -0
  49. qBitrr/utils.py +82 -15
  50. qBitrr/versioning.py +136 -0
  51. qBitrr/webui.py +2612 -0
  52. qbitrr2-5.4.5.dist-info/METADATA +1116 -0
  53. qbitrr2-5.4.5.dist-info/RECORD +61 -0
  54. {qbitrr2-4.10.15.dist-info → qbitrr2-5.4.5.dist-info}/WHEEL +1 -1
  55. qbitrr2-4.10.15.dist-info/METADATA +0 -239
  56. qbitrr2-4.10.15.dist-info/RECORD +0 -19
  57. {qbitrr2-4.10.15.dist-info → qbitrr2-5.4.5.dist-info}/entry_points.txt +0 -0
  58. {qbitrr2-4.10.15.dist-info → qbitrr2-5.4.5.dist-info/licenses}/LICENSE +0 -0
  59. {qbitrr2-4.10.15.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 itertools
4
+ import contextlib
5
+ import glob
5
6
  import logging
6
- import pathlib
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.arss import ArrManager
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 HOME_PATH
35
+ from qBitrr.home_path import APPDATA_FOLDER
33
36
  from qBitrr.logger import run_logs
34
- from qBitrr.utils import ExpiringSet, absolute_file_paths
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
- # if ENABLE_LOGS:
45
- # logs_folder = HOME_PATH.joinpath("logs")
46
- # logs_folder.mkdir(parents=True, exist_ok=True)
47
- # logs_folder.chmod(mode=0o777)
48
- # logfile = logs_folder.joinpath("qBitrr.log")
49
- # if pathlib.Path(logfile).is_file():
50
- # logold = logs_folder.joinpath( "qBitrr.log.old")
51
- # if pathlib.Path(logold).exists():
52
- # logold.unlink()
53
- # logfile.rename(logold)
54
- # fh = logging.FileHandler(logfile)
55
- # logger.addHandler(fh)
56
- run_logs(logger)
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("4.6.7")
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
- if ENABLE_LOGS:
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 any([QBIT_DISABLED, SEARCH_ONLY]):
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 BaseException:
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
- "you may experience errors, please report this error."
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 any([QBIT_DISABLED, SEARCH_ONLY]):
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
- self.arr_manager = ArrManager(self).build_arr_instances()
128
- run_logs(self.logger)
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 self.qBit_v5:
133
- if self.min_supported_version <= self.current_qbit_version:
134
- validated = True
135
- else:
136
- if (
137
- self.min_supported_version
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 1 in self.expiring_bool or self.client is None:
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
- run_logs(self.logger)
190
- self.logger.debug("Managing %s categories", len(self.arr_manager.managed_objects))
191
- count = 0
192
- procs = []
193
- for arr in self.arr_manager.managed_objects.values():
194
- numb, processes = arr.spawn_child_processes()
195
- count += numb
196
- procs.extend(processes)
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.logger.debug("Starting %s child processes", len(self.child_processes))
202
- [p.start() for p in self.child_processes]
203
- [p.join() for p in self.child_processes]
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 Ctrl+C - Terminating process: %r", e)
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
- if CHILD_PROCESSES := manager.get_child_processes():
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()