qBitrr2 5.5.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 (64) hide show
  1. qBitrr/__init__.py +14 -0
  2. qBitrr/arss.py +7100 -0
  3. qBitrr/auto_update.py +382 -0
  4. qBitrr/bundled_data.py +7 -0
  5. qBitrr/config.py +192 -0
  6. qBitrr/config_version.py +144 -0
  7. qBitrr/db_lock.py +400 -0
  8. qBitrr/db_recovery.py +202 -0
  9. qBitrr/env_config.py +73 -0
  10. qBitrr/errors.py +41 -0
  11. qBitrr/ffprobe.py +105 -0
  12. qBitrr/gen_config.py +1331 -0
  13. qBitrr/home_path.py +23 -0
  14. qBitrr/logger.py +235 -0
  15. qBitrr/main.py +790 -0
  16. qBitrr/search_activity_store.py +92 -0
  17. qBitrr/static/assets/ArrView.js +2 -0
  18. qBitrr/static/assets/ArrView.js.map +1 -0
  19. qBitrr/static/assets/ConfigView.js +4 -0
  20. qBitrr/static/assets/ConfigView.js.map +1 -0
  21. qBitrr/static/assets/LogsView.js +2 -0
  22. qBitrr/static/assets/LogsView.js.map +1 -0
  23. qBitrr/static/assets/ProcessesView.js +2 -0
  24. qBitrr/static/assets/ProcessesView.js.map +1 -0
  25. qBitrr/static/assets/app.css +1 -0
  26. qBitrr/static/assets/app.js +11 -0
  27. qBitrr/static/assets/app.js.map +1 -0
  28. qBitrr/static/assets/build.svg +3 -0
  29. qBitrr/static/assets/check-mark.svg +5 -0
  30. qBitrr/static/assets/close.svg +4 -0
  31. qBitrr/static/assets/download.svg +5 -0
  32. qBitrr/static/assets/gear.svg +5 -0
  33. qBitrr/static/assets/live-streaming.svg +8 -0
  34. qBitrr/static/assets/log.svg +3 -0
  35. qBitrr/static/assets/logo.svg +48 -0
  36. qBitrr/static/assets/plus.svg +4 -0
  37. qBitrr/static/assets/process.svg +15 -0
  38. qBitrr/static/assets/react-select.esm.js +7 -0
  39. qBitrr/static/assets/react-select.esm.js.map +1 -0
  40. qBitrr/static/assets/refresh-arrow.svg +3 -0
  41. qBitrr/static/assets/table.js +5 -0
  42. qBitrr/static/assets/table.js.map +1 -0
  43. qBitrr/static/assets/trash.svg +8 -0
  44. qBitrr/static/assets/up-arrow.svg +3 -0
  45. qBitrr/static/assets/useInterval.js +2 -0
  46. qBitrr/static/assets/useInterval.js.map +1 -0
  47. qBitrr/static/assets/vendor.js +2 -0
  48. qBitrr/static/assets/vendor.js.map +1 -0
  49. qBitrr/static/assets/visibility.svg +9 -0
  50. qBitrr/static/index.html +33 -0
  51. qBitrr/static/logov2-clean.svg +48 -0
  52. qBitrr/static/manifest.json +23 -0
  53. qBitrr/static/sw.js +87 -0
  54. qBitrr/static/vite.svg +1 -0
  55. qBitrr/tables.py +143 -0
  56. qBitrr/utils.py +274 -0
  57. qBitrr/versioning.py +136 -0
  58. qBitrr/webui.py +3114 -0
  59. qbitrr2-5.5.5.dist-info/METADATA +1191 -0
  60. qbitrr2-5.5.5.dist-info/RECORD +64 -0
  61. qbitrr2-5.5.5.dist-info/WHEEL +5 -0
  62. qbitrr2-5.5.5.dist-info/entry_points.txt +2 -0
  63. qbitrr2-5.5.5.dist-info/licenses/LICENSE +21 -0
  64. qbitrr2-5.5.5.dist-info/top_level.txt +1 -0
