qBitrr2 4.10.15__py3-none-any.whl → 5.4.5__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 +2127 -850
- qBitrr/auto_update.py +382 -0
- qBitrr/bundled_data.py +3 -2
- qBitrr/config.py +20 -3
- qBitrr/db_lock.py +79 -0
- qBitrr/env_config.py +19 -7
- qBitrr/gen_config.py +286 -26
- qBitrr/logger.py +87 -3
- qBitrr/main.py +453 -101
- qBitrr/search_activity_store.py +88 -0
- qBitrr/static/assets/ArrView.js +2 -0
- qBitrr/static/assets/ArrView.js.map +1 -0
- qBitrr/static/assets/ConfigView.js +4 -0
- qBitrr/static/assets/ConfigView.js.map +1 -0
- qBitrr/static/assets/LogsView.js +230 -0
- qBitrr/static/assets/LogsView.js.map +1 -0
- qBitrr/static/assets/ProcessesView.js +2 -0
- qBitrr/static/assets/ProcessesView.js.map +1 -0
- qBitrr/static/assets/app.css +1 -0
- qBitrr/static/assets/app.js +11 -0
- qBitrr/static/assets/app.js.map +1 -0
- qBitrr/static/assets/build.svg +3 -0
- qBitrr/static/assets/check-mark.svg +5 -0
- qBitrr/static/assets/close.svg +4 -0
- qBitrr/static/assets/download.svg +5 -0
- qBitrr/static/assets/gear.svg +5 -0
- qBitrr/static/assets/lidarr.svg +1 -0
- qBitrr/static/assets/live-streaming.svg +8 -0
- qBitrr/static/assets/log.svg +3 -0
- qBitrr/static/assets/plus.svg +4 -0
- qBitrr/static/assets/process.svg +15 -0
- qBitrr/static/assets/react-select.esm.js +14 -0
- qBitrr/static/assets/react-select.esm.js.map +1 -0
- qBitrr/static/assets/refresh-arrow.svg +3 -0
- qBitrr/static/assets/table.js +23 -0
- qBitrr/static/assets/table.js.map +1 -0
- qBitrr/static/assets/trash.svg +8 -0
- qBitrr/static/assets/up-arrow.svg +3 -0
- qBitrr/static/assets/useInterval.js +2 -0
- qBitrr/static/assets/useInterval.js.map +1 -0
- qBitrr/static/assets/vendor.js +33 -0
- qBitrr/static/assets/vendor.js.map +1 -0
- qBitrr/static/assets/visibility.svg +9 -0
- qBitrr/static/index.html +47 -0
- qBitrr/static/manifest.json +23 -0
- qBitrr/static/sw.js +105 -0
- qBitrr/static/vite.svg +1 -0
- qBitrr/tables.py +44 -0
- qBitrr/utils.py +82 -15
- qBitrr/versioning.py +136 -0
- qBitrr/webui.py +2612 -0
- qbitrr2-5.4.5.dist-info/METADATA +1116 -0
- qbitrr2-5.4.5.dist-info/RECORD +61 -0
- {qbitrr2-4.10.15.dist-info → qbitrr2-5.4.5.dist-info}/WHEEL +1 -1
- qbitrr2-4.10.15.dist-info/METADATA +0 -239
- qbitrr2-4.10.15.dist-info/RECORD +0 -19
- {qbitrr2-4.10.15.dist-info → qbitrr2-5.4.5.dist-info}/entry_points.txt +0 -0
- {qbitrr2-4.10.15.dist-info → qbitrr2-5.4.5.dist-info/licenses}/LICENSE +0 -0
- {qbitrr2-4.10.15.dist-info → qbitrr2-5.4.5.dist-info}/top_level.txt +0 -0
qBitrr/auto_update.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import platform
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from croniter import croniter
|
|
14
|
+
from croniter.croniter import CroniterBadCronError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_installation_type() -> str:
|
|
18
|
+
"""Detect how qBitrr is installed.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
"binary" - PyInstaller frozen executable
|
|
22
|
+
"git" - Git repository installation
|
|
23
|
+
"pip" - PyPI package installation
|
|
24
|
+
"""
|
|
25
|
+
# Check if running as PyInstaller binary
|
|
26
|
+
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
|
27
|
+
return "binary"
|
|
28
|
+
|
|
29
|
+
# Check for git repository
|
|
30
|
+
repo_root = Path(__file__).resolve().parent.parent
|
|
31
|
+
git_dir = repo_root / ".git"
|
|
32
|
+
if git_dir.exists():
|
|
33
|
+
return "git"
|
|
34
|
+
|
|
35
|
+
# Default to pip installation
|
|
36
|
+
return "pip"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_binary_asset_pattern() -> str:
|
|
40
|
+
"""Get the asset filename pattern for the current platform.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Partial filename to match against release assets
|
|
44
|
+
Examples: "ubuntu-latest-x64", "windows-latest-x64", "macOS-latest-arm64"
|
|
45
|
+
|
|
46
|
+
Note: The release workflow only builds these platforms:
|
|
47
|
+
- ubuntu-latest-x64
|
|
48
|
+
- macOS-latest-arm64
|
|
49
|
+
- windows-latest-x64
|
|
50
|
+
Other platforms (Linux ARM, macOS Intel, Windows ARM) are not built.
|
|
51
|
+
"""
|
|
52
|
+
system = platform.system()
|
|
53
|
+
machine = platform.machine()
|
|
54
|
+
|
|
55
|
+
# Map platform to GitHub runner names (matching build workflow)
|
|
56
|
+
if system == "Linux":
|
|
57
|
+
os_part = "ubuntu-latest"
|
|
58
|
+
# Note: Only x64 is built for Linux (arm64 excluded from workflow)
|
|
59
|
+
arch_part = "x64" if machine in ("x86_64", "AMD64") else "arm64"
|
|
60
|
+
elif system == "Darwin": # macOS
|
|
61
|
+
os_part = "macOS-latest"
|
|
62
|
+
# Note: Only arm64 is built for macOS (x64/Intel excluded from workflow)
|
|
63
|
+
arch_part = "arm64" if machine == "arm64" else "x64"
|
|
64
|
+
elif system == "Windows":
|
|
65
|
+
os_part = "windows-latest"
|
|
66
|
+
# Note: Only x64 is built for Windows (arm64 excluded from workflow)
|
|
67
|
+
arch_part = "x64" if machine in ("x86_64", "AMD64") else "arm64"
|
|
68
|
+
else:
|
|
69
|
+
raise RuntimeError(f"Unsupported platform: {system} {machine}")
|
|
70
|
+
|
|
71
|
+
return f"{os_part}-{arch_part}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_binary_download_url(release_tag: str, logger: logging.Logger) -> dict[str, Any]:
|
|
75
|
+
"""Get the download URL for the binary asset matching current platform.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
release_tag: GitHub release tag (e.g., "v5.4.3")
|
|
79
|
+
logger: Logger instance
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Dict with 'url', 'name', 'size' if found, or 'error' if not found
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
# Get asset pattern for current platform
|
|
86
|
+
asset_pattern = get_binary_asset_pattern()
|
|
87
|
+
logger.debug("Looking for binary asset matching: %s", asset_pattern)
|
|
88
|
+
|
|
89
|
+
# Fetch release details with assets
|
|
90
|
+
repo = "Feramance/qBitrr"
|
|
91
|
+
url = f"https://api.github.com/repos/{repo}/releases/tags/{release_tag}"
|
|
92
|
+
response = requests.get(url, timeout=30)
|
|
93
|
+
response.raise_for_status()
|
|
94
|
+
release_data = response.json()
|
|
95
|
+
|
|
96
|
+
# Find matching asset
|
|
97
|
+
assets = release_data.get("assets", [])
|
|
98
|
+
for asset in assets:
|
|
99
|
+
name = asset.get("name", "")
|
|
100
|
+
if asset_pattern in name:
|
|
101
|
+
return {
|
|
102
|
+
"url": asset["browser_download_url"],
|
|
103
|
+
"name": name,
|
|
104
|
+
"size": asset.get("size", 0),
|
|
105
|
+
"error": None,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# No matching asset found
|
|
109
|
+
available = [a.get("name") for a in assets]
|
|
110
|
+
logger.error(
|
|
111
|
+
"No binary asset found for platform %s in release %s",
|
|
112
|
+
asset_pattern,
|
|
113
|
+
release_tag,
|
|
114
|
+
)
|
|
115
|
+
logger.debug("Available assets: %s", available)
|
|
116
|
+
|
|
117
|
+
# Provide helpful error message
|
|
118
|
+
system = platform.system()
|
|
119
|
+
machine = platform.machine()
|
|
120
|
+
unsupported_platforms = [
|
|
121
|
+
"ubuntu-latest-arm64",
|
|
122
|
+
"macOS-latest-x64",
|
|
123
|
+
"windows-latest-arm64",
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
error_msg = f"No binary available for {system} {machine}"
|
|
127
|
+
if asset_pattern in unsupported_platforms:
|
|
128
|
+
error_msg += f" (platform {asset_pattern} is not built by release workflow)"
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"url": None,
|
|
132
|
+
"name": None,
|
|
133
|
+
"size": None,
|
|
134
|
+
"error": error_msg,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
logger.error("Failed to fetch binary asset info: %s", exc)
|
|
139
|
+
return {
|
|
140
|
+
"url": None,
|
|
141
|
+
"name": None,
|
|
142
|
+
"size": None,
|
|
143
|
+
"error": str(exc),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class AutoUpdater:
|
|
148
|
+
"""Background worker that executes a callback on a cron schedule."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, cron_expr: str, callback: Callable[[], None], logger: logging.Logger):
|
|
151
|
+
self._cron_expr = cron_expr
|
|
152
|
+
self._callback = callback
|
|
153
|
+
self._logger = logger
|
|
154
|
+
self._stop_event = threading.Event()
|
|
155
|
+
self._thread: threading.Thread | None = None
|
|
156
|
+
self._iterator = None
|
|
157
|
+
|
|
158
|
+
def start(self) -> bool:
|
|
159
|
+
"""Start the background worker. Returns False if cron expression is invalid."""
|
|
160
|
+
|
|
161
|
+
self.stop()
|
|
162
|
+
try:
|
|
163
|
+
self._iterator = croniter(self._cron_expr, datetime.now())
|
|
164
|
+
except CroniterBadCronError as exc:
|
|
165
|
+
self._logger.error(
|
|
166
|
+
"Auto update disabled: invalid cron expression '%s' (%s)",
|
|
167
|
+
self._cron_expr,
|
|
168
|
+
exc,
|
|
169
|
+
)
|
|
170
|
+
self._iterator = None
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
self._stop_event = threading.Event()
|
|
174
|
+
self._thread = threading.Thread(target=self._run, name="AutoUpdater", daemon=True)
|
|
175
|
+
self._thread.start()
|
|
176
|
+
self._logger.info("Auto update scheduled with cron '%s'.", self._cron_expr)
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
def stop(self) -> None:
|
|
180
|
+
self._stop_event.set()
|
|
181
|
+
thread = self._thread
|
|
182
|
+
if thread and thread.is_alive():
|
|
183
|
+
thread.join(timeout=5)
|
|
184
|
+
if thread.is_alive():
|
|
185
|
+
self._logger.warning("Auto update worker failed to stop within timeout")
|
|
186
|
+
self._thread = None
|
|
187
|
+
|
|
188
|
+
def _run(self) -> None:
|
|
189
|
+
iterator = self._iterator
|
|
190
|
+
if iterator is None:
|
|
191
|
+
return
|
|
192
|
+
stop_event = self._stop_event
|
|
193
|
+
while True:
|
|
194
|
+
next_run = iterator.get_next(datetime)
|
|
195
|
+
self._logger.debug("Next auto update scheduled for %s", next_run.isoformat())
|
|
196
|
+
while True:
|
|
197
|
+
if stop_event.is_set():
|
|
198
|
+
return
|
|
199
|
+
wait_seconds = (next_run - datetime.now()).total_seconds()
|
|
200
|
+
if wait_seconds <= 0:
|
|
201
|
+
break
|
|
202
|
+
stop_event.wait(timeout=min(wait_seconds, 60))
|
|
203
|
+
if stop_event.is_set():
|
|
204
|
+
return
|
|
205
|
+
self._execute()
|
|
206
|
+
|
|
207
|
+
def _execute(self) -> None:
|
|
208
|
+
self._logger.info("Auto update triggered")
|
|
209
|
+
try:
|
|
210
|
+
self._callback()
|
|
211
|
+
except Exception: # pragma: no cover - safeguard for background thread
|
|
212
|
+
self._logger.exception("Auto update failed")
|
|
213
|
+
else:
|
|
214
|
+
self._logger.info("Auto update completed")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def verify_update_success(expected_version: str, logger: logging.Logger) -> bool:
|
|
218
|
+
"""Verify that the installed version matches the expected version.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
expected_version: Expected version string (e.g., "5.4.3")
|
|
222
|
+
logger: Logger instance for output
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
True if version matches, False otherwise
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
# Re-import bundled_data to get fresh version
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
# Remove cached module
|
|
232
|
+
if "qBitrr.bundled_data" in sys.modules:
|
|
233
|
+
del sys.modules["qBitrr.bundled_data"]
|
|
234
|
+
|
|
235
|
+
# Re-import
|
|
236
|
+
from qBitrr import bundled_data
|
|
237
|
+
from qBitrr.versioning import normalize_version
|
|
238
|
+
|
|
239
|
+
current = normalize_version(bundled_data.version)
|
|
240
|
+
expected = normalize_version(expected_version)
|
|
241
|
+
|
|
242
|
+
if current == expected:
|
|
243
|
+
logger.info("Update verified: version %s installed successfully", current)
|
|
244
|
+
return True
|
|
245
|
+
logger.warning(
|
|
246
|
+
"Version mismatch after update: expected %s, got %s",
|
|
247
|
+
expected,
|
|
248
|
+
current,
|
|
249
|
+
)
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
logger.error("Failed to verify update: %s", exc)
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def perform_self_update(logger: logging.Logger, target_version: str | None = None) -> bool:
|
|
258
|
+
"""Attempt to update qBitrr in-place using appropriate method for installation type.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
logger: Logger instance for output
|
|
262
|
+
target_version: Optional specific version to update to (e.g., "5.4.3")
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
True when the update command completed successfully, False otherwise.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
# Detect installation type
|
|
269
|
+
install_type = get_installation_type()
|
|
270
|
+
logger.debug("Installation type detected: %s", install_type)
|
|
271
|
+
|
|
272
|
+
# BINARY INSTALLATION - Cannot auto-update
|
|
273
|
+
if install_type == "binary":
|
|
274
|
+
logger.info("Binary installation detected - manual update required")
|
|
275
|
+
if target_version:
|
|
276
|
+
logger.info(
|
|
277
|
+
"Update available: v%s",
|
|
278
|
+
target_version if target_version.startswith("v") else f"v{target_version}",
|
|
279
|
+
)
|
|
280
|
+
logger.info("Download from: https://github.com/Feramance/qBitrr/releases/latest")
|
|
281
|
+
logger.info("Instructions:")
|
|
282
|
+
logger.info(" 1. Download the binary for your platform")
|
|
283
|
+
logger.info(" 2. Extract the archive")
|
|
284
|
+
logger.info(" 3. Replace current executable with new binary")
|
|
285
|
+
logger.info(" 4. Restart qBitrr")
|
|
286
|
+
return False # Binary updates require manual intervention
|
|
287
|
+
|
|
288
|
+
# GIT INSTALLATION
|
|
289
|
+
elif install_type == "git":
|
|
290
|
+
repo_root = Path(__file__).resolve().parent.parent
|
|
291
|
+
repo_root / ".git"
|
|
292
|
+
logger.debug("Git repository detected at %s", repo_root)
|
|
293
|
+
|
|
294
|
+
if target_version:
|
|
295
|
+
# Strict version: checkout specific tag
|
|
296
|
+
tag = target_version if target_version.startswith("v") else f"v{target_version}"
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
logger.debug("Fetching tags from remote")
|
|
300
|
+
subprocess.run(
|
|
301
|
+
["git", "fetch", "--tags", "--force"],
|
|
302
|
+
cwd=str(repo_root),
|
|
303
|
+
capture_output=True,
|
|
304
|
+
text=True,
|
|
305
|
+
check=True,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
result = subprocess.run(
|
|
309
|
+
["git", "rev-parse", tag],
|
|
310
|
+
cwd=str(repo_root),
|
|
311
|
+
capture_output=True,
|
|
312
|
+
text=True,
|
|
313
|
+
)
|
|
314
|
+
if result.returncode != 0:
|
|
315
|
+
logger.error("Tag %s not found in repository", tag)
|
|
316
|
+
logger.warning("Falling back to git pull")
|
|
317
|
+
else:
|
|
318
|
+
result = subprocess.run(
|
|
319
|
+
["git", "checkout", tag],
|
|
320
|
+
cwd=str(repo_root),
|
|
321
|
+
capture_output=True,
|
|
322
|
+
text=True,
|
|
323
|
+
check=True,
|
|
324
|
+
)
|
|
325
|
+
stdout = (result.stdout or "").strip()
|
|
326
|
+
if stdout:
|
|
327
|
+
logger.info("git checkout output:\n%s", stdout)
|
|
328
|
+
logger.info("Checked out tag %s", tag)
|
|
329
|
+
return True
|
|
330
|
+
|
|
331
|
+
except subprocess.CalledProcessError as exc:
|
|
332
|
+
stderr = (exc.stderr or "").strip()
|
|
333
|
+
logger.error("Failed to checkout tag %s: %s", tag, stderr or exc)
|
|
334
|
+
logger.warning("Falling back to git pull")
|
|
335
|
+
|
|
336
|
+
# Default: git pull
|
|
337
|
+
try:
|
|
338
|
+
result = subprocess.run(
|
|
339
|
+
["git", "pull", "--ff-only"],
|
|
340
|
+
cwd=str(repo_root),
|
|
341
|
+
capture_output=True,
|
|
342
|
+
text=True,
|
|
343
|
+
check=True,
|
|
344
|
+
)
|
|
345
|
+
stdout = (result.stdout or "").strip()
|
|
346
|
+
if stdout:
|
|
347
|
+
logger.info("git pull output:\n%s", stdout)
|
|
348
|
+
return True
|
|
349
|
+
except subprocess.CalledProcessError as exc:
|
|
350
|
+
stderr = (exc.stderr or "").strip()
|
|
351
|
+
logger.error("Failed to update repository via git: %s", stderr or exc)
|
|
352
|
+
return False
|
|
353
|
+
|
|
354
|
+
# PIP INSTALLATION
|
|
355
|
+
elif install_type == "pip":
|
|
356
|
+
logger.debug("PyPI installation detected")
|
|
357
|
+
|
|
358
|
+
package = "qBitrr2"
|
|
359
|
+
if target_version:
|
|
360
|
+
# Strict version: install exact version
|
|
361
|
+
version = target_version[1:] if target_version.startswith("v") else target_version
|
|
362
|
+
package = f"{package}=={version}"
|
|
363
|
+
|
|
364
|
+
logger.debug("Upgrading package: %s", package)
|
|
365
|
+
try:
|
|
366
|
+
result = subprocess.run(
|
|
367
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", package],
|
|
368
|
+
capture_output=True,
|
|
369
|
+
text=True,
|
|
370
|
+
check=True,
|
|
371
|
+
)
|
|
372
|
+
stdout = (result.stdout or "").strip()
|
|
373
|
+
if stdout:
|
|
374
|
+
logger.info("pip upgrade output:\n%s", stdout)
|
|
375
|
+
return True
|
|
376
|
+
except subprocess.CalledProcessError as exc:
|
|
377
|
+
stderr = (exc.stderr or "").strip()
|
|
378
|
+
logger.error("Failed to upgrade package via pip: %s", stderr or exc)
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
logger.error("Unknown installation type: %s", install_type)
|
|
382
|
+
return False
|
qBitrr/bundled_data.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
version = "4.
|
|
2
|
-
git_hash = "
|
|
1
|
+
version = "5.4.5"
|
|
2
|
+
git_hash = "155d9043"
|
|
3
3
|
license_text = (
|
|
4
4
|
"Licence can be found on:\n\nhttps://github.com/Feramance/qBitrr/blob/master/LICENSE"
|
|
5
5
|
)
|
|
6
6
|
patched_version = f"{version}-{git_hash}"
|
|
7
|
+
tagged_version = f"{version}"
|
qBitrr/config.py
CHANGED
|
@@ -8,7 +8,7 @@ import sys
|
|
|
8
8
|
|
|
9
9
|
from qBitrr.bundled_data import license_text, patched_version
|
|
10
10
|
from qBitrr.env_config import ENVIRO_CONFIG
|
|
11
|
-
from qBitrr.gen_config import MyConfig, _write_config_file, generate_doc
|
|
11
|
+
from qBitrr.gen_config import MyConfig, _write_config_file, apply_config_migrations, generate_doc
|
|
12
12
|
from qBitrr.home_path import APPDATA_FOLDER, HOME_PATH
|
|
13
13
|
|
|
14
14
|
|
|
@@ -84,8 +84,8 @@ elif (not CONFIG_FILE.exists()) and (not CONFIG_PATH.exists()):
|
|
|
84
84
|
print(f"{file} has not been found")
|
|
85
85
|
|
|
86
86
|
CONFIG_FILE = _write_config_file(docker=True)
|
|
87
|
-
print(f"
|
|
88
|
-
print(
|
|
87
|
+
print(f'"{CONFIG_FILE.name}" has been generated with default values.')
|
|
88
|
+
print("Update the file to match your environment, then restart the container.")
|
|
89
89
|
|
|
90
90
|
CONFIG_EXISTS = False
|
|
91
91
|
|
|
@@ -105,6 +105,10 @@ if COPIED_TO_NEW_DIR is not None:
|
|
|
105
105
|
else:
|
|
106
106
|
print(f"STARTING QBITRR | CONFIG_FILE={CONFIG_FILE} | CONFIG_PATH={CONFIG_PATH}")
|
|
107
107
|
|
|
108
|
+
# Apply configuration migrations and validations
|
|
109
|
+
if CONFIG_EXISTS:
|
|
110
|
+
apply_config_migrations(CONFIG)
|
|
111
|
+
|
|
108
112
|
FFPROBE_AUTO_UPDATE = (
|
|
109
113
|
CONFIG.get("Settings.FFprobeAutoUpdate", fallback=True)
|
|
110
114
|
if ENVIRO_CONFIG.settings.ffprobe_auto_update is None
|
|
@@ -173,3 +177,16 @@ if SEARCH_ONLY and QBIT_DISABLED is False:
|
|
|
173
177
|
# Settings Config Values
|
|
174
178
|
FF_VERSION = APPDATA_FOLDER.joinpath("ffprobe_info.json")
|
|
175
179
|
FF_PROBE = APPDATA_FOLDER.joinpath("ffprobe")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_auto_update_settings() -> tuple[bool, str]:
|
|
183
|
+
enabled_env = ENVIRO_CONFIG.settings.auto_update_enabled
|
|
184
|
+
cron_env = ENVIRO_CONFIG.settings.auto_update_cron
|
|
185
|
+
enabled = (
|
|
186
|
+
enabled_env
|
|
187
|
+
if enabled_env is not None
|
|
188
|
+
else CONFIG.get("Settings.AutoUpdateEnabled", fallback=False)
|
|
189
|
+
)
|
|
190
|
+
cron = cron_env or CONFIG.get("Settings.AutoUpdateCron", fallback="0 3 * * 0")
|
|
191
|
+
cron = str(cron or "0 3 * * 0")
|
|
192
|
+
return bool(enabled), cron
|
qBitrr/db_lock.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterator
|
|
8
|
+
|
|
9
|
+
from qBitrr.home_path import APPDATA_FOLDER
|
|
10
|
+
|
|
11
|
+
if os.name == "nt": # pragma: no cover - platform specific
|
|
12
|
+
import msvcrt
|
|
13
|
+
else: # pragma: no cover
|
|
14
|
+
import fcntl
|
|
15
|
+
|
|
16
|
+
_LOCK_FILE = APPDATA_FOLDER.joinpath("qbitrr.db.lock")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _InterProcessFileLock:
|
|
20
|
+
"""Cross-process, re-entrant file lock to guard SQLite access."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, path: Path):
|
|
23
|
+
self._path = path
|
|
24
|
+
self._thread_gate = threading.RLock()
|
|
25
|
+
self._local = threading.local()
|
|
26
|
+
|
|
27
|
+
def acquire(self) -> None:
|
|
28
|
+
depth = getattr(self._local, "depth", 0)
|
|
29
|
+
if depth == 0:
|
|
30
|
+
self._thread_gate.acquire()
|
|
31
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
handle = open(self._path, "a+b")
|
|
33
|
+
try:
|
|
34
|
+
if os.name == "nt": # pragma: no cover - Windows specific branch
|
|
35
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
36
|
+
else: # pragma: no cover - POSIX branch
|
|
37
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
38
|
+
except Exception:
|
|
39
|
+
handle.close()
|
|
40
|
+
self._thread_gate.release()
|
|
41
|
+
raise
|
|
42
|
+
self._local.handle = handle
|
|
43
|
+
self._local.depth = depth + 1
|
|
44
|
+
|
|
45
|
+
def release(self) -> None:
|
|
46
|
+
depth = getattr(self._local, "depth", 0)
|
|
47
|
+
if depth <= 0:
|
|
48
|
+
raise RuntimeError("Attempted to release an unacquired database lock")
|
|
49
|
+
depth -= 1
|
|
50
|
+
if depth == 0:
|
|
51
|
+
handle = getattr(self._local, "handle")
|
|
52
|
+
try:
|
|
53
|
+
if os.name == "nt": # pragma: no cover
|
|
54
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
55
|
+
else: # pragma: no cover
|
|
56
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
57
|
+
finally:
|
|
58
|
+
handle.close()
|
|
59
|
+
del self._local.handle
|
|
60
|
+
self._thread_gate.release()
|
|
61
|
+
self._local.depth = depth
|
|
62
|
+
|
|
63
|
+
@contextmanager
|
|
64
|
+
def context(self) -> Iterator[None]:
|
|
65
|
+
self.acquire()
|
|
66
|
+
try:
|
|
67
|
+
yield
|
|
68
|
+
finally:
|
|
69
|
+
self.release()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_DB_LOCK = _InterProcessFileLock(_LOCK_FILE)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@contextmanager
|
|
76
|
+
def database_lock() -> Iterator[None]:
|
|
77
|
+
"""Provide a shared lock used to serialize SQLite access across processes."""
|
|
78
|
+
with _DB_LOCK.context():
|
|
79
|
+
yield
|
qBitrr/env_config.py
CHANGED
|
@@ -1,21 +1,32 @@
|
|
|
1
|
-
from
|
|
2
|
-
from typing import Optional
|
|
1
|
+
from __future__ import annotations
|
|
3
2
|
|
|
4
3
|
import environ
|
|
5
4
|
|
|
6
5
|
|
|
6
|
+
def _strtobool(value: str) -> int:
|
|
7
|
+
"""Return 1 for truthy strings and 0 for falsy strings, mirroring distutils.util.strtobool."""
|
|
8
|
+
if value is None:
|
|
9
|
+
raise ValueError("Boolean value must be a string")
|
|
10
|
+
normalized = value.strip().lower()
|
|
11
|
+
if normalized in {"y", "yes", "t", "true", "on", "1"}:
|
|
12
|
+
return 1
|
|
13
|
+
if normalized in {"n", "no", "f", "false", "off", "0"}:
|
|
14
|
+
return 0
|
|
15
|
+
raise ValueError(f"Invalid truth value {value!r}")
|
|
16
|
+
|
|
17
|
+
|
|
7
18
|
class Converter:
|
|
8
19
|
@staticmethod
|
|
9
|
-
def int(value:
|
|
20
|
+
def int(value: str | None) -> int | None:
|
|
10
21
|
return None if value is None else int(value)
|
|
11
22
|
|
|
12
23
|
@staticmethod
|
|
13
|
-
def list(value:
|
|
24
|
+
def list(value: str | None, delimiter=",", converter=str) -> list | None:
|
|
14
25
|
return None if value is None else list(map(converter, value.split(delimiter)))
|
|
15
26
|
|
|
16
27
|
@staticmethod
|
|
17
|
-
def bool(value:
|
|
18
|
-
return None if value is None else
|
|
28
|
+
def bool(value: str | None) -> bool | None:
|
|
29
|
+
return None if value is None else _strtobool(value) == 1
|
|
19
30
|
|
|
20
31
|
|
|
21
32
|
@environ.config(prefix="QBITRR", frozen=True)
|
|
@@ -43,6 +54,8 @@ class AppConfig:
|
|
|
43
54
|
ignore_torrents_younger_than = environ.var(None, converter=Converter.int)
|
|
44
55
|
ping_urls = environ.var(None, converter=Converter.list)
|
|
45
56
|
ffprobe_auto_update = environ.var(None, converter=Converter.bool)
|
|
57
|
+
auto_update_enabled = environ.var(None, converter=Converter.bool)
|
|
58
|
+
auto_update_cron = environ.var(None)
|
|
46
59
|
|
|
47
60
|
@environ.config(prefix="QBIT", frozen=True)
|
|
48
61
|
class qBit:
|
|
@@ -51,7 +64,6 @@ class AppConfig:
|
|
|
51
64
|
port = environ.var(None, converter=Converter.int)
|
|
52
65
|
username = environ.var(None)
|
|
53
66
|
password = environ.var(None)
|
|
54
|
-
v5 = environ.var(False)
|
|
55
67
|
|
|
56
68
|
overrides: Overrides = environ.group(Overrides)
|
|
57
69
|
settings: Settings = environ.group(Settings)
|