qBitrr2 5.4.4__py3-none-any.whl → 5.5.0__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/gen_config.py CHANGED
@@ -4,7 +4,7 @@ import pathlib
4
4
  from functools import reduce
5
5
  from typing import Any, TypeVar
6
6
 
7
- from tomlkit import comment, document, nl, parse, table
7
+ from tomlkit import comment, document, inline_table, nl, parse, table
8
8
  from tomlkit.items import Table
9
9
  from tomlkit.toml_document import TOMLDocument
10
10
 
@@ -83,6 +83,15 @@ def generate_doc() -> TOMLDocument:
83
83
 
84
84
  def _add_settings_section(config: TOMLDocument):
85
85
  settings = table()
86
+ _gen_default_line(
87
+ settings,
88
+ [
89
+ "Internal config schema version - DO NOT MODIFY",
90
+ "This is managed automatically by qBitrr for config migrations",
91
+ ],
92
+ "ConfigVersion",
93
+ 3,
94
+ )
86
95
  _gen_default_line(
87
96
  settings,
88
97
  "Level of logging; One of CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, TRACE",
@@ -202,6 +211,39 @@ def _add_settings_section(config: TOMLDocument):
202
211
  "AutoUpdateCron",
203
212
  ENVIRO_CONFIG.settings.auto_update_cron or "0 3 * * 0",
204
213
  )
214
+ _gen_default_line(
215
+ settings,
216
+ [
217
+ "Automatically restart worker processes that fail or crash",
218
+ "Set to false to disable auto-restart (processes will only log failures)",
219
+ ],
220
+ "AutoRestartProcesses",
221
+ True,
222
+ )
223
+ _gen_default_line(
224
+ settings,
225
+ [
226
+ "Maximum number of restart attempts per process within the restart window",
227
+ "Prevents infinite restart loops for processes that crash immediately",
228
+ ],
229
+ "MaxProcessRestarts",
230
+ 5,
231
+ )
232
+ _gen_default_line(
233
+ settings,
234
+ [
235
+ "Time window (seconds) for tracking restart attempts",
236
+ "If a process restarts MaxProcessRestarts times within this window, auto-restart is disabled for that process",
237
+ ],
238
+ "ProcessRestartWindow",
239
+ 300,
240
+ )
241
+ _gen_default_line(
242
+ settings,
243
+ "Delay (seconds) before attempting to restart a failed process",
244
+ "ProcessRestartDelay",
245
+ 5,
246
+ )
205
247
  config.add("Settings", settings)
206
248
 
207
249
 
@@ -705,6 +747,34 @@ def _gen_default_search_table(category: str, cat_default: Table):
705
747
  )
706
748
  _gen_default_line(search_table, "Use Temp profile for missing", "UseTempForMissing", False)
707
749
  _gen_default_line(search_table, "Don't change back to main profile", "KeepTempProfile", False)
750
+ _gen_default_line(
751
+ search_table,
752
+ [
753
+ "Quality profile mappings for temp profile switching (Main Profile Name -> Temp Profile Name)",
754
+ "Profile names must match exactly as they appear in your Arr instance",
755
+ 'Example: QualityProfileMappings = {"HD-1080p" = "SD", "HD-720p" = "SD"}',
756
+ ],
757
+ "QualityProfileMappings",
758
+ inline_table(),
759
+ )
760
+ _gen_default_line(
761
+ search_table,
762
+ "Reset all items using temp profiles to their original main profile on qBitrr startup",
763
+ "ForceResetTempProfiles",
764
+ False,
765
+ )
766
+ _gen_default_line(
767
+ search_table,
768
+ "Timeout in minutes after which items with temp profiles are automatically reset to main profile (0 = disabled)",
769
+ "TempProfileResetTimeoutMinutes",
770
+ 0,
771
+ )
772
+ _gen_default_line(
773
+ search_table,
774
+ "Number of retry attempts for profile switch API calls (default: 3)",
775
+ "ProfileSwitchRetryAttempts",
776
+ 3,
777
+ )
708
778
  _gen_default_line(
709
779
  search_table,
710
780
  "Main quality profile (To pair quality profiles, ensure they are in the same order as in the temp profiles)",
@@ -899,6 +969,146 @@ def _migrate_webui_config(config: MyConfig) -> bool:
899
969
  return migrated
900
970
 
901
971
 
972
+ def _migrate_process_restart_settings(config: MyConfig) -> bool:
973
+ """
974
+ Add process auto-restart settings to existing configs.
975
+
976
+ Migration runs if:
977
+ - ConfigVersion < 3 (versions 1 or 2)
978
+
979
+ After migration, ConfigVersion will be set to 3 by apply_config_migrations().
980
+
981
+ Returns:
982
+ True if changes were made, False otherwise
983
+ """
984
+ import logging
985
+
986
+ from qBitrr.config_version import get_config_version
987
+
988
+ logger = logging.getLogger(__name__)
989
+
990
+ # Check if migration already applied
991
+ current_version = get_config_version(config)
992
+ if current_version >= 3:
993
+ return False # Already migrated
994
+
995
+ # Ensure Settings section exists
996
+ if "Settings" not in config.config:
997
+ config.config["Settings"] = table()
998
+
999
+ settings = config.config["Settings"]
1000
+ changes_made = False
1001
+
1002
+ # Add AutoRestartProcesses if missing
1003
+ if "AutoRestartProcesses" not in settings:
1004
+ settings["AutoRestartProcesses"] = True
1005
+ changes_made = True
1006
+ logger.info("Added AutoRestartProcesses = true (default: enabled)")
1007
+
1008
+ # Add MaxProcessRestarts if missing
1009
+ if "MaxProcessRestarts" not in settings:
1010
+ settings["MaxProcessRestarts"] = 5
1011
+ changes_made = True
1012
+ logger.info("Added MaxProcessRestarts = 5 (default)")
1013
+
1014
+ # Add ProcessRestartWindow if missing
1015
+ if "ProcessRestartWindow" not in settings:
1016
+ settings["ProcessRestartWindow"] = 300
1017
+ changes_made = True
1018
+ logger.info("Added ProcessRestartWindow = 300 seconds (5 minutes)")
1019
+
1020
+ # Add ProcessRestartDelay if missing
1021
+ if "ProcessRestartDelay" not in settings:
1022
+ settings["ProcessRestartDelay"] = 5
1023
+ changes_made = True
1024
+ logger.info("Added ProcessRestartDelay = 5 seconds")
1025
+
1026
+ if changes_made:
1027
+ print("Migration v2→v3: Added process auto-restart configuration settings")
1028
+
1029
+ return changes_made
1030
+
1031
+
1032
+ def _migrate_quality_profile_mappings(config: MyConfig) -> bool:
1033
+ """
1034
+ Migrate from list-based profile config to dict-based mappings.
1035
+
1036
+ Migration runs if:
1037
+ - ConfigVersion is missing (old config), OR
1038
+ - ConfigVersion == 1 (baseline version before this feature)
1039
+
1040
+ After migration, ConfigVersion will be set to 2 by apply_config_migrations().
1041
+
1042
+ Returns:
1043
+ True if changes were made, False otherwise
1044
+ """
1045
+ import logging
1046
+
1047
+ from qBitrr.config_version import get_config_version
1048
+
1049
+ logger = logging.getLogger(__name__)
1050
+
1051
+ # Check if migration already applied
1052
+ current_version = get_config_version(config)
1053
+ if current_version >= 2:
1054
+ return False # Already migrated
1055
+
1056
+ # At this point, ConfigVersion is either missing (returns 1 as default) or explicitly 1
1057
+ # Both cases need migration if old format exists
1058
+
1059
+ changes_made = False
1060
+ arr_types = ["Radarr", "Sonarr", "Lidarr", "Animarr"]
1061
+
1062
+ for arr_type in arr_types:
1063
+ # Find all Arr instances (e.g., "Radarr-Movies", "Sonarr-TV")
1064
+ for key in list(config.config.keys()):
1065
+ if not str(key).startswith(arr_type):
1066
+ continue
1067
+
1068
+ entry_search_key = f"{key}.EntrySearch"
1069
+ entry_search_section = config.get(entry_search_key, fallback=None)
1070
+ if not entry_search_section:
1071
+ continue
1072
+
1073
+ # Check for old format
1074
+ main_profiles = config.get(f"{entry_search_key}.MainQualityProfile", fallback=None)
1075
+ temp_profiles = config.get(f"{entry_search_key}.TempQualityProfile", fallback=None)
1076
+
1077
+ # Skip if no old format found
1078
+ if not main_profiles or not temp_profiles:
1079
+ continue
1080
+
1081
+ # Validate list lengths match
1082
+ if len(main_profiles) != len(temp_profiles):
1083
+ logger.error(
1084
+ f"Cannot migrate {key}: MainQualityProfile ({len(main_profiles)}) "
1085
+ f"and TempQualityProfile ({len(temp_profiles)}) have different lengths"
1086
+ )
1087
+ continue
1088
+
1089
+ # Create mappings dict, filtering out empty/None values
1090
+ mappings = {
1091
+ str(main).strip(): str(temp).strip()
1092
+ for main, temp in zip(main_profiles, temp_profiles)
1093
+ if main and temp and str(main).strip() and str(temp).strip()
1094
+ }
1095
+
1096
+ if mappings:
1097
+ # Set new format - use tomlkit's inline_table to ensure it's rendered as inline dict
1098
+ inline_mappings = inline_table()
1099
+ inline_mappings.update(mappings)
1100
+ config.config[str(key)]["EntrySearch"]["QualityProfileMappings"] = inline_mappings
1101
+ changes_made = True
1102
+ logger.info(f"Migrated {key} to QualityProfileMappings: {mappings}")
1103
+
1104
+ # Remove old format
1105
+ del config.config[str(key)]["EntrySearch"]["MainQualityProfile"]
1106
+ del config.config[str(key)]["EntrySearch"]["TempQualityProfile"]
1107
+ logger.debug(f"Removed legacy profile lists from {key}")
1108
+
1109
+ return changes_made
1110
+
1111
+
902
1112
  def _normalize_theme_value(value: Any) -> str:
903
1113
  """
904
1114
  Normalize theme value to always be 'Light' or 'Dark' (case insensitive input).
@@ -948,6 +1158,7 @@ def _validate_and_fill_config(config: MyConfig) -> bool:
948
1158
 
949
1159
  # Validate Settings section
950
1160
  settings_defaults = [
1161
+ ("ConfigVersion", 1), # Internal version, DO NOT expose to WebUI
951
1162
  ("ConsoleLevel", "INFO"),
952
1163
  ("Logging", True),
953
1164
  ("CompletedDownloadFolder", "CHANGE_ME"),
@@ -1009,6 +1220,35 @@ def _validate_and_fill_config(config: MyConfig) -> bool:
1009
1220
  if ensure_value("qBit", key, default):
1010
1221
  changed = True
1011
1222
 
1223
+ # Validate EntrySearch sections for all Arr instances
1224
+ arr_types = ["Radarr", "Sonarr", "Lidarr", "Animarr"]
1225
+ entry_search_defaults = {
1226
+ "QualityProfileMappings": inline_table(),
1227
+ "ForceResetTempProfiles": False,
1228
+ "TempProfileResetTimeoutMinutes": 0,
1229
+ "ProfileSwitchRetryAttempts": 3,
1230
+ }
1231
+
1232
+ for arr_type in arr_types:
1233
+ for key in list(config.config.keys()):
1234
+ if not str(key).startswith(arr_type):
1235
+ continue
1236
+
1237
+ # Check if this Arr instance has an EntrySearch section
1238
+ if "EntrySearch" in config.config[str(key)]:
1239
+ entry_search = config.config[str(key)]["EntrySearch"]
1240
+
1241
+ # Add missing fields directly to the existing section
1242
+ for field, default in entry_search_defaults.items():
1243
+ if field not in entry_search:
1244
+ if field == "QualityProfileMappings":
1245
+ # Create as inline table (inline dict) not a section
1246
+ entry_search[field] = inline_table()
1247
+ else:
1248
+ # Add as a simple value
1249
+ entry_search[field] = default
1250
+ changed = True
1251
+
1012
1252
  return changed
1013
1253
 
1014
1254
 
@@ -1017,16 +1257,58 @@ def apply_config_migrations(config: MyConfig) -> None:
1017
1257
  Apply all configuration migrations and validations.
1018
1258
  Saves the config if any changes were made.
1019
1259
  """
1260
+ from qBitrr.config_version import (
1261
+ EXPECTED_CONFIG_VERSION,
1262
+ backup_config,
1263
+ get_config_version,
1264
+ set_config_version,
1265
+ validate_config_version,
1266
+ )
1267
+
1020
1268
  changes_made = False
1021
1269
 
1022
- # Apply migrations
1270
+ # Validate config version
1271
+ is_valid, validation_result = validate_config_version(config)
1272
+
1273
+ if not is_valid:
1274
+ # Config version is newer than expected - log error but continue
1275
+ print(f"WARNING: {validation_result}")
1276
+ print("Continuing with potentially incompatible config...")
1277
+
1278
+ # Check if migration is needed
1279
+ current_version = get_config_version(config)
1280
+ needs_migration = current_version < EXPECTED_CONFIG_VERSION
1281
+
1282
+ if needs_migration:
1283
+ print(f"Config schema upgrade needed (v{current_version} -> v{EXPECTED_CONFIG_VERSION})")
1284
+ # Create backup before migration
1285
+ backup_path = backup_config(config.path)
1286
+ if backup_path:
1287
+ print(f"Config backup created: {backup_path}")
1288
+ else:
1289
+ print("WARNING: Could not create config backup, proceeding with migration anyway")
1290
+
1291
+ # Apply migrations in order
1023
1292
  if _migrate_webui_config(config):
1024
1293
  changes_made = True
1025
1294
 
1026
- # Validate and fill config
1295
+ # NEW: Migrate quality profile mappings from list to dict format (v1 → v2)
1296
+ if _migrate_quality_profile_mappings(config):
1297
+ changes_made = True
1298
+
1299
+ # NEW: Add process auto-restart settings (v2 → v3)
1300
+ if _migrate_process_restart_settings(config):
1301
+ changes_made = True
1302
+
1303
+ # Validate and fill config (this also ensures ConfigVersion field exists)
1027
1304
  if _validate_and_fill_config(config):
1028
1305
  changes_made = True
1029
1306
 
1307
+ # Update config version if migration was needed
1308
+ if needs_migration and current_version < EXPECTED_CONFIG_VERSION:
1309
+ set_config_version(config, EXPECTED_CONFIG_VERSION)
1310
+ changes_made = True
1311
+
1030
1312
  # Save if changes were made
1031
1313
  if changes_made:
1032
1314
  config.save()
qBitrr/main.py CHANGED
@@ -133,6 +133,14 @@ class qBitManager:
133
133
  self._restart_requested = False
134
134
  self._restart_thread: Thread | None = None
135
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)
136
144
  try:
