qBitrr2 5.8.6__py3-none-any.whl → 5.8.8__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 CHANGED
@@ -7649,6 +7649,7 @@ class ArrManager:
7649
7649
  self.uris: set[str] = set()
7650
7650
  self.special_categories: set[str] = {FAILED_CATEGORY, RECHECK_CATEGORY}
7651
7651
  self.arr_categories: set[str] = set()
7652
+ self.qbit_managed_categories: set[str] = set()
7652
7653
  self.category_allowlist: set[str] = self.special_categories.copy()
7653
7654
  self.completed_folders: set[pathlib.Path] = set()
7654
7655
  self.managed_objects: dict[str, Arr] = {}
@@ -7663,6 +7664,62 @@ class ArrManager:
7663
7664
  self.qbit_manager.ffprobe_downloader.probe_path,
7664
7665
  )
7665
7666
 
7667
+ def _validate_category_assignments(self):
7668
+ """
7669
+ Validate that no category is managed by both Arr and qBit instances.
7670
+
7671
+ Collects all qBit-managed categories from all qBit instances and checks
7672
+ for conflicts with Arr-managed categories. Allows same category on
7673
+ multiple qBit instances (acceptable).
7674
+
7675
+ Raises:
7676
+ ValueError: If any category is managed by both Arr and qBit
7677
+ """
7678
+ # Collect qBit-managed categories from all instances
7679
+ for section in CONFIG.sections():
7680
+ # Check default qBit section
7681
+ if section == "qBit":
7682
+ managed_cats = CONFIG.get("qBit.ManagedCategories", fallback=[])
7683
+ if managed_cats:
7684
+ self.qbit_managed_categories.update(managed_cats)
7685
+ self.logger.debug(
7686
+ "qBit instance 'default' manages categories: %s",
7687
+ ", ".join(managed_cats),
7688
+ )
7689
+ # Check additional qBit-XXX sections
7690
+ elif section.startswith("qBit-"):
7691
+ instance_name = section.replace("qBit-", "", 1)
7692
+ managed_cats = CONFIG.get(f"{section}.ManagedCategories", fallback=[])
7693
+ if managed_cats:
7694
+ self.qbit_managed_categories.update(managed_cats)
7695
+ self.logger.debug(
7696
+ "qBit instance '%s' manages categories: %s",
7697
+ instance_name,
7698
+ ", ".join(managed_cats),
7699
+ )
7700
+
7701
+ # Check for conflicts between Arr and qBit categories
7702
+ conflicts = self.arr_categories & self.qbit_managed_categories
7703
+ if conflicts:
7704
+ conflict_list = ", ".join(sorted(conflicts))
7705
+ error_msg = (
7706
+ f"Category conflict detected: {conflict_list} "
7707
+ f"cannot be managed by both Arr instances and qBit instances. "
7708
+ f"Please assign each category to either Arr OR qBit management, not both."
7709
+ )
7710
+ self.logger.error(error_msg)
7711
+ raise ValueError(error_msg)
7712
+
7713
+ # Update category allowlist to include qBit-managed categories
7714
+ self.category_allowlist.update(self.qbit_managed_categories)
7715
+
7716
+ if self.qbit_managed_categories:
7717
+ self.logger.info(
7718
+ "qBit-managed categories registered: %s",
7719
+ ", ".join(sorted(self.qbit_managed_categories)),
7720
+ )
7721
+ self.logger.debug("Category validation passed - no conflicts detected")
7722
+
7666
7723
  def build_arr_instances(self):
7667
7724
  for key in CONFIG.sections():
7668
7725
  if search := re.match("(rad|son|anim|lid)arr.*", key, re.IGNORECASE):
@@ -7690,6 +7747,10 @@ class ArrManager:
7690
7747
  continue
7691
7748
  except (OSError, TypeError) as e:
7692
7749
  self.logger.exception(e)
