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.
- {qbitrr2-5.5.3/qBitrr2.egg-info → qbitrr2-5.5.5}/PKG-INFO +2 -2
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/README.md +1 -1
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/pyproject.toml +1 -1
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/bundled_data.py +2 -2
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/db_lock.py +135 -3
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/app.js +5 -5
- qbitrr2-5.5.5/qBitrr/static/assets/app.js.map +1 -0
- qbitrr2-5.5.5/qBitrr/static/assets/logo.svg +48 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/index.html +1 -1
- qbitrr2-5.5.5/qBitrr/static/logov2-clean.svg +48 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/manifest.json +1 -1
- {qbitrr2-5.5.3 → qbitrr2-5.5.5/qBitrr2.egg-info}/PKG-INFO +2 -2
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr2.egg-info/SOURCES.txt +2 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/setup.cfg +1 -1
- qbitrr2-5.5.3/qBitrr/static/assets/app.js.map +0 -1
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/LICENSE +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/MANIFEST.in +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/config.example.toml +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/__init__.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/arss.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/auto_update.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/config.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/config_version.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/db_recovery.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/env_config.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/errors.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/ffprobe.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/gen_config.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/home_path.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/logger.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/main.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/search_activity_store.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/ArrView.js +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/ArrView.js.map +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/ConfigView.js +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/ConfigView.js.map +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/LogsView.js +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/LogsView.js.map +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/ProcessesView.js +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/ProcessesView.js.map +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/app.css +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/build.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/check-mark.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/close.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/download.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/gear.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/live-streaming.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/log.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/plus.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/process.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/react-select.esm.js +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/react-select.esm.js.map +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/refresh-arrow.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/table.js +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/table.js.map +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/trash.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/up-arrow.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/useInterval.js +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/useInterval.js.map +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/vendor.js +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/vendor.js.map +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/assets/visibility.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/sw.js +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/static/vite.svg +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/tables.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/utils.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/versioning.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr/webui.py +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr2.egg-info/dependency_links.txt +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr2.egg-info/entry_points.txt +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr2.egg-info/requires.txt +0 -0
- {qbitrr2-5.5.3 → qbitrr2-5.5.5}/qBitrr2.egg-info/top_level.txt +0 -0
- {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
|
+
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
|
[](https://pypi.org/project/qBitrr2/)
|
|
89
89
|
[](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
|
[](https://pypi.org/project/qBitrr2/)
|
|
4
4
|
[](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.
|
|
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"
|
|
@@ -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
|
-
#
|
|
205
|
-
if
|
|
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:
|