137
145
  if not (QBIT_DISABLED or SEARCH_ONLY):
138
146
  self.ffprobe_downloader.update()
@@ -490,15 +498,40 @@ class qBitManager:
490
498
  exit_code = proc.exitcode
491
499
  if exit_code is None:
492
500
  continue
493
- meta = self._process_registry.pop(proc, {})
494
- with contextlib.suppress(ValueError):
495
- self.child_processes.remove(proc)
501
+
502
+ meta = self._process_registry.get(proc, {})
503
+ category = meta.get("category", "unknown")
504
+ role = meta.get("role", "unknown")
505
+
496
506
  self.logger.warning(
497
507
  "Worker process exited (role=%s, category=%s, code=%s)",
498
- meta.get("role", "unknown"),
499
- meta.get("category", "unknown"),
508
+ role,
509
+ category,
500
510
  exit_code,
501
511
  )
512
+
513
+ # Attempt auto-restart if enabled and process crashed (non-zero exit)
514
+ if self.auto_restart_enabled and exit_code != 0:
515
+ if self._should_restart_process(category, role):
516
+ self.logger.info(
517
+ "Attempting to restart %s worker for category '%s'",
518
+ role,
519
+ category,
520
+ )
521
+ if self._restart_process(proc, meta):
522
+ continue # Keep process in list, skip removal
523
+ else:
524
+ self.logger.error(
525
+ "Failed to restart %s worker for category '%s'",
526
+ role,
527
+ category,
528
+ )
529
+
530
+ # Remove process if not restarted
531
+ self._process_registry.pop(proc, None)
532
+ with contextlib.suppress(ValueError):
533
+ self.child_processes.remove(proc)
534
+
502
535
  if not self.child_processes:
503
536
  if not any_alive:
504
537
  break
@@ -518,6 +551,139 @@ class qBitManager:
518
551
  if proc.is_alive():
519
552
  proc.join(timeout=1)
520
553
 
554
+ def _should_restart_process(self, category: str, role: str) -> bool:
555
+ """
556
+ Determine if a process should be restarted based on restart count and window.
557
+
558
+ Tracks restart attempts per (category, role) combination and prevents
559
+ crash loops by enforcing maximum restart limits within a time window.
560
+
561
+ Args:
562
+ category: The Arr category (e.g., "radarr", "sonarr")
563
+ role: The process role ("search" or "torrent")
564
+
565
+ Returns:
566
+ bool: True if process should be restarted, False otherwise
567
+ """
568
+ key = (category, role)
569
+ now = time.time()
570
+
571
+ # Get restart history for this process type
572
+ if key not in self._process_restart_counts:
573
+ self._process_restart_counts[key] = []
574
+
575
+ restart_times = self._process_restart_counts[key]
576
+
577
+ # Remove timestamps outside the restart window
578
+ restart_times[:] = [t for t in restart_times if now - t < self.process_restart_window]
579
+
580
+ # Check if we've exceeded max restarts
581
+ if len(restart_times) >= self.max_process_restarts:
582
+ self.logger.error(
583
+ "Process %s/%s has failed %d times in %d seconds. Auto-restart disabled for this process.",
584
+ category,
585
+ role,
586
+ len(restart_times),
587
+ self.process_restart_window,
588
+ )
589
+ return False
590
+
591
+ return True
592
+
593
+ def _restart_process(
594
+ self, failed_proc: pathos.helpers.mp.Process, meta: dict[str, str]
595
+ ) -> bool:
596
+ """
597
+ Restart a failed worker process.
598
+
599
+ Creates a new process instance with the same target function, starts it,
600
+ and updates all tracking structures to reference the new process.
601
+
602
+ Args:
603
+ failed_proc: The failed process object
604
+ meta: Process metadata dict with keys: category, name, role
605
+
606
+ Returns:
607
+ bool: True if restart successful, False otherwise
608
+ """
609
+ category = meta.get("category", "")
610
+ role = meta.get("role", "worker")
611
+ meta.get("name", "")
612
+
613
+ try:
614
+ # Wait before restarting
615
+ if self.process_restart_delay > 0:
616
+ self.logger.debug(
617
+ "Waiting %ds before restarting %s worker for '%s'",
618
+ self.process_restart_delay,
619
+ role,
620
+ category,
621
+ )
622
+ time.sleep(self.process_restart_delay)
623
+
624
+ # Find the corresponding Arr instance
625
+ if not self.arr_manager:
626
+ self.logger.error("ArrManager not available for process restart")
627
+ return False
628
+
629
+ arr = self.arr_manager.managed_objects.get(category)
630
+ if not arr:
631
+ self.logger.error("Cannot find Arr instance for category '%s'", category)
632
+ return False
633
+
634
+ # Recreate the process based on role
635
+ new_proc = None
636
+ if role == "search" and hasattr(arr, "run_search_loop"):
637
+ new_proc = pathos.helpers.mp.Process(target=arr.run_search_loop, daemon=False)
638
+ if hasattr(arr, "process_search_loop"):
639
+ arr.process_search_loop = new_proc
640
+ elif role == "torrent" and hasattr(arr, "run_torrent_loop"):
641
+ new_proc = pathos.helpers.mp.Process(target=arr.run_torrent_loop, daemon=False)
642
+ if hasattr(arr, "process_torrent_loop"):
643
+ arr.process_torrent_loop = new_proc
644
+ else:
645
+ self.logger.error(
646
+ "Unknown role '%s' for category '%s' or target method not found",
647
+ role,
648
+ category,
649
+ )
650
+ return False
651
+
652
+ if not new_proc:
653
+ return False
654
+
655
+ # Start the new process
656
+ new_proc.start()
657
+
658
+ # Update restart tracking
659
+ key = (category, role)
660
+ self._process_restart_counts.setdefault(key, []).append(time.time())
661
+
662
+ # Replace in child_processes list
663
+ with contextlib.suppress(ValueError):
664
+ self.child_processes.remove(failed_proc)
665
+ self.child_processes.append(new_proc)
666
+
667
+ # Update registry
668
+ self._process_registry.pop(failed_proc, None)
669
+ self._process_registry[new_proc] = meta
670
+
671
+ self.logger.notice(
672
+ "Successfully restarted %s worker for category '%s' (restarts in window: %d/%d)",
673
+ role,
674
+ category,
675
+ len(self._process_restart_counts[key]),
676
+ self.max_process_restarts,
677
+ )
678
+
679
+ return True
680
+
681
+ except Exception as e:
682
+ self.logger.exception(
683
+ "Failed to restart %s worker for category '%s': %s", role, category, e
684
+ )
685
+ return False
686
+
521
687
 