7750
+
7751
+ # Validate category assignments after all Arr instances are initialized
7752
+ self._validate_category_assignments()
7753
+
7693
7754
  if (
7694
7755
  FREE_SPACE != "-1"
7695
7756
  and AUTO_PAUSE_RESUME
qBitrr/bundled_data.py CHANGED
@@ -1,5 +1,5 @@
1
- version = "5.8.6"
2
- git_hash = "236b1bf2"
1
+ version = "5.8.8"
2
+ git_hash = "7569d1c7"
3
3
  license_text = (
4
4
  "Licence can be found on:\n\nhttps://github.com/Feramance/qBitrr/blob/master/LICENSE"
5
5
  )
qBitrr/config_version.py CHANGED
@@ -14,7 +14,7 @@ if TYPE_CHECKING:
14
14
  from qBitrr.gen_config import MyConfig
15
15
 
16
16
  # Current expected config version - increment when schema changes require migration
17
- EXPECTED_CONFIG_VERSION = 3
17
+ EXPECTED_CONFIG_VERSION = 4
18
18
 
19
19
  logger = logging.getLogger(__name__)
20
20
 
qBitrr/database.py CHANGED
@@ -43,8 +43,10 @@ def get_database() -> SqliteDatabase:
43
43
  "cache_size": -64_000,
44
44
  "foreign_keys": 1,
45
45
  "ignore_check_constraints": 0,
46
- "synchronous": 0,
46
+ "synchronous": 2, # FULL mode - maximum safety, prevents corruption on power loss
47
47
  "read_uncommitted": 1,
48
+ "wal_autocheckpoint": 100, # Checkpoint every 100 pages (more frequent = safer)
49
+ "journal_size_limit": 67108864, # 64MB max WAL size
48
50
  },
49
51
  timeout=15,
50
52
  )
@@ -148,3 +150,29 @@ def _create_arrinstance_indexes(db: SqliteDatabase, models: list) -> None:
148
150
  db.commit()
149
151
  except Exception as e:
150
152
  logger.error("Error creating ArrInstance indexes: %s", e)
153
+
154
+
155
+ def get_database_path() -> Path:
156
+ """Get the path to the database file."""
157
+ return Path(APPDATA_FOLDER) / "qbitrr.db"
158
+
159
+
160
+ def checkpoint_database() -> bool:
161
+ """
162
+ Checkpoint the database WAL to prevent corruption on shutdown.
163
+
164
+ This is called automatically on graceful shutdown to ensure all
165
+ WAL entries are flushed to the main database file.
166
+
167
+ Returns:
168
+ True if checkpoint successful, False otherwise
169
+ """
170
+ from qBitrr.db_recovery import checkpoint_wal
171
+
172
+ db_path = get_database_path()
173
+ if not db_path.exists():
174
+ logger.debug("Database file does not exist, skipping checkpoint")
175
+ return True
176
+
177
+ logger.info("Checkpointing database WAL before shutdown...")
178
+ return checkpoint_wal(db_path, logger_override=logger)
qBitrr/gen_config.py CHANGED
@@ -61,6 +61,12 @@ def _add_web_settings_section(config: TOMLDocument):
61
61
  "Theme",
62
62
  "Dark",
63
63
  )
64
+ _gen_default_line(
65
+ web_settings,
66
+ "WebUI view density (Comfortable or Compact)",
67
+ "ViewDensity",
68
+ "Comfortable",
69
+ )
64
70
  config.add("WebUI", web_settings)
65
71
 
66
72
 
@@ -283,6 +289,58 @@ def _add_qbit_section(config: TOMLDocument):
283
289
  "Password",
284
290
  ENVIRO_CONFIG.qbit.password or "CHANGE_ME",
285
291
  )
292
+ _gen_default_line(
293
+ qbit,
294
+ [
295
+ "Categories managed directly by this qBit instance (not managed by Arr instances).",
296
+ "These categories will have seeding settings applied according to CategorySeeding configuration.",
297
+ "Example: ['downloads', 'private-tracker', 'long-term-seed']",
298
+ ],
299
+ "ManagedCategories",
300
+ [],
301
+ )
302
+
303
+ # Add CategorySeeding subsection
304
+ category_seeding = table()
305
+ _gen_default_line(
306
+ category_seeding,
307
+ "Download rate limit per torrent in KB/s (-1 = disabled)",
308
+ "DownloadRateLimitPerTorrent",
309
+ -1,
310
+ )
311
+ _gen_default_line(
312
+ category_seeding,
313
+ "Upload rate limit per torrent in KB/s (-1 = disabled)",
314
+ "UploadRateLimitPerTorrent",
315
+ -1,
316
+ )
317
+ _gen_default_line(
318
+ category_seeding,
319
+ "Maximum upload ratio (-1 = disabled, e.g. 2.0 for 200%)",
320
+ "MaxUploadRatio",
321
+ -1,
322
+ )
323
+ _gen_default_line(
324
+ category_seeding,
325
+ "Maximum seeding time in seconds (-1 = disabled, e.g. 604800 for 7 days)",
326
+ "MaxSeedingTime",
327
+ -1,
328
+ )
329
+ _gen_default_line(
330
+ category_seeding,
331
+ [
332
+ "When to remove torrents from qBittorrent:",
333
+ " -1 = Never remove",
334
+ " 1 = Remove when MaxUploadRatio is reached",
335
+ " 2 = Remove when MaxSeedingTime is reached",
336
+ " 3 = Remove when either condition is met (OR)",
337
+ " 4 = Remove when both conditions are met (AND)",
338
+ ],
339
+ "RemoveTorrent",
340
+ -1,
341
+ )
342
+ qbit.add("CategorySeeding", category_seeding)
343
+
286
344
  config.add("qBit", qbit)
287
345
 
288
346
 
@@ -1152,6 +1210,79 @@ def _migrate_quality_profile_mappings(config: MyConfig) -> bool:
1152
1210
  return changes_made
1153
1211
 
1154
1212
 
1213
+ def _migrate_qbit_category_settings(config: MyConfig) -> bool:
1214
+ """
1215
+ Add qBit category management settings to existing configs.
1216
+
1217
+ Migration runs if:
1218
+ - ConfigVersion < 4
1219
+
1220
+ Adds ManagedCategories and CategorySeeding configuration to all qBit sections.
1221
+
1222
+ After migration, ConfigVersion will be set to 4 by apply_config_migrations().
1223
+
1224
+ Returns:
1225
+ True if changes were made, False otherwise
1226
+ """
1227
+ import logging
1228
+
1229
+ from qBitrr.config_version import get_config_version
1230
+
1231
+ logger = logging.getLogger(__name__)
1232
+
1233
+ # Check if migration already applied
1234
+ current_version = get_config_version(config)
1235
+ if current_version >= 4:
1236
+ return False # Already migrated
1237
+
1238
+ changes_made = False
1239
+
1240
+ # Migrate default qBit section
1241
+ if "qBit" in config.config:
1242
+ qbit_section = config.config["qBit"]
1243
+ if "ManagedCategories" not in qbit_section:
1244
+ qbit_section["ManagedCategories"] = []
1245
+ changes_made = True
1246
+ logger.info("Added ManagedCategories = [] to [qBit]")
1247
+
1248
+ # Add CategorySeeding subsection
1249
+ if "CategorySeeding" not in qbit_section:
1250
+ seeding = table()
1251
+ seeding["DownloadRateLimitPerTorrent"] = -1
1252
+ seeding["UploadRateLimitPerTorrent"] = -1
1253
+ seeding["MaxUploadRatio"] = -1
1254
+ seeding["MaxSeedingTime"] = -1
1255
+ seeding["RemoveTorrent"] = -1
1256
+ qbit_section["CategorySeeding"] = seeding
1257
+ changes_made = True
1258
+ logger.info("Added CategorySeeding configuration to [qBit]")
1259
+
1260
+ # Migrate additional qBit instances (qBit-XXX)
1261
+ for section in config.config.keys():
1262
+ if str(section).startswith("qBit-"):
1263
+ qbit_section = config.config[str(section)]
1264
+ if "ManagedCategories" not in qbit_section:
1265
+ qbit_section["ManagedCategories"] = []
1266
+ changes_made = True
1267
+ logger.info(f"Added ManagedCategories = [] to [{section}]")
1268
+
1269
+ if "CategorySeeding" not in qbit_section:
1270
+ seeding = table()
1271
+ seeding["DownloadRateLimitPerTorrent"] = -1
1272
+ seeding["UploadRateLimitPerTorrent"] = -1
1273
+ seeding["MaxUploadRatio"] = -1
1274
+ seeding["MaxSeedingTime"] = -1
1275
+ seeding["RemoveTorrent"] = -1
1276
+ qbit_section["CategorySeeding"] = seeding
1277
+ changes_made = True
1278
+ logger.info(f"Added CategorySeeding configuration to [{section}]")
1279
+
1280
+ if changes_made:
1281
+ print("Migration v3→v4: Added qBit category management settings")
1282
+
1283
+ return changes_made
1284
+
1285
+
1155
1286
  def _normalize_theme_value(value: Any) -> str:
