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.
Files changed (59) hide show
  1. qBitrr/arss.py +2127 -850
  2. qBitrr/auto_update.py +382 -0
  3. qBitrr/bundled_data.py +3 -2
  4. qBitrr/config.py +20 -3
  5. qBitrr/db_lock.py +79 -0
  6. qBitrr/env_config.py +19 -7
  7. qBitrr/gen_config.py +286 -26
  8. qBitrr/logger.py +87 -3
  9. qBitrr/main.py +453 -101
  10. qBitrr/search_activity_store.py +88 -0
  11. qBitrr/static/assets/ArrView.js +2 -0
  12. qBitrr/static/assets/ArrView.js.map +1 -0
  13. qBitrr/static/assets/ConfigView.js +4 -0
  14. qBitrr/static/assets/ConfigView.js.map +1 -0
  15. qBitrr/static/assets/LogsView.js +230 -0
  16. qBitrr/static/assets/LogsView.js.map +1 -0
  17. qBitrr/static/assets/ProcessesView.js +2 -0
  18. qBitrr/static/assets/ProcessesView.js.map +1 -0
  19. qBitrr/static/assets/app.css +1 -0
  20. qBitrr/static/assets/app.js +11 -0
  21. qBitrr/static/assets/app.js.map +1 -0
  22. qBitrr/static/assets/build.svg +3 -0
  23. qBitrr/static/assets/check-mark.svg +5 -0
  24. qBitrr/static/assets/close.svg +4 -0
  25. qBitrr/static/assets/download.svg +5 -0
  26. qBitrr/static/assets/gear.svg +5 -0
  27. qBitrr/static/assets/lidarr.svg +1 -0
  28. qBitrr/static/assets/live-streaming.svg +8 -0
  29. qBitrr/static/assets/log.svg +3 -0
  30. qBitrr/static/assets/plus.svg +4 -0
  31. qBitrr/static/assets/process.svg +15 -0
  32. qBitrr/static/assets/react-select.esm.js +14 -0
  33. qBitrr/static/assets/react-select.esm.js.map +1 -0
  34. qBitrr/static/assets/refresh-arrow.svg +3 -0
  35. qBitrr/static/assets/table.js +23 -0
  36. qBitrr/static/assets/table.js.map +1 -0
  37. qBitrr/static/assets/trash.svg +8 -0
  38. qBitrr/static/assets/up-arrow.svg +3 -0
  39. qBitrr/static/assets/useInterval.js +2 -0
  40. qBitrr/static/assets/useInterval.js.map +1 -0
  41. qBitrr/static/assets/vendor.js +33 -0
  42. qBitrr/static/assets/vendor.js.map +1 -0
  43. qBitrr/static/assets/visibility.svg +9 -0
  44. qBitrr/static/index.html +47 -0
  45. qBitrr/static/manifest.json +23 -0
  46. qBitrr/static/sw.js +105 -0
  47. qBitrr/static/vite.svg +1 -0
  48. qBitrr/tables.py +44 -0
  49. qBitrr/utils.py +82 -15
  50. qBitrr/versioning.py +136 -0
  51. qBitrr/webui.py +2612 -0
  52. qbitrr2-5.4.5.dist-info/METADATA +1116 -0
  53. qbitrr2-5.4.5.dist-info/RECORD +61 -0
  54. {qbitrr2-4.10.15.dist-info → qbitrr2-5.4.5.dist-info}/WHEEL +1 -1
  55. qbitrr2-4.10.15.dist-info/METADATA +0 -239
  56. qbitrr2-4.10.15.dist-info/RECORD +0 -19
  57. {qbitrr2-4.10.15.dist-info → qbitrr2-5.4.5.dist-info}/entry_points.txt +0 -0
  58. {qbitrr2-4.10.15.dist-info → qbitrr2-5.4.5.dist-info/licenses}/LICENSE +0 -0
  59. {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.10.15"
2
- git_hash = "5164158"
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"'{CONFIG_FILE.name}' has been generated")
88
- print('Rename it to "config.toml" then edit it and restart the container')
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 distutils.util import strtobool
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: Optional[str]) -> Optional[int]:
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: Optional[str], delimiter=",", converter=str) -> Optional[list]:
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: Optional[str]) -> Optional[bool]:
18
- return None if value is None else strtobool(value) == 1
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)