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.
Files changed (96) hide show
  1. {qbitrr2-5.8.6/qBitrr2.egg-info → qbitrr2-5.8.8}/PKG-INFO +3 -3
  2. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/README.md +2 -2
  3. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/config.example.toml +1 -1
  4. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/pyproject.toml +1 -1
  5. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/arss.py +61 -0
  6. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/bundled_data.py +2 -2
  7. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/config_version.py +1 -1
  8. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/database.py +29 -1
  9. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/gen_config.py +160 -0
  10. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/main.py +229 -0
  11. qbitrr2-5.8.8/qBitrr/qbit_category_manager.py +293 -0
  12. qbitrr2-5.8.8/qBitrr/static/assets/ArrView.js +2 -0
  13. qbitrr2-5.8.8/qBitrr/static/assets/ArrView.js.map +1 -0
  14. qbitrr2-5.8.8/qBitrr/static/assets/ConfigView.js +6 -0
  15. qbitrr2-5.8.8/qBitrr/static/assets/ConfigView.js.map +1 -0
  16. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/LogsView.js +9 -9
  17. qbitrr2-5.8.8/qBitrr/static/assets/LogsView.js.map +1 -0
  18. qbitrr2-5.8.8/qBitrr/static/assets/ProcessesView.js +2 -0
  19. qbitrr2-5.8.8/qBitrr/static/assets/ProcessesView.js.map +1 -0
  20. qbitrr2-5.8.8/qBitrr/static/assets/QbitCategoriesView.js +2 -0
  21. qbitrr2-5.8.8/qBitrr/static/assets/QbitCategoriesView.js.map +1 -0
  22. qbitrr2-5.8.8/qBitrr/static/assets/StableTable.js +2 -0
  23. qbitrr2-5.8.8/qBitrr/static/assets/StableTable.js.map +1 -0
  24. qbitrr2-5.8.8/qBitrr/static/assets/app.css +1 -0
  25. qbitrr2-5.8.8/qBitrr/static/assets/app.js +24 -0
  26. qbitrr2-5.8.8/qBitrr/static/assets/app.js.map +1 -0
  27. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/table.js +1 -1
  28. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/vendor.js +1 -1
  29. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/vendor.js.map +1 -1
  30. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/webui.py +208 -2
  31. {qbitrr2-5.8.6 → qbitrr2-5.8.8/qBitrr2.egg-info}/PKG-INFO +3 -3
  32. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr2.egg-info/SOURCES.txt +5 -0
  33. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/setup.cfg +1 -1
  34. qbitrr2-5.8.6/qBitrr/static/assets/ArrView.js +0 -2
  35. qbitrr2-5.8.6/qBitrr/static/assets/ArrView.js.map +0 -1
  36. qbitrr2-5.8.6/qBitrr/static/assets/ConfigView.js +0 -6
  37. qbitrr2-5.8.6/qBitrr/static/assets/ConfigView.js.map +0 -1
  38. qbitrr2-5.8.6/qBitrr/static/assets/LogsView.js.map +0 -1
  39. qbitrr2-5.8.6/qBitrr/static/assets/ProcessesView.js +0 -2
  40. qbitrr2-5.8.6/qBitrr/static/assets/ProcessesView.js.map +0 -1
  41. qbitrr2-5.8.6/qBitrr/static/assets/app.css +0 -1
  42. qbitrr2-5.8.6/qBitrr/static/assets/app.js +0 -11
  43. qbitrr2-5.8.6/qBitrr/static/assets/app.js.map +0 -1
  44. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/LICENSE +0 -0
  45. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/MANIFEST.in +0 -0
  46. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/__init__.py +0 -0
  47. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/auto_update.py +0 -0
  48. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/config.py +0 -0
  49. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/db_lock.py +0 -0
  50. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/db_recovery.py +0 -0
  51. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/env_config.py +0 -0
  52. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/errors.py +0 -0
  53. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/ffprobe.py +0 -0
  54. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/home_path.py +0 -0
  55. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/logger.py +0 -0
  56. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/search_activity_store.py +0 -0
  57. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/build.svg +0 -0
  58. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/check-mark.svg +0 -0
  59. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/close.svg +0 -0
  60. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/download.svg +0 -0
  61. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/gear.svg +0 -0
  62. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/lidarr.svg +0 -0
  63. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/live-streaming.svg +0 -0
  64. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/log.svg +0 -0
  65. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/logo.svg +0 -0
  66. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/plus.svg +0 -0
  67. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/process.svg +0 -0
  68. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/react-select.esm.js +0 -0
  69. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/react-select.esm.js.map +0 -0
  70. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/refresh-arrow.svg +0 -0
  71. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/table.js.map +0 -0
  72. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/trash.svg +0 -0
  73. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/up-arrow.svg +0 -0
  74. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/useInterval.js +0 -0
  75. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/useInterval.js.map +0 -0
  76. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/assets/visibility.svg +0 -0
  77. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/favicon-16x16.png +0 -0
  78. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/favicon-32x32.png +0 -0
  79. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/favicon-48x48.png +0 -0
  80. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/favicon.ico +0 -0
  81. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/icon-192.png +0 -0
  82. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/icon-512.png +0 -0
  83. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/index.html +0 -0
  84. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/logov2-clean.png +0 -0
  85. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/logov2-clean.svg +0 -0
  86. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/manifest.json +0 -0
  87. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/sw.js +0 -0
  88. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/static/vite.svg +0 -0
  89. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/tables.py +0 -0
  90. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/utils.py +0 -0
  91. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr/versioning.py +0 -0
  92. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr2.egg-info/dependency_links.txt +0 -0
  93. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr2.egg-info/entry_points.txt +0 -0
  94. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr2.egg-info/requires.txt +0 -0
  95. {qbitrr2-5.8.6 → qbitrr2-5.8.8}/qBitrr2.egg-info/top_level.txt +0 -0
  96. {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.6
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 (v3.0+)** – Manage torrents across multiple qBittorrent instances for load balancing, redundancy, and VPN isolation
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 (v3.0+)
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 (v3.0+)** – Manage torrents across multiple qBittorrent instances for load balancing, redundancy, and VPN isolation
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 (v3.0+)
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 (v3.0+)
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.6"
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
@@ -1,5 +1,5 @@
1
- version = "5.8.6"
2
- git_hash = "236b1bf2"
1
+ version = "5.8.8"
2
+ git_hash = "7569d1c7"
3
3
  license_text = (
4
4
  "Licence can be found on:\n\nhttps://github.com/Feramance/qBitrr/blob/master/LICENSE"
5
5
  )
@@ -14,7 +14,7 @@ if TYPE_CHECKING:
14
14
  from qBitrr.gen_config import MyConfig
15
15
 
16
16
  # Current expected config version - increment when schema changes require migration
17
- EXPECTED_CONFIG_VERSION = 3
17
+ EXPECTED_CONFIG_VERSION = 4
18
18
 
19
19
  logger = logging.getLogger(__name__)
20
20
 
@@ -43,8 +43,10 @@ def get_database() -> SqliteDatabase:
43
43
  "cache_size": -64_000,
44
44
  "foreign_keys": 1,
45
45
  "ignore_check_constraints": 0,
46
- "synchronous": 0,
46
+ "synchronous": 2, # FULL mode - maximum safety, prevents corruption on power loss
47
47
  "read_uncommitted": 1,
48
+ "wal_autocheckpoint": 100, # Checkpoint every 100 pages (more frequent = safer)
49
+ "journal_size_limit": 67108864, # 64MB max WAL size
48
50
  },
49
51
  timeout=15,
50
52
  )
@@ -148,3 +150,29 @@ def _create_arrinstance_indexes(db: SqliteDatabase, models: list) -> None:
148
150
  db.commit()
149
151
  except Exception as e:
150
152
  logger.error("Error creating ArrInstance indexes: %s", e)
153
+
154
+
155
+ def get_database_path() -> Path:
156
+ """Get the path to the database file."""
157
+ return Path(APPDATA_FOLDER) / "qbitrr.db"
158
+
159
+
160
+ def checkpoint_database() -> bool:
161
+ """
162
+ Checkpoint the database WAL to prevent corruption on shutdown.
163
+
164
+ This is called automatically on graceful shutdown to ensure all
165
+ WAL entries are flushed to the main database file.
166
+
167
+ Returns:
168
+ True if checkpoint successful, False otherwise
169
+ """
170
+ from qBitrr.db_recovery import checkpoint_wal
171
+
172
+ db_path = get_database_path()
173
+ if not db_path.exists():
174
+ logger.debug("Database file does not exist, skipping checkpoint")
175
+ return True
176
+
177
+ logger.info("Checkpointing database WAL before shutdown...")
178
+ return checkpoint_wal(db_path, logger_override=logger)
@@ -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