1156
1287
  """
1157
1288
  Normalize theme value to always be 'Light' or 'Dark' (case insensitive input).
@@ -1168,6 +1299,22 @@ def _normalize_theme_value(value: Any) -> str:
1168
1299
  return "Dark"
1169
1300
 
1170
1301
 
1302
+ def _normalize_view_density_value(value: Any) -> str:
1303
+ """
1304
+ Normalize view density value to always be 'Comfortable' or 'Compact' (case insensitive input).
1305
+ """
1306
+ if value is None:
1307
+ return "Comfortable"
1308
+ value_str = str(value).strip().lower()
1309
+ if value_str == "comfortable":
1310
+ return "Comfortable"
1311
+ elif value_str == "compact":
1312
+ return "Compact"
1313
+ else:
1314
+ # Default to Comfortable if invalid value
1315
+ return "Comfortable"
1316
+
1317
+
1171
1318
  def _validate_and_fill_config(config: MyConfig) -> bool:
1172
1319
  """
1173
1320
  Validate configuration and fill in missing values with defaults.
@@ -1234,6 +1381,7 @@ def _validate_and_fill_config(config: MyConfig) -> bool:
1234
1381
  ("GroupSonarr", True),
1235
1382
  ("GroupLidarr", True),
1236
1383
  ("Theme", "Dark"),
1384
+ ("ViewDensity", "Comfortable"),
1237
1385
  ]
1238
1386
 
1239
1387
  for key, default in webui_defaults:
@@ -1250,6 +1398,14 @@ def _validate_and_fill_config(config: MyConfig) -> bool:
1250
1398
  webui_section["Theme"] = normalized_theme
1251
1399
  changed = True
1252
1400
 
1401
+ # Normalize ViewDensity value to always be capitalized (Comfortable or Compact)
1402
+ if "ViewDensity" in webui_section:
1403
+ current_density = webui_section["ViewDensity"]
1404
+ normalized_density = _normalize_view_density_value(current_density)
1405
+ if current_density != normalized_density:
1406
+ webui_section["ViewDensity"] = normalized_density
1407
+ changed = True
1408
+
1253
1409
  # Validate qBit section
1254
1410
  qbit_defaults = [
1255
1411
  ("Disabled", False),
@@ -1343,6 +1499,10 @@ def apply_config_migrations(config: MyConfig) -> None:
1343
1499
  if _migrate_process_restart_settings(config):
1344
1500
  changes_made = True
1345
1501
 
1502
+ # NEW: Add qBit category management settings (v3 → v4)
1503
+ if _migrate_qbit_category_settings(config):
1504
+ changes_made = True
1505
+
1346
1506
  # Validate and fill config (this also ensures ConfigVersion field exists)
1347
1507
  if _validate_and_fill_config(config):
1348
1508
  changes_made = True
qBitrr/main.py CHANGED
@@ -5,6 +5,7 @@ import contextlib
5
5
  import glob
6
6
  import logging
7
7
  import os
8
+ import signal
8
9
  import sys
9
10
  import time
10
11
  from multiprocessing import Event, freeze_support
@@ -34,6 +35,7 @@ from qBitrr.env_config import ENVIRO_CONFIG
34
35
  from qBitrr.ffprobe import FFprobeDownloader
35
36
  from qBitrr.home_path import APPDATA_FOLDER
36
37
  from qBitrr.logger import run_logs
38
+ from qBitrr.qbit_category_manager import qBitCategoryManager
37
39
  from qBitrr.utils import ExpiringSet
38
40
  from qBitrr.versioning import fetch_latest_release
39
41
  from qBitrr.webui import WebUI
@@ -114,6 +116,9 @@ class qBitManager:
114
116
  self.qbit_versions: dict[str, VersionClass] = {}
115
117
  self.instance_metadata: dict[str, dict] = {}
116
118
  self.instance_health: dict[str, bool] = {}
119
+ # qBit category management
120
+ self.qbit_category_configs: dict[str, dict] = {}
121
+ self.qbit_category_managers: dict[str, qBitCategoryManager] = {}
117
122
  if not (QBIT_DISABLED or SEARCH_ONLY):
118
123
  self.client = qbittorrentapi.Client(
119
124
  host=self.qBit_Host,
@@ -158,6 +163,9 @@ class qBitManager:
158
163
  self._process_restart_counts: dict[tuple[str, str], list[float]] = (
159
164
  {}
160
165
  ) # (category, role) -> [timestamps]
166
+ # Database checkpoint thread
167
+ self._db_checkpoint_thread: Thread | None = None
168
+ self._db_checkpoint_event = ThreadEvent()
161
169
  self._failed_spawn_attempts: dict[tuple[str, str], int] = {} # Track failed spawn attempts
162
170
  self._pending_spawns: list[tuple] = [] # (arr_instance, meta) tuples to retry
163
171
  self.auto_restart_enabled = CONFIG.get("Settings.AutoRestartProcesses", fallback=True)
@@ -411,9 +419,20 @@ class qBitManager:
411
419
  arr_manager = ArrManager(self)
412
420
  self.arr_manager = arr_manager
413
421
  arr_manager.build_arr_instances()
422
+
423
+ # Initialize qBit category managers after Arr instances (for category validation)
424
+ self._initialize_qbit_category_managers()
425
+
414
426
  run_logs(self.logger)
415
427
  for arr in arr_manager.managed_objects.values():
416
428
  self._prepare_arr_processes(arr)
429
+
430
+ # Spawn qBit category workers after Arr workers
431
+ self._spawn_qbit_category_workers()
432
+
433
+ # Start periodic database checkpoint thread
434
+ self._start_db_checkpoint_thread()
435
+
417
436
  self.configure_auto_update()
418
437
  elapsed = monotonic() - started_at
419
438
  self.logger.info("Background startup completed in %.1fs", elapsed)
@@ -467,6 +486,41 @@ class qBitManager:
467
486
  # Default instance already initialized in __init__
468
487
  self.logger.info("Initialized qBit instance: default")
469
488
 
489
+ # Load qBit category config for default instance
490
+ managed_categories = CONFIG.get("qBit.ManagedCategories", fallback=[])
491
+ if managed_categories:
492
+ # Load default seeding settings
493
+ default_seeding = {}
494
+ seeding_keys = [
495
+ "DownloadRateLimitPerTorrent",
496
+ "UploadRateLimitPerTorrent",
497
+ "MaxUploadRatio",
498
+ "MaxSeedingTime",
499
+ "RemoveTorrent",
500
+ ]
501
+ for key in seeding_keys:
502
+ value = CONFIG.get(f"qBit.CategorySeeding.{key}", fallback=-1)
503
+ default_seeding[key] = value
504
+
505
+ # Load per-category overrides
506
+ category_overrides = {}
507
+ categories_list = CONFIG.get("qBit.CategorySeeding.Categories", fallback=[])
508
+ for cat_config in categories_list:
509
+ if isinstance(cat_config, dict) and "Name" in cat_config:
510
+ cat_name = cat_config["Name"]
511
+ category_overrides[cat_name] = cat_config
512
+
513
+ # Store config for later initialization
514
+ self.qbit_category_configs["default"] = {
515
+ "managed_categories": managed_categories,
516
+ "default_seeding": default_seeding,
517
+ "category_overrides": category_overrides,
518
+ }
519
+ self.logger.debug(
520
+ "Loaded qBit category config for 'default': %d managed categories",
521
+ len(managed_categories),
522
+ )
523
+
470
524
  # Scan for additional instances (qBit-XXX sections)
471
525
  for section in CONFIG.sections():
472
526
  if section.startswith("qBit-") and section != "qBit":
@@ -550,6 +604,42 @@ class qBitManager:
550
604
  }
551
605
  self.instance_health[instance_name] = True
552
606
 
607
+ # Load qBit category management config for this instance
608
+ managed_categories = CONFIG.get(f"{section_name}.ManagedCategories", fallback=[])
609
+ if managed_categories:
610
+ # Load default seeding settings
611
+ default_seeding = {}
612
+ seeding_keys = [
613
+ "DownloadRateLimitPerTorrent",
614
+ "UploadRateLimitPerTorrent",
615
+ "MaxUploadRatio",
616
+ "MaxSeedingTime",
617
+ "RemoveTorrent",
618
+ ]
619
+ for key in seeding_keys:
620
+ value = CONFIG.get(f"{section_name}.CategorySeeding.{key}", fallback=-1)
621
+ default_seeding[key] = value
622
+
623
+ # Load per-category overrides
624
+ category_overrides = {}
625
+ categories_list = CONFIG.get(f"{section_name}.CategorySeeding.Categories", fallback=[])
626
+ for cat_config in categories_list:
627
+ if isinstance(cat_config, dict) and "Name" in cat_config:
628
+ cat_name = cat_config["Name"]
629
+ category_overrides[cat_name] = cat_config
630
+
631
+ # Store config for later initialization
632
+ self.qbit_category_configs[instance_name] = {
633
+ "managed_categories": managed_categories,
634
+ "default_seeding": default_seeding,
635
+ "category_overrides": category_overrides,
636
+ }
637
+ self.logger.debug(
638
+ "Loaded qBit category config for '%s': %d managed categories",
639
+ instance_name,
640
+ len(managed_categories),
641
+ )
642
+
553
643
  def is_instance_alive(self, instance_name: str = "default") -> bool:
554
644
  """
555
645
  Check if a specific qBittorrent instance is alive and responding.
@@ -633,6 +723,117 @@ class qBitManager:
633
723
  return None
634
724
  return self.clients[instance_name]
635
725
 
726
+ def _initialize_qbit_category_managers(self) -> None:
727
+ """
728
+ Initialize qBit category managers for instances with managed categories.
729
+
730
+ Creates qBitCategoryManager instances for each qBit instance that has
731
+ ManagedCategories configured. Managers handle seeding settings and
732
+ removal logic for qBit-managed torrents.
733
+ """
734
+ if not self.qbit_category_configs:
735
+ self.logger.debug("No qBit category managers to initialize")
736
+ return
737
+
738
+ for instance_name, config in self.qbit_category_configs.items():
739
+ try:
740
+ manager = qBitCategoryManager(instance_name, self, config)
741
+ self.qbit_category_managers[instance_name] = manager
742
+ self.logger.info(
743
+ "Initialized qBit category manager for instance '%s'", instance_name
744
+ )
745
+ except Exception as e:
746
+ self.logger.error(
747
+ "Failed to initialize qBit category manager for '%s': %s",
748
+ instance_name,
749
+ e,
750
+ exc_info=True,
751
+ )
752
+
753
+ self.logger.info(
754
+ "Total qBit category managers initialized: %d", len(self.qbit_category_managers)
755
+ )
756
+
757
+ def _spawn_qbit_category_workers(self) -> None:
758
+ """
759
+ Spawn worker processes for qBit category managers.
760
+
761
+ Creates a worker process for each qBit category manager to handle
762
+ continuous processing of managed torrents (applying seeding settings,
763
+ checking removal conditions).
764
+ """
765
+ if not self.qbit_category_managers:
766
+ self.logger.debug("No qBit category workers to spawn")
767
+ return
768
+
769
+ for instance_name, manager in self.qbit_category_managers.items():
770
+ try:
771
+ process = pathos.helpers.mp.Process(
772
+ target=manager.run_processing_loop,
773
+ name=f"qBitCategory-{instance_name}",
774
+ )
775
+ process.start()
776
+ self.child_processes.append(process)
777
+ self._process_registry[process] = {
778
+ "category": f"qbit-{instance_name}",
779
+ "role": "category_manager",
780
+ "instance": instance_name,
781
+ }
782
+ self.logger.info(
783
+ "Spawned qBit category worker for instance '%s' (PID: %d)",
784
+ instance_name,
785
+ process.pid,
786
+ )
787
+ except Exception as e:
788
+ self.logger.error(
789
+ "Failed to spawn qBit category worker for '%s': %s",
790
+ instance_name,
791
+ e,
792
+ exc_info=True,
793
+ )
794
+
795
+ self.logger.info(
796
+ "Total qBit category workers spawned: %d", len(self.qbit_category_managers)
797
+ )
798
+
799
+ def _periodic_db_checkpoint(self) -> None:
800
+ """
801
+ Background thread that periodically checkpoints the database WAL.
802
+
803
+ This runs every 5 minutes during normal operation to ensure WAL entries
804
+ are regularly flushed to the main database file, minimizing data loss
805
+ risk in case of sudden crashes or power loss.
806
+ """
807
+ from qBitrr.database import checkpoint_database
808
+
809
+ self.logger.info("Starting periodic database checkpoint thread (interval: 5 minutes)")
810
+
811
+ while not self.shutdown_event.is_set():
812
+ # Wait 5 minutes or until shutdown
813
+ if self._db_checkpoint_event.wait(timeout=300): # 300 seconds = 5 minutes
814
+ break # Shutdown requested
815
+
816
+ if self.shutdown_event.is_set():
817
+ break
818
+
819
+ try:
820
+ checkpoint_database()
821
+ except Exception as e:
822
+ self.logger.error("Periodic database checkpoint failed: %s", e)
823
+
824
+ self.logger.info("Periodic database checkpoint thread stopped")
825
+
826
+ def _start_db_checkpoint_thread(self) -> None:
827
+ """Start the periodic database checkpoint background thread."""
828
+ if self._db_checkpoint_thread is None or not self._db_checkpoint_thread.is_alive():
829
+ self._db_checkpoint_thread = Thread(
830
+ target=self._periodic_db_checkpoint,
831
+ name="DBCheckpoint",
832
+ daemon=True,
833
+ )
834
+ self._db_checkpoint_thread.start()
835
+ self.logger.info("Started periodic database checkpoint thread")
836
+
636
837
  # @response_text(str)
637
838
  # @login_required
638
839
  def app_version(self, instance_name: str = "default", **kwargs):
@@ -1187,11 +1388,28 @@ def run():
1187
1388
  # Early consolidated config validation feedback
1188
1389
  _report_config_issues()
1189
1390
  logger.debug("Environment variables: %r", ENVIRO_CONFIG)
1391
+
1392
+ # Flag to track if shutdown has been initiated
1393
+ shutdown_initiated = False
1394
+
1190
1395
  try:
1191
1396
  manager.get_child_processes()
1192
1397
 
1193
1398
  # Register cleanup for child processes when the main process exits
1194
1399
  def _cleanup():
1400
+ nonlocal shutdown_initiated
1401
+ if shutdown_initiated:
1402
+ return # Already cleaned up
1403
+ shutdown_initiated = True
1404
+
1405
+ # Checkpoint database WAL before shutdown
1406
+ try:
1407
+ from qBitrr.database import checkpoint_database
1408
+
1409
+ checkpoint_database()
1410
+ except Exception as e:
1411
+ logger.error("Failed to checkpoint database on shutdown: %s", e)
1412
+
1195
1413
  # Signal loops to shutdown gracefully
1196
1414
  try:
1197
1415
  manager.shutdown_event.set()
@@ -1207,6 +1425,15 @@ def run():
1207
1425
  with contextlib.suppress(Exception):
1208
1426
  p.terminate()
1209
1427
 
1428
+ # Register signal handlers for graceful shutdown
1429
+ def _signal_handler(signum, frame):
1430
+ logger.info("Received signal %s - initiating graceful shutdown", signum)
1431
+ _cleanup()
1432
+ sys.exit(0)
1433
+
1434
+ signal.signal(signal.SIGTERM, _signal_handler)
1435
+ signal.signal(signal.SIGINT, _signal_handler)
1436
+
1210
1437
  atexit.register(_cleanup)
1211
1438
  if manager.child_processes:
1212
1439
  manager.run()
@@ -1216,9 +1443,11 @@ def run():
1216
1443
  )
1217
1444
  except KeyboardInterrupt:
1218
1445
  logger.info("Detected Ctrl+C - Terminating process")
1446
+ _cleanup()
1219
1447
  sys.exit(0)
1220
1448
  except Exception:
1221
1449
  logger.info("Attempting to terminate child processes, please wait a moment.")
1450
+ _cleanup()
1222
1451
  for child in manager.child_processes:
1223
1452
  child.kill()
1224
1453