qBitrr2 5.8.6__tar.gz → 5.8.8__tar.gz
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.
- {qbitrr2-5.8.6/qBitrr2.egg-info → qbitrr2-5.8.8}/PKG-INFO +3 -3
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/README.md +2 -2
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/config.example.toml +1 -1
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/pyproject.toml +1 -1
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/arss.py +61 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/bundled_data.py +2 -2
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/config_version.py +1 -1
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/database.py +29 -1
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/gen_config.py +160 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/main.py +229 -0
- qbitrr2-5.8.8/qBitrr/qbit_category_manager.py +293 -0
- qbitrr2-5.8.8/qBitrr/static/assets/ArrView.js +2 -0
- qbitrr2-5.8.8/qBitrr/static/assets/ArrView.js.map +1 -0
- qbitrr2-5.8.8/qBitrr/static/assets/ConfigView.js +6 -0
- qbitrr2-5.8.8/qBitrr/static/assets/ConfigView.js.map +1 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/LogsView.js +9 -9
- qbitrr2-5.8.8/qBitrr/static/assets/LogsView.js.map +1 -0
- qbitrr2-5.8.8/qBitrr/static/assets/ProcessesView.js +2 -0
- qbitrr2-5.8.8/qBitrr/static/assets/ProcessesView.js.map +1 -0
- qbitrr2-5.8.8/qBitrr/static/assets/QbitCategoriesView.js +2 -0
- qbitrr2-5.8.8/qBitrr/static/assets/QbitCategoriesView.js.map +1 -0
- qbitrr2-5.8.8/qBitrr/static/assets/StableTable.js +2 -0
- qbitrr2-5.8.8/qBitrr/static/assets/StableTable.js.map +1 -0
- qbitrr2-5.8.8/qBitrr/static/assets/app.css +1 -0
- qbitrr2-5.8.8/qBitrr/static/assets/app.js +24 -0
- qbitrr2-5.8.8/qBitrr/static/assets/app.js.map +1 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/table.js +1 -1
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/vendor.js +1 -1
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/vendor.js.map +1 -1
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/webui.py +208 -2
- {qbitrr2-5.8.6 → qbitrr2-5.8.8/qBitrr2.egg-info}/PKG-INFO +3 -3
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr2.egg-info/SOURCES.txt +5 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/setup.cfg +1 -1
- qbitrr2-5.8.6/qBitrr/static/assets/ArrView.js +0 -2
- qbitrr2-5.8.6/qBitrr/static/assets/ArrView.js.map +0 -1
- qbitrr2-5.8.6/qBitrr/static/assets/ConfigView.js +0 -6
- qbitrr2-5.8.6/qBitrr/static/assets/ConfigView.js.map +0 -1
- qbitrr2-5.8.6/qBitrr/static/assets/LogsView.js.map +0 -1
- qbitrr2-5.8.6/qBitrr/static/assets/ProcessesView.js +0 -2
- qbitrr2-5.8.6/qBitrr/static/assets/ProcessesView.js.map +0 -1
- qbitrr2-5.8.6/qBitrr/static/assets/app.css +0 -1
- qbitrr2-5.8.6/qBitrr/static/assets/app.js +0 -11
- qbitrr2-5.8.6/qBitrr/static/assets/app.js.map +0 -1
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/LICENSE +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/MANIFEST.in +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/__init__.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/auto_update.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/config.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/db_lock.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/db_recovery.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/env_config.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/errors.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/ffprobe.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/home_path.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/logger.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/search_activity_store.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/build.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/check-mark.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/close.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/download.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/gear.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/lidarr.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/live-streaming.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/log.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/logo.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/plus.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/process.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/react-select.esm.js +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/react-select.esm.js.map +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/refresh-arrow.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/table.js.map +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/trash.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/up-arrow.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/useInterval.js +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/useInterval.js.map +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/visibility.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/favicon-16x16.png +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/favicon-32x32.png +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/favicon-48x48.png +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/favicon.ico +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/icon-192.png +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/icon-512.png +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/index.html +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/logov2-clean.png +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/logov2-clean.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/manifest.json +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/sw.js +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/vite.svg +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/tables.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/utils.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/versioning.py +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr2.egg-info/dependency_links.txt +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr2.egg-info/entry_points.txt +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr2.egg-info/requires.txt +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr2.egg-info/top_level.txt +0 -0
- {qbitrr2-5.8.6 → qbitrr2-5.8.8}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qBitrr2
|
|
3
|
-
Version: 5.8.
|
|
3
|
+
Version: 5.8.8
|
|
4
4
|
Summary: Intelligent automation for qBittorrent and *Arr apps (Radarr/Sonarr/Lidarr) - health monitoring, instant imports, quality upgrades, request integration
|
|
5
5
|
Home-page: https://github.com/Feramance/qBitrr
|
|
6
6
|
Author: Feramance
|
|
@@ -171,7 +171,7 @@ Access the WebUI at `http://<host>:6969/ui` after startup.
|
|
|
171
171
|
|
|
172
172
|
## ✨ Key Features
|
|
173
173
|
|
|
174
|
-
- **🚀 Multi-qBittorrent Support (
|
|
174
|
+
- **🚀 Multi-qBittorrent Support (v5.7.x+)** – Manage torrents across multiple qBittorrent instances for load balancing, redundancy, and VPN isolation
|
|
175
175
|
- **🚑 Torrent Health Monitoring** – Detect stalled/failed downloads, auto-blacklist, trigger re-searches
|
|
176
176
|
- **🔍 Automated Search** – Missing media, quality upgrades, custom format scoring
|
|
177
177
|
- **🎯 Request Integration** – Pull requests from Overseerr/Ombi, prioritize user-requested media
|
|
@@ -206,7 +206,7 @@ Access the WebUI at `http://<host>:6969/ui` after startup.
|
|
|
206
206
|
CompletedDownloadFolder = "/path/to/completed"
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
-
### 🆕 Multi-qBittorrent (
|
|
209
|
+
### 🆕 Multi-qBittorrent (v5.7.x+)
|
|
210
210
|
|
|
211
211
|
Manage torrents across multiple qBittorrent instances:
|
|
212
212
|
|
|
@@ -67,7 +67,7 @@ Access the WebUI at `http://<host>:6969/ui` after startup.
|
|
|
67
67
|
|
|
68
68
|
## ✨ Key Features
|
|
69
69
|
|
|
70
|
-
- **🚀 Multi-qBittorrent Support (
|
|
70
|
+
- **🚀 Multi-qBittorrent Support (v5.7.x+)** – Manage torrents across multiple qBittorrent instances for load balancing, redundancy, and VPN isolation
|
|
71
71
|
- **🚑 Torrent Health Monitoring** – Detect stalled/failed downloads, auto-blacklist, trigger re-searches
|
|
72
72
|
- **🔍 Automated Search** – Missing media, quality upgrades, custom format scoring
|
|
73
73
|
- **🎯 Request Integration** – Pull requests from Overseerr/Ombi, prioritize user-requested media
|
|
@@ -102,7 +102,7 @@ Access the WebUI at `http://<host>:6969/ui` after startup.
|
|
|
102
102
|
CompletedDownloadFolder = "/path/to/completed"
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
### 🆕 Multi-qBittorrent (
|
|
105
|
+
### 🆕 Multi-qBittorrent (v5.7.x+)
|
|
106
106
|
|
|
107
107
|
Manage torrents across multiple qBittorrent instances:
|
|
108
108
|
|
|
@@ -129,7 +129,7 @@ Password = "CHANGE_ME"
|
|
|
129
129
|
|
|
130
130
|
|
|
131
131
|
# ============================================================
|
|
132
|
-
# MULTI-QBITTORRENT SUPPORT (
|
|
132
|
+
# MULTI-QBITTORRENT SUPPORT (v5.7.x+)
|
|
133
133
|
# ============================================================
|
|
134
134
|
# You can configure multiple qBittorrent instances using the [qBit-NAME] syntax.
|
|
135
135
|
# Each Arr instance will monitor ALL qBit instances for torrents in its category.
|
|
@@ -28,7 +28,7 @@ target-version = ['py311']
|
|
|
28
28
|
|
|
29
29
|
[tool.poetry]
|
|
30
30
|
name = "pypi-public"
|
|
31
|
-
version = "5.8.
|
|
31
|
+
version = "5.8.8"
|
|
32
32
|
description = "Intelligent automation for qBittorrent and *Arr apps (Radarr/Sonarr/Lidarr) - health monitoring, instant imports, quality upgrades, request integration"
|
|
33
33
|
authors = ["Drapersniper", "Feramance"]
|
|
34
34
|
readme = "README.md"
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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
|