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/bundled_data.py CHANGED
@@ -1,5 +1,5 @@
1
- version = "5.6.1"
2
- git_hash = "db497bc6"
1
+ version = "5.7.0"
2
+ git_hash = "f7e3e092"
3
3
  license_text = (
4
4
  "Licence can be found on:\n\nhttps://github.com/Feramance/qBitrr/blob/master/LICENSE"
5
5
  )
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
- logger.error(
145
- "Database corruption detected: %s. Attempting automatic recovery...",
146
- e,
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 failed. "
188
- "Manual intervention may be required. Attempting normal retry..."
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.error(
195
- "Database operation failed after %s attempts: %s",
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
- return self.client._get(
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
- """Proxy transfer info requests to the underlying qBittorrent client."""
443
- if self.client is None:
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 self.client.transfer_info(**kwargs)
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
- self.client.app_version()
455
- self.logger.trace("Successfully connected to %s:%s", self.qBit_Host, self.qBit_Port)
456
- self.expiring_bool.add(1)
457
- return True
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