qBitrr/db_recovery.py ADDED
@@ -0,0 +1,202 @@
1
+ """SQLite database recovery utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import shutil
7
+ import sqlite3
8
+ from pathlib import Path
9
+
10
+ logger = logging.getLogger("qBitrr.DBRecovery")
11
+
12
+
13
+ class DatabaseRecoveryError(Exception):
14
+ """Raised when database recovery fails."""
15
+
16
+
17
+ def checkpoint_wal(db_path: Path, logger_override=None) -> bool:
18
+ """
19
+ Force checkpoint of WAL file to main database.
20
+
21
+ This operation flushes all Write-Ahead Log entries to the main database
22
+ file, which can resolve certain types of corruption and reduce the risk
23
+ of data loss.
24
+
25
+ Args:
26
+ db_path: Path to SQLite database file
27
+ logger_override: Optional logger instance to use instead of module logger
28
+
29
+ Returns:
30
+ True if successful, False otherwise
31
+ """
32
+ log = logger_override or logger
33
+
34
+ try:
35
+ log.info("Starting WAL checkpoint for database: %s", db_path)
36
+ conn = sqlite3.connect(str(db_path), timeout=10.0)
37
+ cursor = conn.cursor()
38
+
39
+ # Force WAL checkpoint with TRUNCATE mode
40
+ # This checkpoints all frames and truncates the WAL file
41
+ cursor.execute("PRAGMA wal_checkpoint(TRUNCATE)")
42
+ result = cursor.fetchone()
43
+
44
+ conn.close()
45
+
46
+ # Result is (busy, log_pages, checkpointed_pages)
47
+ # If busy=0, checkpoint was fully successful
48
+ if result and result[0] == 0:
49
+ log.info(
50
+ "WAL checkpoint successful: %s frames checkpointed, %s pages in log",
51
+ result[2],
52
+ result[1],
53
+ )
54
+ return True
55
+ else:
56
+ log.warning(
57
+ "WAL checkpoint partially successful: result=%s (database may be busy)",
58
+ result,
59
+ )
60
+ return True # Still consider partial success as success
61
+
62
+ except sqlite3.OperationalError as e:
63
+ log.error("WAL checkpoint failed (OperationalError): %s", e)
64
+ return False
65
+ except Exception as e:
66
+ log.error("WAL checkpoint failed (unexpected error): %s", e)
67
+ return False
68
+
69
+
70
+ def repair_database(db_path: Path, backup: bool = True, logger_override=None) -> bool:
71
+ """
72
+ Attempt to repair corrupted SQLite database via dump/restore.
73
+
74
+ This operation:
75
+ 1. Creates a backup of the corrupted database
76
+ 2. Dumps all recoverable data to a temporary database
77
+ 3. Replaces the original with the repaired copy
78
+ 4. Verifies integrity of the repaired database
79
+
80
+ WARNING: Some data may be lost if corruption is severe.
81
+
82
+ Args:
83
+ db_path: Path to SQLite database file
84
+ backup: Whether to create backup before repair (default: True)
85
+ logger_override: Optional logger instance to use instead of module logger
86
+
87
+ Returns:
88
+ True if repair successful, False otherwise
89
+
90
+ Raises:
91
+ DatabaseRecoveryError: If repair fails critically
92
+ """
93
+ log = logger_override or logger
94
+
95
+ backup_path = db_path.with_suffix(".db.backup")
96
+ temp_path = db_path.with_suffix(".db.temp")
97
+
98
+ try:
99
+ # Step 1: Backup original
100
+ if backup:
101
+ log.info("Creating backup: %s", backup_path)
102
+ shutil.copy2(db_path, backup_path)
103
+
104
+ # Step 2: Dump recoverable data
105
+ log.info("Dumping recoverable data from corrupted database...")
106
+ source_conn = sqlite3.connect(str(db_path))
107
+
108
+ temp_conn = sqlite3.connect(str(temp_path))
109
+
110
+ # Dump schema and data
111
+ skipped_rows = 0
112
+ for line in source_conn.iterdump():
113
+ try:
114
+ temp_conn.execute(line)
115
+ except sqlite3.Error as e:
116
+ # Log but continue - recover what we can
117
+ skipped_rows += 1
118
+ log.debug("Skipping corrupted row during dump: %s", e)
119
+
120
+ if skipped_rows > 0:
121
+ log.warning("Skipped %s corrupted rows during dump", skipped_rows)
122
+
123
+ temp_conn.commit()
124
+ temp_conn.close()
125
+ source_conn.close()
126
+
127
+ # Step 3: Replace original with repaired copy
128
+ log.info("Replacing database with repaired version...")
129
+ db_path.unlink()
130
+ shutil.move(str(temp_path), str(db_path))
131
+
132
+ # Step 4: Verify integrity
133
+ log.info("Verifying integrity of repaired database...")
134
+ verify_conn = sqlite3.connect(str(db_path))
135
+ cursor = verify_conn.cursor()
136
+ cursor.execute("PRAGMA integrity_check")
137
+ result = cursor.fetchone()[0]
138
+ verify_conn.close()
139
+
140
+ if result != "ok":
141
+ raise DatabaseRecoveryError(f"Repair verification failed: {result}")
142
+
143
+ log.info("Database repair successful!")
144
+ return True
145
+
146
+ except Exception as e:
147
+ log.error("Database repair failed: %s", e)
148
+
149
+ # Attempt to restore backup
150
+ if backup and backup_path.exists():
151
+ log.warning("Restoring from backup...")
152
+ try:
153
+ shutil.copy2(backup_path, db_path)
154
+ log.info("Backup restored successfully")
155
+ except Exception as restore_error:
156
+ log.error("Failed to restore backup: %s", restore_error)
157
+
158
+ # Cleanup temp files
159
+ if temp_path.exists():
160
+ try:
161
+ temp_path.unlink()
162
+ except Exception:
163
+ pass # Best effort cleanup
164
+
165
+ raise DatabaseRecoveryError(f"Repair failed: {e}") from e
166
+
167
+
168
+ def vacuum_database(db_path: Path, logger_override=None) -> bool:
169
+ """
170
+ Run VACUUM to reclaim space and optimize database.
171
+
172
+ VACUUM rebuilds the database file, repacking it into a minimal amount of
173
+ disk space. This can help resolve some types of corruption and improve
174
+ performance.
175
+
176
+ Note: VACUUM requires free disk space approximately 2x the database size.
177
+
178
+ Args:
179
+ db_path: Path to SQLite database file
180
+ logger_override: Optional logger instance to use instead of module logger
181
+
182
+ Returns:
183
+ True if successful, False otherwise
184
+ """
185
+ log = logger_override or logger
186
+
187
+ try:
188
+ log.info("Running VACUUM on database: %s", db_path)
189
+ conn = sqlite3.connect(str(db_path), timeout=30.0)
190
+
191
+ conn.execute("VACUUM")
192
+ conn.close()
193
+
194
+ log.info("VACUUM completed successfully")
195
+ return True
196
+
197
+ except sqlite3.OperationalError as e:
198
+ log.error("VACUUM failed (OperationalError): %s", e)
199
+ return False
200
+ except Exception as e:
201
+ log.error("VACUUM failed (unexpected error): %s", e)
202
+ return False
qBitrr/env_config.py ADDED
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import environ
4
+
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
+
18
+ class Converter:
19
+ @staticmethod
20
+ def int(value: str | None) -> int | None:
21
+ return None if value is None else int(value)
22
+
23
+ @staticmethod
24
+ def list(value: str | None, delimiter=",", converter=str) -> list | None:
25
+ return None if value is None else list(map(converter, value.split(delimiter)))
26
+
27
+ @staticmethod
28
+ def bool(value: str | None) -> bool | None:
29
+ return None if value is None else _strtobool(value) == 1
30
+
31
+
32
+ @environ.config(prefix="QBITRR", frozen=True)
33
+ class AppConfig:
34
+ @environ.config(prefix="OVERRIDES", frozen=True)
35
+ class Overrides:
36
+ search_only = environ.var(None, converter=Converter.bool)
37
+ processing_only = environ.var(None, converter=Converter.bool)
38
+ data_path = environ.var(None)
39
+
40
+ @environ.config(prefix="SETTINGS", frozen=True)
41
+ class Settings:
42
+ console_level = environ.var(None)
43
+ logging = environ.var(None, converter=Converter.bool)
44
+ completed_download_folder = environ.var(None)
45
+ free_space = environ.var(None)
46
+ free_space_folder = environ.var(None)
47
+ no_internet_sleep_timer = environ.var(None, converter=Converter.int)
48
+ loop_sleep_timer = environ.var(None, converter=Converter.int)
49
+ search_loop_delay = environ.var(None, converter=Converter.int)
50
+ auto_pause_resume = environ.var(None, converter=Converter.bool)
51
+ failed_category = environ.var(None)
52
+ recheck_category = environ.var(None)
53
+ tagless = environ.var(None, converter=Converter.bool)
54
+ ignore_torrents_younger_than = environ.var(None, converter=Converter.int)
55
+ ping_urls = environ.var(None, converter=Converter.list)
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)
59
+
60
+ @environ.config(prefix="QBIT", frozen=True)
61
+ class qBit:
62
+ disabled = environ.var(None, converter=Converter.bool)
63
+ host = environ.var(None)
64
+ port = environ.var(None, converter=Converter.int)
65
+ username = environ.var(None)
66
+ password = environ.var(None)
67
+
68
+ overrides: Overrides = environ.group(Overrides)
69
+ settings: Settings = environ.group(Settings)
70
+ qbit: qBit = environ.group(qBit)
71
+
72
+
73
+ ENVIRO_CONFIG: AppConfig = environ.to_config(AppConfig)
qBitrr/errors.py ADDED
@@ -0,0 +1,41 @@
1
+ class qBitManagerError(Exception):
2
+ """Base Exception"""
3
+
4
+
5
+ class UnhandledError(qBitManagerError):
6
+ """Use to raise when there an unhandled edge case"""
7
+
8
+
9
+ class ConfigException(qBitManagerError):
10
+ """Base Exception for Config related exceptions"""
11
+
12
+
13
+ class ArrManagerException(qBitManagerError):
14
+ """Base Exception for Arr related Exceptions"""
15
+
16
+
17
+ class SkipException(qBitManagerError):
18
+ """Dummy error to skip actions"""
19
+
20
+
21
+ class RequireConfigValue(qBitManagerError):
22
+ """Exception raised when a config value requires a value."""
23
+
24
+ def __init__(self, config_class: str, config_key: str):
25
+ self.message = f"Config key '{config_key}' in '{config_class}' requires a value."
26
+
27
+
28
+ class NoConnectionrException(qBitManagerError):
29
+ def __init__(self, message: str, type: str = "delay"):
30
+ self.message = message
31
+ self.type = type
32
+
33
+
34
+ class DelayLoopException(qBitManagerError):
35
+ def __init__(self, length: int, type: str):
36
+ self.type = type
37
+ self.length = length
38
+
39
+
40
+ class RestartLoopException(ArrManagerException):
41
+ """Exception to trigger a loop restart"""
qBitrr/ffprobe.py ADDED
@@ -0,0 +1,105 @@
1
+ import io
2
+ import json
3
+ import logging
4
+ import os
5
+ import platform
6
+ import sys
7
+ import zipfile
8
+
9
+ import requests
10
+
11
+ from qBitrr.config import FF_PROBE, FF_VERSION, FFPROBE_AUTO_UPDATE
12
+ from qBitrr.logger import run_logs
13
+
14
+
15
+ class FFprobeDownloader:
16
+ def __init__(self):
17
+ self.api = "https://ffbinaries.com/api/v1/version/latest"
18
+ self.version_file = FF_VERSION
19
+ self.logger = logging.getLogger("qBitrr.FFprobe")
20
+ run_logs(self.logger)
21
+ self.platform = platform.system()
22
+ if self.platform == "Windows":
23
+ self.probe_path = FF_PROBE.with_suffix(".exe")
24
+ else:
25
+ self.probe_path = FF_PROBE
26
+
27
+ def get_upstream_version(self) -> dict:
28
+ with requests.Session() as session:
29
+ with session.get(self.api) as response:
30
+ if response.status_code != 200:
31
+ self.logger.warning("Failed to retrieve ffprobe version from API.")
32
+ return {}
33
+ return response.json()
34
+
35
+ def get_current_version(self):
36
+ try:
37
+ with self.version_file.open(mode="r") as file:
38
+ data = json.load(file)
39
+ return data.get("version")
40
+ except Exception: # If file can't be found or read or parsed
41
+ self.logger.warning("Failed to retrieve current ffprobe version.")
42
+ return ""
43
+
44
+ def update(self):
45
+ if not FFPROBE_AUTO_UPDATE:
46
+ return
47
+ current_version = self.get_current_version()
48
+ upstream_data = self.get_upstream_version()
49
+ upstream_version = upstream_data.get("version")
50
+ if upstream_version is None:
51
+ self.logger.debug(
52
+ "Failed to retrieve ffprobe version from API.'upstream_version' is None"
53
+ )
54
+ return
55
+ probe_file_exists = self.probe_path.exists()
56
+ if current_version == upstream_version and probe_file_exists:
57
+ self.logger.debug("Current FFprobe is up to date.")
58
+ return
59
+ arch_key = self.get_arch()
60
+ urls = upstream_data.get("bin", {}).get(arch_key)
61
+ if urls is None:
62
+ self.logger.debug("Failed to retrieve ffprobe version from API.'urls' is None")
63
+ return
64
+ ffprobe_url = urls.get("ffprobe")
65
+ self.logger.debug("Downloading newer FFprobe: %s", ffprobe_url)
66
+ self.download_and_extract(ffprobe_url)
67
+ self.logger.debug("Updating local version of FFprobe: %s", upstream_version)
68
+ self.version_file.write_text(json.dumps({"version": upstream_version}))
69
+ try:
70
+ os.chmod(self.probe_path, 0o777)
71
+ self.logger.debug("Successfully changed permissions for ffprobe")
72
+ except Exception as e:
73
+ self.logger.debug("Failed to change permissions for ffprobe, %s", e)
74
+
75
+ def download_and_extract(self, ffprobe_url):
76
+ r = requests.get(ffprobe_url)
77
+ z = zipfile.ZipFile(io.BytesIO(r.content))
78
+ self.logger.debug("Extracting downloaded FFprobe to: %s", FF_PROBE.parent)
79
+ z.extract(member=self.probe_path.name, path=FF_PROBE.parent)
80
+
81
+ def get_arch(self):
82
+ part1 = None
83
+ is_64bits = sys.maxsize > 2**32
84
+ part2 = "64" if is_64bits else "32"
85
+ if self.platform == "Windows":
86
+ part1 = "windows-"
87
+ elif self.platform == "Linux":
88
+ part1 = "linux-"
89
+ machine = platform.machine()
90
+ if machine == "armv6l":
91
+ part2 = "armhf"
92
+ elif ("arm" in machine and is_64bits) or machine == "aarch64":
93
+ part2 = "arm64"
94
+ # Else just 32/64, Not armel - because just no
95
+ elif self.platform == "Darwin":
96
+ part1 = "osx-"
97
+ part2 = "64"
98
+ if part1 is None:
99
+ raise RuntimeError(
100
+ "You are running in an unsupported platform, "
101
+ "if you expect this to be supported please open an issue on GitHub "
102
+ "https://github.com/Feramance/qBitrr."
103
+ )
104
+
105
+ return part1 + part2