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/arss.py +457 -127
- qBitrr/bundled_data.py +2 -2
- qBitrr/config_version.py +144 -0
- qBitrr/db_lock.py +189 -0
- qBitrr/db_recovery.py +202 -0
- qBitrr/gen_config.py +285 -3
- qBitrr/main.py +171 -5
- qBitrr/search_activity_store.py +6 -2
- qBitrr/static/assets/ArrView.js +1 -1
- qBitrr/static/assets/ArrView.js.map +1 -1
- qBitrr/static/assets/ConfigView.js +4 -3
- qBitrr/static/assets/ConfigView.js.map +1 -1
- qBitrr/static/assets/LogsView.js +17 -39
- qBitrr/static/assets/LogsView.js.map +1 -1
- qBitrr/static/assets/ProcessesView.js +1 -1
- qBitrr/static/assets/ProcessesView.js.map +1 -1
- qBitrr/static/assets/app.css +1 -1
- qBitrr/static/assets/app.js +1 -9
- qBitrr/static/assets/app.js.map +1 -1
- qBitrr/static/assets/react-select.esm.js +1 -8
- qBitrr/static/assets/react-select.esm.js.map +1 -1
- qBitrr/static/assets/table.js +2 -20
- qBitrr/static/assets/table.js.map +1 -1
- qBitrr/static/assets/vendor.js +1 -25
- qBitrr/static/assets/vendor.js.map +1 -1
- qBitrr/static/sw.js +5 -0
- qBitrr/tables.py +27 -0
- qBitrr/webui.py +523 -23
- {qbitrr2-5.4.4.dist-info → qbitrr2-5.5.0.dist-info}/METADATA +88 -13
- qbitrr2-5.5.0.dist-info/RECORD +63 -0
- qbitrr2-5.4.4.dist-info/RECORD +0 -61
- {qbitrr2-5.4.4.dist-info → qbitrr2-5.5.0.dist-info}/WHEEL +0 -0
- {qbitrr2-5.4.4.dist-info → qbitrr2-5.5.0.dist-info}/entry_points.txt +0 -0
- {qbitrr2-5.4.4.dist-info → qbitrr2-5.5.0.dist-info}/licenses/LICENSE +0 -0
- {qbitrr2-5.4.4.dist-info → qbitrr2-5.5.0.dist-info}/top_level.txt +0 -0
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
499
|
-
|
|
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:
|
qBitrr/search_activity_store.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|