qBitrr2 5.6.1__py3-none-any.whl → 5.7.0__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 +390 -89
- qBitrr/bundled_data.py +2 -2
- qBitrr/db_lock.py +24 -10
- qBitrr/main.py +232 -10
- qBitrr/static/assets/ArrView.js +1 -1
- qBitrr/static/assets/ArrView.js.map +1 -1
- qBitrr/static/assets/ConfigView.js +5 -4
- qBitrr/static/assets/ConfigView.js.map +1 -1
- qBitrr/static/assets/LogsView.js +1 -1
- qBitrr/static/assets/LogsView.js.map +1 -1
- qBitrr/static/assets/ProcessesView.js +1 -1
- qBitrr/static/assets/ProcessesView.js.map +1 -1
- qBitrr/static/assets/app.css +1 -1
- qBitrr/static/assets/app.js +5 -5
- qBitrr/static/assets/app.js.map +1 -1
- qBitrr/static/assets/vendor.js +1 -1
- qBitrr/static/assets/vendor.js.map +1 -1
- qBitrr/tables.py +7 -0
- qBitrr/webui.py +48 -1
- qbitrr2-5.7.0.dist-info/METADATA +282 -0
- {qbitrr2-5.6.1.dist-info → qbitrr2-5.7.0.dist-info}/RECORD +25 -25
- qbitrr2-5.6.1.dist-info/METADATA +0 -1210
- {qbitrr2-5.6.1.dist-info → qbitrr2-5.7.0.dist-info}/WHEEL +0 -0
- {qbitrr2-5.6.1.dist-info → qbitrr2-5.7.0.dist-info}/entry_points.txt +0 -0
- {qbitrr2-5.6.1.dist-info → qbitrr2-5.7.0.dist-info}/licenses/LICENSE +0 -0
- {qbitrr2-5.6.1.dist-info → qbitrr2-5.7.0.dist-info}/top_level.txt +0 -0
qBitrr/bundled_data.py
CHANGED
qBitrr/db_lock.py
CHANGED
|
@@ -133,18 +133,27 @@ def with_database_retry(
|
|
|
133
133
|
if "syntax" in error_msg or "constraint" in error_msg:
|
|
134
134
|
raise
|
|
135
135
|
|
|
136
|
-
# Detect corruption and attempt recovery (only once)
|
|
136
|
+
# Detect corruption or persistent I/O errors and attempt recovery (only once)
|
|
137
137
|
if not corruption_recovery_attempted and (
|
|
138
138
|
"disk image is malformed" in error_msg
|
|
139
139
|
or "database disk image is malformed" in error_msg
|
|
140
140
|
or "database corruption" in error_msg
|
|
141
|
+
or "disk i/o error" in error_msg
|
|
141
142
|
):
|
|
142
143
|
corruption_recovery_attempted = True
|
|
143
144
|
if logger:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
if "disk i/o error" in error_msg:
|
|
146
|
+
logger.error(
|
|
147
|
+
"Persistent database I/O error detected: %s. "
|
|
148
|
+
"This may indicate disk issues, filesystem problems, or database corruption. "
|
|
149
|
+
"Attempting automatic recovery...",
|
|
150
|
+
e,
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
logger.error(
|
|
154
|
+
"Database corruption detected: %s. Attempting automatic recovery...",
|
|
155
|
+
e,
|
|
156
|
+
)
|
|
148
157
|
|
|
149
158
|
recovery_succeeded = False
|
|
150
159
|
try:
|
|
@@ -184,15 +193,19 @@ def with_database_retry(
|
|
|
184
193
|
# If we reach here, recovery failed - log and continue with normal retry
|
|
185
194
|
if logger:
|
|
186
195
|
logger.critical(
|
|
187
|
-
"Automatic database recovery
|
|
188
|
-
"
|
|
196
|
+
"Automatic database recovery FAILED. "
|
|
197
|
+
"Both WAL checkpoint and full database repair were unsuccessful. "
|
|
198
|
+
"This indicates serious underlying issues. "
|
|
199
|
+
"Attempting normal retry, but manual intervention will likely be required."
|
|
189
200
|
)
|
|
190
201
|
|
|
191
202
|
attempt += 1
|
|
192
203
|
if attempt >= retries:
|
|
193
204
|
if logger:
|
|
194
|
-
logger.
|
|
195
|
-
"Database operation
|
|
205
|
+
logger.critical(
|
|
206
|
+
"Database operation EXHAUSTED %s retry attempts. "
|
|
207
|
+
"Error: %s. "
|
|
208
|
+
"This will be re-raised to the calling code for handling.",
|
|
196
209
|
retries,
|
|
197
210
|
e,
|
|
198
211
|
)
|
|
@@ -264,11 +277,12 @@ class ResilientSqliteDatabase:
|
|
|
264
277
|
except (OperationalError, DatabaseError, sqlite3.OperationalError) as e:
|
|
265
278
|
error_msg = str(e).lower()
|
|
266
279
|
|
|
267
|
-
# Detect corruption and attempt recovery (only once)
|
|
280
|
+
# Detect corruption or persistent I/O errors and attempt recovery (only once)
|
|
268
281
|
if not corruption_recovery_attempted and (
|
|
269
282
|
"disk image is malformed" in error_msg
|
|
270
283
|
or "database disk image is malformed" in error_msg
|
|
271
284
|
or "database corruption" in error_msg
|
|
285
|
+
or "disk i/o error" in error_msg
|
|
272
286
|
):
|
|
273
287
|
corruption_recovery_attempted = True
|
|
274
288
|
if self._logger:
|
qBitrr/main.py
CHANGED
|
@@ -102,6 +102,11 @@ class qBitManager:
|
|
|
102
102
|
self._validated_version = False
|
|
103
103
|
self.client = None
|
|
104
104
|
self.current_qbit_version = None
|
|
105
|
+
# Multi-instance support
|
|
106
|
+
self.clients: dict[str, qbittorrentapi.Client] = {}
|
|
107
|
+
self.qbit_versions: dict[str, VersionClass] = {}
|
|
108
|
+
self.instance_metadata: dict[str, dict] = {}
|
|
109
|
+
self.instance_health: dict[str, bool] = {}
|
|
105
110
|
if not (QBIT_DISABLED or SEARCH_ONLY):
|
|
106
111
|
self.client = qbittorrentapi.Client(
|
|
107
112
|
host=self.qBit_Host,
|
|
@@ -120,6 +125,15 @@ class qBitManager:
|
|
|
120
125
|
e,
|
|
121
126
|
)
|
|
122
127
|
self._version_validator()
|
|
128
|
+
# Register default instance in multi-instance dictionaries
|
|
129
|
+
self.clients["default"] = self.client
|
|
130
|
+
self.qbit_versions["default"] = self.current_qbit_version
|
|
131
|
+
self.instance_metadata["default"] = {
|
|
132
|
+
"host": self.qBit_Host,
|
|
133
|
+
"port": self.qBit_Port,
|
|
134
|
+
"username": self.qBit_UserName,
|
|
135
|
+
}
|
|
136
|
+
self.instance_health["default"] = self._validated_version
|
|
123
137
|
self.expiring_bool = ExpiringSet(max_age_seconds=10)
|
|
124
138
|
self.cache = {}
|
|
125
139
|
self.name_cache = {}
|
|
@@ -383,6 +397,8 @@ class qBitManager:
|
|
|
383
397
|
def _complete_startup(self) -> None:
|
|
384
398
|
started_at = monotonic()
|
|
385
399
|
try:
|
|
400
|
+
# Initialize all qBit instances before Arr managers
|
|
401
|
+
self._initialize_qbit_instances()
|
|
386
402
|
arr_manager = ArrManager(self)
|
|
387
403
|
self.arr_manager = arr_manager
|
|
388
404
|
arr_manager.build_arr_instances()
|
|
@@ -427,10 +443,195 @@ class qBitManager:
|
|
|
427
443
|
)
|
|
428
444
|
sys.exit(1)
|
|
429
445
|
|
|
446
|
+
def _initialize_qbit_instances(self) -> None:
|
|
447
|
+
"""
|
|
448
|
+
Initialize all qBittorrent instances from config.
|
|
449
|
+
|
|
450
|
+
Scans config for [qBit] and [qBit-XXX] sections, initializes clients,
|
|
451
|
+
and populates multi-instance dictionaries. The default [qBit] section
|
|
452
|
+
is registered as "default" instance.
|
|
453
|
+
"""
|
|
454
|
+
if QBIT_DISABLED or SEARCH_ONLY:
|
|
455
|
+
self.logger.debug("qBit disabled or search-only mode; skipping instance init")
|
|
456
|
+
return
|
|
457
|
+
|
|
458
|
+
# Default instance already initialized in __init__
|
|
459
|
+
self.logger.info("Initialized qBit instance: default")
|
|
460
|
+
|
|
461
|
+
# Scan for additional instances (qBit-XXX sections)
|
|
462
|
+
for section in CONFIG.sections():
|
|
463
|
+
if section.startswith("qBit-") and section != "qBit":
|
|
464
|
+
instance_name = section.replace("qBit-", "", 1)
|
|
465
|
+
try:
|
|
466
|
+
self._init_instance(section, instance_name)
|
|
467
|
+
self.logger.info("Initialized qBit instance: %s", instance_name)
|
|
468
|
+
except Exception as e:
|
|
469
|
+
self.logger.error(
|
|
470
|
+
"Failed to initialize qBit instance '%s': %s", instance_name, e
|
|
471
|
+
)
|
|
472
|
+
self.instance_health[instance_name] = False
|
|
473
|
+
|
|
474
|
+
self.logger.info("Total qBit instances initialized: %d", len(self.clients))
|
|
475
|
+
|
|
476
|
+
def _init_instance(self, section_name: str, instance_name: str) -> None:
|
|
477
|
+
"""
|
|
478
|
+
Initialize a single qBittorrent instance.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
section_name: Config section name (e.g., "qBit-Seedbox")
|
|
482
|
+
instance_name: Short instance identifier (e.g., "Seedbox")
|
|
483
|
+
|
|
484
|
+
Raises:
|
|
485
|
+
Exception: If connection fails or version is unsupported
|
|
486
|
+
"""
|
|
487
|
+
host = CONFIG.get(f"{section_name}.Host", fallback="localhost")
|
|
488
|
+
port = CONFIG.get(f"{section_name}.Port", fallback=8105)
|
|
489
|
+
username = CONFIG.get(f"{section_name}.UserName", fallback=None)
|
|
490
|
+
password = CONFIG.get(f"{section_name}.Password", fallback=None)
|
|
491
|
+
|
|
492
|
+
self.logger.debug(
|
|
493
|
+
"Connecting to qBit instance '%s': %s:%s (user: %s)",
|
|
494
|
+
instance_name,
|
|
495
|
+
host,
|
|
496
|
+
port,
|
|
497
|
+
username,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
client = qbittorrentapi.Client(
|
|
501
|
+
host=host,
|
|
502
|
+
port=port,
|
|
503
|
+
username=username,
|
|
504
|
+
password=password,
|
|
505
|
+
SIMPLE_RESPONSES=False,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# Test connection and get version
|
|
509
|
+
try:
|
|
510
|
+
version = version_parser.parse(client.app_version())
|
|
511
|
+
self.logger.debug("Instance '%s' version: %s", instance_name, version)
|
|
512
|
+
except Exception as e:
|
|
513
|
+
self.logger.error(
|
|
514
|
+
"Could not connect to qBit instance '%s' at %s:%s: %s",
|
|
515
|
+
instance_name,
|
|
516
|
+
host,
|
|
517
|
+
port,
|
|
518
|
+
e,
|
|
519
|
+
)
|
|
520
|
+
raise
|
|
521
|
+
|
|
522
|
+
# Validate version
|
|
523
|
+
if version < self.min_supported_version:
|
|
524
|
+
self.logger.critical(
|
|
525
|
+
"Instance '%s' version %s is below minimum supported %s",
|
|
526
|
+
instance_name,
|
|
527
|
+
version,
|
|
528
|
+
self.min_supported_version,
|
|
529
|
+
)
|
|
530
|
+
raise ValueError(
|
|
531
|
+
f"Unsupported qBittorrent version {version} for instance {instance_name}"
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# Register instance
|
|
535
|
+
self.clients[instance_name] = client
|
|
536
|
+
self.qbit_versions[instance_name] = version
|
|
537
|
+
self.instance_metadata[instance_name] = {
|
|
538
|
+
"host": host,
|
|
539
|
+
"port": port,
|
|
540
|
+
"username": username,
|
|
541
|
+
}
|
|
542
|
+
self.instance_health[instance_name] = True
|
|
543
|
+
|
|
544
|
+
def is_instance_alive(self, instance_name: str = "default") -> bool:
|
|
545
|
+
"""
|
|
546
|
+
Check if a specific qBittorrent instance is alive and responding.
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
instance_name: The instance identifier (default: "default")
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
bool: True if instance is healthy and responding, False otherwise
|
|
553
|
+
"""
|
|
554
|
+
if instance_name not in self.clients:
|
|
555
|
+
self.logger.warning("Instance '%s' not found in clients", instance_name)
|
|
556
|
+
return False
|
|
557
|
+
|
|
558
|
+
client = self.clients[instance_name]
|
|
559
|
+
if client is None:
|
|
560
|
+
return False
|
|
561
|
+
|
|
562
|
+
try:
|
|
563
|
+
# Quick health check - just get app version
|
|
564
|
+
client.app_version()
|
|
565
|
+
self.instance_health[instance_name] = True
|
|
566
|
+
return True
|
|
567
|
+
except Exception as e:
|
|
568
|
+
self.logger.debug("Instance '%s' health check failed: %s", instance_name, e)
|
|
569
|
+
self.instance_health[instance_name] = False
|
|
570
|
+
return False
|
|
571
|
+
|
|
572
|
+
def get_all_instances(self) -> list[str]:
|
|
573
|
+
"""
|
|
574
|
+
Get list of all configured qBittorrent instance names.
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
list[str]: List of instance identifiers (e.g., ["default", "Seedbox"])
|
|
578
|
+
"""
|
|
579
|
+
return list(self.clients.keys())
|
|
580
|
+
|
|
581
|
+
def get_healthy_instances(self) -> list[str]:
|
|
582
|
+
"""
|
|
583
|
+
Get list of all healthy (responding) qBittorrent instances.
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
list[str]: List of healthy instance identifiers
|
|
587
|
+
"""
|
|
588
|
+
return [name for name in self.clients.keys() if self.is_instance_alive(name)]
|
|
589
|
+
|
|
590
|
+
def get_instance_info(self, instance_name: str = "default") -> dict:
|
|
591
|
+
"""
|
|
592
|
+
Get metadata about a specific qBittorrent instance.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
instance_name: The instance identifier (default: "default")
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
dict: Instance metadata including host, port, version, health status
|
|
599
|
+
"""
|
|
600
|
+
if instance_name not in self.clients:
|
|
601
|
+
return {"error": f"Instance '{instance_name}' not found"}
|
|
602
|
+
|
|
603
|
+
metadata = self.instance_metadata.get(instance_name, {})
|
|
604
|
+
return {
|
|
605
|
+
"name": instance_name,
|
|
606
|
+
"host": metadata.get("host"),
|
|
607
|
+
"port": metadata.get("port"),
|
|
608
|
+
"version": str(self.qbit_versions.get(instance_name, "unknown")),
|
|
609
|
+
"healthy": self.instance_health.get(instance_name, False),
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
def get_client(self, instance_name: str = "default") -> qbittorrentapi.Client | None:
|
|
613
|
+
"""
|
|
614
|
+
Get qBittorrent client for a specific instance.
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
instance_name: The instance identifier (default: "default")
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
qbittorrentapi.Client | None: Client instance, or None if not found/unhealthy
|
|
621
|
+
"""
|
|
622
|
+
if instance_name not in self.clients:
|
|
623
|
+
self.logger.warning("Instance '%s' not found in clients", instance_name)
|
|
624
|
+
return None
|
|
625
|
+
return self.clients[instance_name]
|
|
626
|
+
|
|
430
627
|
# @response_text(str)
|
|
431
628
|
# @login_required
|
|
432
|
-
def app_version(self, **kwargs):
|
|
433
|
-
|
|
629
|
+
def app_version(self, instance_name: str = "default", **kwargs):
|
|
630
|
+
"""Get qBittorrent app version for a specific instance."""
|
|
631
|
+
client = self.get_client(instance_name)
|
|
632
|
+
if client is None:
|
|
633
|
+
return None
|
|
634
|
+
return client._get(
|
|
434
635
|
_name=APINames.Application,
|
|
435
636
|
_method="version",
|
|
436
637
|
_retries=0,
|
|
@@ -438,23 +639,44 @@ class qBitManager:
|
|
|
438
639
|
**kwargs,
|
|
439
640
|
)
|
|
440
641
|
|
|
441
|
-
def transfer_info(self, **kwargs):
|
|
442
|
-
"""
|
|
443
|
-
|
|
642
|
+
def transfer_info(self, instance_name: str = "default", **kwargs):
|
|
643
|
+
"""
|
|
644
|
+
Proxy transfer info requests to a specific qBittorrent instance.
|
|
645
|
+
|
|
646
|
+
Args:
|
|
647
|
+
instance_name: The instance identifier (default: "default")
|
|
648
|
+
**kwargs: Additional arguments to pass to transfer_info
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
dict: Transfer info or connection status
|
|
652
|
+
"""
|
|
653
|
+
client = self.get_client(instance_name)
|
|
654
|
+
if client is None:
|
|
444
655
|
return {"connection_status": "disconnected"}
|
|
445
|
-
return
|
|
656
|
+
return client.transfer_info(**kwargs)
|
|
446
657
|
|
|
447
658
|
@property
|
|
448
659
|
def is_alive(self) -> bool:
|
|
660
|
+
"""
|
|
661
|
+
Check if the default qBittorrent instance is alive.
|
|
662
|
+
|
|
663
|
+
Backward-compatible property that delegates to is_instance_alive("default").
|
|
664
|
+
Uses caching via expiring_bool to avoid excessive health checks.
|
|
665
|
+
"""
|
|
449
666
|
try:
|
|
450
667
|
if self.client is None:
|
|
451
668
|
return False
|
|
452
669
|
if 1 in self.expiring_bool:
|
|
453
670
|
return True
|
|
454
|
-
|
|
455
|
-
self.
|
|
456
|
-
|
|
457
|
-
|
|
671
|
+
# Delegate to instance health check
|
|
672
|
+
alive = self.is_instance_alive("default")
|
|
673
|
+
if alive:
|
|
674
|
+
self.logger.trace(
|
|
675
|
+
"Successfully connected to %s:%s", self.qBit_Host, self.qBit_Port
|
|
676
|
+
)
|
|
677
|
+
self.expiring_bool.add(1)
|
|
678
|
+
return True
|
|
679
|
+
self.logger.warning("Could not connect to %s:%s", self.qBit_Host, self.qBit_Port)
|
|
458
680
|
except requests.RequestException:
|
|
459
681
|
self.logger.warning("Could not connect to %s:%s", self.qBit_Host, self.qBit_Port)
|
|
460
682
|
self.should_delay_torrent_scan = True
|