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 +61 -0
- qBitrr/bundled_data.py +2 -2
- qBitrr/config_version.py +1 -1
- qBitrr/database.py +29 -1
- qBitrr/gen_config.py +160 -0
- qBitrr/main.py +229 -0
- qBitrr/qbit_category_manager.py +293 -0
- qBitrr/static/assets/ArrView.js +1 -1
- qBitrr/static/assets/ArrView.js.map +1 -1
- qBitrr/static/assets/ConfigView.js +5 -5
- qBitrr/static/assets/ConfigView.js.map +1 -1
- qBitrr/static/assets/LogsView.js +9 -9
- 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/QbitCategoriesView.js +2 -0
- qBitrr/static/assets/QbitCategoriesView.js.map +1 -0
- qBitrr/static/assets/StableTable.js +2 -0
- qBitrr/static/assets/StableTable.js.map +1 -0
- qBitrr/static/assets/app.css +1 -1
- qBitrr/static/assets/app.js +23 -10
- qBitrr/static/assets/app.js.map +1 -1
- qBitrr/static/assets/table.js +1 -1
- qBitrr/static/assets/vendor.js +1 -1
- qBitrr/static/assets/vendor.js.map +1 -1
- qBitrr/webui.py +208 -2
- {qbitrr2-5.8.6.dist-info → qbitrr2-5.8.8.dist-info}/METADATA +3 -3
- {qbitrr2-5.8.6.dist-info → qbitrr2-5.8.8.dist-info}/RECORD +32 -27
- {qbitrr2-5.8.6.dist-info → qbitrr2-5.8.8.dist-info}/WHEEL +0 -0
- {qbitrr2-5.8.6.dist-info → qbitrr2-5.8.8.dist-info}/entry_points.txt +0 -0
- {qbitrr2-5.8.6.dist-info → qbitrr2-5.8.8.dist-info}/licenses/LICENSE +0 -0
- {qbitrr2-5.8.6.dist-info → qbitrr2-5.8.8.dist-info}/top_level.txt +0 -0
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
qBitrr/config_version.py
CHANGED
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":
|
|
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
|
|