522
688
  def _report_config_issues():
523
689
  try:
@@ -5,6 +5,7 @@ from typing import Any
5
5
 
6
6
  from peewee import Model, SqliteDatabase, TextField
7
7
 
8
+ from qBitrr.db_lock import with_database_retry
8
9
  from qBitrr.home_path import APPDATA_FOLDER
9
10
 
10
11
  _DB_LOCK = RLock()
@@ -24,6 +25,7 @@ def _get_database() -> SqliteDatabase:
24
25
  "foreign_keys": 1,
25
26
  "ignore_check_constraints": 0,
26
27
  "synchronous": 0,
28
+ "read_uncommitted": 1,
27
29
  },
28
30
  timeout=15,
29
31
  check_same_thread=False,
@@ -45,7 +47,8 @@ class SearchActivity(BaseModel):
45
47
  def _ensure_tables() -> None:
46
48
  db = _get_database()
47
49
  with _DB_LOCK:
48
- db.connect(reuse_if_open=True)
50
+ # Connect with retry logic for transient I/O errors
51
+ with_database_retry(lambda: db.connect(reuse_if_open=True))
49
52
  db.create_tables([SearchActivity], safe=True)
50
53
 
51
54
 
@@ -67,7 +70,8 @@ def fetch_search_activities() -> dict[str, dict[str, str | None]]:
67
70
  _ensure_tables()
68
71
  activities: dict[str, dict[str, str | None]] = {}
69
72
  db = _get_database()
70
- db.connect(reuse_if_open=True)
73
+ # Connect with retry logic for transient I/O errors
74
+ with_database_retry(lambda: db.connect(reuse_if_open=True))
71
75
  try:
72
76
  query = SearchActivity.select()
73
77
  except Exception: