qBitrr2 5.5.3__tar.gz → 5.5.5__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 (73) hide show
  1. {qbitrr2-5.5.3/qBitrr2.egg-info → qbitrr2-5.5.5}/PKG-INFO +2 -2
  2. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/README.md +1 -1
  3. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/pyproject.toml +1 -1
  4. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/bundled_data.py +2 -2
  5. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/db_lock.py +135 -3
  6. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/app.js +5 -5
  7. qbitrr2-5.5.5/qBitrr/static/assets/app.js.map +1 -0
  8. qbitrr2-5.5.5/qBitrr/static/assets/logo.svg +48 -0
  9. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/index.html +1 -1
  10. qbitrr2-5.5.5/qBitrr/static/logov2-clean.svg +48 -0
  11. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/manifest.json +1 -1
  12. {qbitrr2-5.5.3 → qbitrr2-5.5.5/qBitrr2.egg-info}/PKG-INFO +2 -2
  13. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr2.egg-info/SOURCES.txt +2 -0
  14. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/setup.cfg +1 -1
  15. qbitrr2-5.5.3/qBitrr/static/assets/app.js.map +0 -1
  16. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/LICENSE +0 -0
  17. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/MANIFEST.in +0 -0
  18. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/config.example.toml +0 -0
  19. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/__init__.py +0 -0
  20. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/arss.py +0 -0
  21. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/auto_update.py +0 -0
  22. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/config.py +0 -0
  23. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/config_version.py +0 -0
  24. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/db_recovery.py +0 -0
  25. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/env_config.py +0 -0
  26. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/errors.py +0 -0
  27. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/ffprobe.py +0 -0
  28. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/gen_config.py +0 -0
  29. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/home_path.py +0 -0
  30. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/logger.py +0 -0
  31. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/main.py +0 -0
  32. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/search_activity_store.py +0 -0
  33. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/ArrView.js +0 -0
  34. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/ArrView.js.map +0 -0
  35. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/ConfigView.js +0 -0
  36. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/ConfigView.js.map +0 -0
  37. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/LogsView.js +0 -0
  38. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/LogsView.js.map +0 -0
  39. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/ProcessesView.js +0 -0
  40. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/ProcessesView.js.map +0 -0
  41. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/app.css +0 -0
  42. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/build.svg +0 -0
  43. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/check-mark.svg +0 -0
  44. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/close.svg +0 -0
  45. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/download.svg +0 -0
  46. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/gear.svg +0 -0
  47. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/live-streaming.svg +0 -0
  48. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/log.svg +0 -0
  49. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/plus.svg +0 -0
  50. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/process.svg +0 -0
  51. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/react-select.esm.js +0 -0
  52. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/react-select.esm.js.map +0 -0
  53. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/refresh-arrow.svg +0 -0
  54. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/table.js +0 -0
  55. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/table.js.map +0 -0
  56. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/trash.svg +0 -0
  57. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/up-arrow.svg +0 -0
  58. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/useInterval.js +0 -0
  59. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/useInterval.js.map +0 -0
  60. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/vendor.js +0 -0
  61. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/vendor.js.map +0 -0
  62. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/visibility.svg +0 -0
  63. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/sw.js +0 -0
  64. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/vite.svg +0 -0
  65. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/tables.py +0 -0
  66. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/utils.py +0 -0
  67. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/versioning.py +0 -0
  68. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/webui.py +0 -0
  69. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr2.egg-info/dependency_links.txt +0 -0
  70. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr2.egg-info/entry_points.txt +0 -0
  71. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr2.egg-info/requires.txt +0 -0
  72. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr2.egg-info/top_level.txt +0 -0
  73. {qbitrr2-5.5.3 → qbitrr2-5.5.5}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qBitrr2
3
- Version: 5.5.3
3
+ Version: 5.5.5
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
@@ -83,7 +83,7 @@ Requires-Dist: upgrade-pip==0.1.4; extra == "all"
83
83
  Requires-Dist: ujson==5.10.0; extra == "all"
84
84
  Dynamic: license-file
85
85
 
86
- # qBitrr
86
+ # <img src="assets/logov2-clean.png" alt="qBitrr Logo" width="40" style="vertical-align: middle;"/> qBitrr
87
87
 
88
88
  [![PyPI](https://img.shields.io/pypi/v/qBitrr2?label=PyPI)](https://pypi.org/project/qBitrr2/)
89
89
  [![Downloads](https://img.shields.io/pypi/dm/qBitrr2)](https://pypi.org/project/qBitrr2/)
@@ -1,4 +1,4 @@
1
- # qBitrr
1
+ # <img src="assets/logov2-clean.png" alt="qBitrr Logo" width="40" style="vertical-align: middle;"/> qBitrr
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/qBitrr2?label=PyPI)](https://pypi.org/project/qBitrr2/)
4
4
  [![Downloads](https://img.shields.io/pypi/dm/qBitrr2)](https://pypi.org/project/qBitrr2/)
@@ -28,7 +28,7 @@ target-version = ['py311']
28
28
 
29
29
  [tool.poetry]
30
30
  name = "pypi-public"
31
- version = "5.5.3"
31
+ version = "5.5.5"
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"
@@ -1,5 +1,5 @@
1
- version = "5.5.3"
2
- git_hash = "0af67a21"
1
+ version = "5.5.5"
2
+ git_hash = "b4485490"
3
3
  license_text = (
4
4
  "Licence can be found on:\n\nhttps://github.com/Feramance/qBitrr/blob/master/LICENSE"
5
5
  )
@@ -6,6 +6,7 @@ from contextlib import contextmanager
6
6
  from pathlib import Path
7
7
  from typing import Iterator
8
8
 
9
+ from qBitrr.db_recovery import checkpoint_wal, repair_database
9
10
  from qBitrr.home_path import APPDATA_FOLDER
10
11
 
11
12
  if os.name == "nt": # pragma: no cover - platform specific
@@ -99,6 +100,8 @@ def with_database_retry(
99
100
  - sqlite3.IntegrityError (data constraint violations)
100
101
  - sqlite3.ProgrammingError (SQL syntax errors)
101
102
 
103
+ On detecting database corruption, attempts automatic recovery before retrying.
104
+
102
105
  Args:
103
106
  func: Callable to execute (should take no arguments)
104
107
  retries: Maximum number of retry attempts (default: 5)
@@ -118,6 +121,8 @@ def with_database_retry(
118
121
  import time
119
122
 
120
123
  attempt = 0
124
+ corruption_recovery_attempted = False
125
+
121
126
  while True:
122
127
  try:
123
128
  return func()
@@ -128,6 +133,61 @@ def with_database_retry(
128
133
  if "syntax" in error_msg or "constraint" in error_msg:
129
134
  raise
130
135
 
136
+ # Detect corruption and attempt recovery (only once)
137
+ if not corruption_recovery_attempted and (
138
+ "disk image is malformed" in error_msg
139
+ or "database disk image is malformed" in error_msg
140
+ or "database corruption" in error_msg
141
+ ):
142
+ corruption_recovery_attempted = True
143
+ if logger:
144
+ logger.error(
145
+ "Database corruption detected: %s. Attempting automatic recovery...",
146
+ e,
147
+ )
148
+
149
+ recovery_succeeded = False
150
+ try:
151
+ db_path = APPDATA_FOLDER / "qbitrr.db"
152
+
153
+ # Step 1: Try WAL checkpoint (least invasive)
154
+ if logger:
155
+ logger.info("Attempting WAL checkpoint...")
156
+ if checkpoint_wal(db_path, logger):
157
+ if logger:
158
+ logger.info("WAL checkpoint successful - retrying operation")
159
+ recovery_succeeded = True
160
+ else:
161
+ # Step 2: Try full repair (more invasive)
162
+ if logger:
163
+ logger.warning(
164
+ "WAL checkpoint failed - attempting full database repair..."
165
+ )
166
+ if repair_database(db_path, backup=True, logger_override=logger):
167
+ if logger:
168
+ logger.info("Database repair successful - retrying operation")
169
+ recovery_succeeded = True
170
+
171
+ except Exception as recovery_error:
172
+ if logger:
173
+ logger.error(
174
+ "Database recovery error: %s",
175
+ recovery_error,
176
+ )
177
+
178
+ if recovery_succeeded:
179
+ # Reset attempt counter after successful recovery
180
+ attempt = 0
181
+ time.sleep(1) # Brief pause before retry
182
+ continue
183
+
184
+ # If we reach here, recovery failed - log and continue with normal retry
185
+ if logger:
186
+ logger.critical(
187
+ "Automatic database recovery failed. "
188
+ "Manual intervention may be required. Attempting normal retry..."
189
+ )
190
+
131
191
  attempt += 1
132
192
  if attempt >= retries:
133
193
  if logger:
@@ -161,16 +221,18 @@ class ResilientSqliteDatabase:
161
221
  (specifically when setting PRAGMAs), before query-level retry logic can help.
162
222
  """
163
223
 
164
- def __init__(self, database, max_retries=5, backoff=0.5):
224
+ def __init__(self, database, max_retries=5, backoff=0.5, logger=None):
165
225
  """
166
226
  Args:
167
227
  database: Peewee SqliteDatabase instance to wrap
168
228
  max_retries: Maximum connection retry attempts
169
229
  backoff: Initial backoff delay in seconds
230
+ logger: Optional logger instance for logging recovery attempts
170
231
  """
171
232
  self._db = database
172
233
  self._max_retries = max_retries
173
234
  self._backoff = backoff
235
+ self._logger = logger
174
236
 
175
237
  def __getattr__(self, name):
176
238
  """Delegate all attribute access to the wrapped database."""
@@ -194,6 +256,7 @@ class ResilientSqliteDatabase:
194
256
 
195
257
  last_error = None
196
258
  delay = self._backoff
259
+ corruption_recovery_attempted = False
197
260
 
198
261
  for attempt in range(1, self._max_retries + 1):
199
262
  try:
@@ -201,8 +264,77 @@ class ResilientSqliteDatabase:
201
264
  except (OperationalError, DatabaseError, sqlite3.OperationalError) as e:
202
265
  error_msg = str(e).lower()
203
266
 
204
- # Only retry on transient I/O errors
205
- if "disk i/o error" in error_msg or "database is locked" in error_msg:
267
+ # Detect corruption and attempt recovery (only once)
268
+ if not corruption_recovery_attempted and (
269
+ "disk image is malformed" in error_msg
270
+ or "database disk image is malformed" in error_msg
271
+ or "database corruption" in error_msg
272
+ ):
273
+ corruption_recovery_attempted = True
274
+ if self._logger:
275
+ self._logger.error(
276
+ "Database corruption detected during connection: %s. "
277
+ "Attempting automatic recovery...",
278
+ e,
279
+ )
280
+
281
+ recovery_succeeded = False
282
+ try:
283
+ db_path = APPDATA_FOLDER / "qbitrr.db"
284
+
285
+ # Close current connection if any
286
+ try:
287
+ if not self._db.is_closed():
288
+ self._db.close()
289
+ except Exception:
290
+ pass # Ignore errors closing corrupted connection
291
+
292
+ # Step 1: Try WAL checkpoint
293
+ if self._logger:
294
+ self._logger.info("Attempting WAL checkpoint...")
295
+ if checkpoint_wal(db_path, self._logger):
296
+ if self._logger:
297
+ self._logger.info(
298
+ "WAL checkpoint successful - retrying connection"
299
+ )
300
+ recovery_succeeded = True
301
+ else:
302
+ # Step 2: Try full repair
303
+ if self._logger:
304
+ self._logger.warning(
305
+ "WAL checkpoint failed - attempting full database repair..."
306
+ )
307
+ if repair_database(db_path, backup=True, logger_override=self._logger):
308
+ if self._logger:
309
+ self._logger.info(
310
+ "Database repair successful - retrying connection"
311
+ )
312
+ recovery_succeeded = True
313
+
314
+ except Exception as recovery_error:
315
+ if self._logger:
316
+ self._logger.error(
317
+ "Database recovery error: %s",
318
+ recovery_error,
319
+ )
320
+
321
+ if recovery_succeeded:
322
+ time.sleep(1)
323
+ continue
324
+
325
+ # Recovery failed - log and continue with normal retry
326
+ if self._logger:
327
+ self._logger.critical(
328
+ "Automatic database recovery failed. "
329
+ "Manual intervention may be required."
330
+ )
331
+
332
+ # Retry on transient I/O errors
333
+ if (
334
+ "disk i/o error" in error_msg
335
+ or "database is locked" in error_msg
336
+ or "disk image is malformed" in error_msg
337
+ ):
206
338
  last_error = e
207
339
 
208
340
  if attempt < self._max_retries: