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 CHANGED
@@ -20,7 +20,7 @@ import qbittorrentapi
20
20
  import qbittorrentapi.exceptions
21
21
  import requests
22
22
  from packaging import version as version_parser
23
- from peewee import Model, SqliteDatabase
23
+ from peewee import DatabaseError, Model, OperationalError, SqliteDatabase
24
24
  from pyarr import LidarrAPI, RadarrAPI, SonarrAPI
25
25
  from pyarr.exceptions import PyarrResourceNotFound, PyarrServerError
26
26
  from pyarr.types import JsonObject
@@ -147,36 +147,36 @@ class Arr:
147
147
  self.logger = logging.getLogger(f"qBitrr.{self._name}")
148
148
  run_logs(self.logger, self._name)
149
149
 
150
+ # Set completed_folder path (used for category creation and file monitoring)
150
151
  if not QBIT_DISABLED:
151
152
  try:
153
+ # Check default instance for existing category configuration
152
154
  categories = self.manager.qbit_manager.client.torrent_categories.categories
153
- try:
154
- categ = categories[self.category]
155
- path = categ["savePath"]
156
- if path:
157
- self.logger.trace("Category exists with save path [%s]", path)
158
- self.completed_folder = pathlib.Path(path)
159
- else:
160
- self.logger.trace("Category exists without save path")
161
- self.completed_folder = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).joinpath(
162
- self.category
163
- )
164
- except KeyError:
155
+ categ = categories.get(self.category)
156
+ if categ and categ.get("savePath"):
157
+ self.logger.trace("Category exists with save path [%s]", categ["savePath"])
158
+ self.completed_folder = pathlib.Path(categ["savePath"])
159
+ else:
160
+ self.logger.trace("Category does not exist or lacks save path")
165
161
  self.completed_folder = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).joinpath(
166
162
  self.category
167
163
  )
168
- self.manager.qbit_manager.client.torrent_categories.create_category(
169
- self.category, save_path=self.completed_folder
170
- )
171
164
  except Exception as e:
172
165
  self.logger.warning(
173
- "Could not connect to qBittorrent during initialization for %s: %s. Will retry when process starts.",
166
+ "Could not connect to qBittorrent during initialization for %s: %s. Using default path.",
174
167
  self._name,
175
- str(e).split("\n")[0] if "\n" in str(e) else str(e), # First line only
168
+ str(e).split("\n")[0] if "\n" in str(e) else str(e),
176
169
  )
177
170
  self.completed_folder = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).joinpath(
178
171
  self.category
179
172
  )
173
+ # Ensure category exists on ALL instances (deferred to avoid __init__ failures)
174
+ try:
175
+ self._ensure_category_on_all_instances()
176
+ except Exception as e:
177
+ self.logger.warning(
178
+ "Could not ensure category on all instances during init: %s", e
179
+ )
180
180
  else:
181
181
  self.completed_folder = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).joinpath(self.category)
182
182
 
@@ -673,6 +673,57 @@ class Arr:
673
673
  )
674
674
  self.logger.hnotice("Starting %s monitor", self._name)
675
675
 
676
+ def _ensure_category_on_all_instances(self) -> None:
677
+ """
678
+ Ensure the Arr category exists on ALL qBittorrent instances.
679
+
680
+ Creates the category with the completed_folder save path on each instance.
681
+ Logs errors but continues if individual instances fail.
682
+ """
683
+ if QBIT_DISABLED:
684
+ return
685
+
686
+ qbit_manager = self.manager.qbit_manager
687
+ all_instances = qbit_manager.get_all_instances()
688
+
689
+ self.logger.debug(
690
+ "Ensuring category '%s' exists on %d qBit instance(s)",
691
+ self.category,
692
+ len(all_instances),
693
+ )
694
+
695
+ for instance_name in all_instances:
696
+ try:
697
+ client = qbit_manager.get_client(instance_name)
698
+ if client is None:
699
+ self.logger.warning(
700
+ "Skipping category creation on instance '%s' (client unavailable)",
701
+ instance_name,
702
+ )
703
+ continue
704
+
705
+ categories = client.torrent_categories.categories
706
+ if self.category not in categories:
707
+ client.torrent_categories.create_category(
708
+ self.category, save_path=str(self.completed_folder)
709
+ )
710
+ self.logger.info(
711
+ "Created category '%s' on instance '%s'", self.category, instance_name
712
+ )
713
+ else:
714
+ self.logger.debug(
715
+ "Category '%s' already exists on instance '%s'",
716
+ self.category,
717
+ instance_name,
718
+ )
719
+ except Exception as e:
720
+ self.logger.error(
721
+ "Failed to ensure category '%s' on instance '%s': %s",
722
+ self.category,
723
+ instance_name,
724
+ str(e).split("\n")[0] if "\n" in str(e) else str(e),
725
+ )
726
+
676
727
  @staticmethod
677
728
  def _humanize_request_tag(tag: str) -> str | None:
678
729
  if not tag:
@@ -785,7 +836,9 @@ class Arr:
785
836
  """Returns True if the State is categorized as Downloading."""
786
837
  return torrent.state_enum in (TorrentStates.DOWNLOADING, TorrentStates.PAUSED_DOWNLOAD)
787
838
 
788
- def in_tags(self, torrent: TorrentDictionary, tag: str) -> bool:
839
+ def in_tags(
840
+ self, torrent: TorrentDictionary, tag: str, instance_name: str = "default"
841
+ ) -> bool:
789
842
  return_value = False
790
843
  if TAGLESS:
791
844
  if tag == "qBitrr-ignored":
@@ -796,15 +849,20 @@ class Arr:
796
849
  .where(
797
850
  (self.torrents.Hash == torrent.hash)
798
851
  & (self.torrents.Category == torrent.category)
852
+ & (self.torrents.QbitInstance == instance_name)
799
853
  )
800
854
  .execute()
801
855
  )
802
856
  if not query:
803
857
  self.torrents.insert(
804
- Hash=torrent.hash, Category=torrent.category
858
+ Hash=torrent.hash,
859
+ Category=torrent.category,
860
+ QbitInstance=instance_name,
805
861
  ).on_conflict_ignore().execute()
806
- condition = (self.torrents.Hash == torrent.hash) & (
807
- self.torrents.Category == torrent.category
862
+ condition = (
863
+ (self.torrents.Hash == torrent.hash)
864
+ & (self.torrents.Category == torrent.category)
865
+ & (self.torrents.QbitInstance == instance_name)
808
866
  )
809
867
  if tag == "qBitrr-allowed_seeding":
810
868
  condition &= self.torrents.AllowedSeeding == True
@@ -832,7 +890,9 @@ class Arr:
832
890
  self.logger.trace("Tag %s not in %s", tag, torrent.name)
833
891
  return False
834
892
 
835
- def remove_tags(self, torrent: TorrentDictionary, tags: list) -> None:
893
+ def remove_tags(
894
+ self, torrent: TorrentDictionary, tags: list, instance_name: str = "default"
895
+ ) -> None:
836
896
  for tag in tags:
837
897
  self.logger.trace("Removing tag %s from %s", tag, torrent.name)
838
898
  if TAGLESS:
@@ -842,32 +902,39 @@ class Arr:
842
902
  .where(
843
903
  (self.torrents.Hash == torrent.hash)
844
904
  & (self.torrents.Category == torrent.category)
905
+ & (self.torrents.QbitInstance == instance_name)
845
906
  )
846
907
  .execute()
847
908
  )
848
909
  if not query:
849
910
  self.torrents.insert(
850
- Hash=torrent.hash, Category=torrent.category
911
+ Hash=torrent.hash,
912
+ Category=torrent.category,
913
+ QbitInstance=instance_name,
851
914
  ).on_conflict_ignore().execute()
852
915
  if tag == "qBitrr-allowed_seeding":
853
916
  self.torrents.update(AllowedSeeding=False).where(
854
917
  (self.torrents.Hash == torrent.hash)
855
918
  & (self.torrents.Category == torrent.category)
919
+ & (self.torrents.QbitInstance == instance_name)
856
920
  ).execute()
857
921
  elif tag == "qBitrr-imported":
858
922
  self.torrents.update(Imported=False).where(
859
923
  (self.torrents.Hash == torrent.hash)
860
924
  & (self.torrents.Category == torrent.category)
925
+ & (self.torrents.QbitInstance == instance_name)
861
926
  ).execute()
862
927
  elif tag == "qBitrr-allowed_stalled":
863
928
  self.torrents.update(AllowedStalled=False).where(
864
929
  (self.torrents.Hash == torrent.hash)
865
930
  & (self.torrents.Category == torrent.category)
931
+ & (self.torrents.QbitInstance == instance_name)
866
932
  ).execute()
867
933
  elif tag == "qBitrr-free_space_paused":
868
934
  self.torrents.update(FreeSpacePaused=False).where(
869
935
  (self.torrents.Hash == torrent.hash)
870
936
  & (self.torrents.Category == torrent.category)
937
+ & (self.torrents.QbitInstance == instance_name)
871
938
  ).execute()
872
939
  else:
873
940
  with contextlib.suppress(Exception):
@@ -883,7 +950,9 @@ class Arr:
883
950
  ),
884
951
  )
885
952
 
886
- def add_tags(self, torrent: TorrentDictionary, tags: list) -> None:
953
+ def add_tags(
954
+ self, torrent: TorrentDictionary, tags: list, instance_name: str = "default"
955
+ ) -> None:
887
956
  for tag in tags:
888
957
  self.logger.trace("Adding tag %s from %s", tag, torrent.name)
889
958
  if TAGLESS:
@@ -893,32 +962,39 @@ class Arr:
893
962
  .where(
894
963
  (self.torrents.Hash == torrent.hash)
895
964
  & (self.torrents.Category == torrent.category)
965
+ & (self.torrents.QbitInstance == instance_name)
896
966
  )
897
967
  .execute()
898
968
  )
899
969
  if not query:
900
970
  self.torrents.insert(
901
- Hash=torrent.hash, Category=torrent.category
971
+ Hash=torrent.hash,
972
+ Category=torrent.category,
973
+ QbitInstance=instance_name,
902
974
  ).on_conflict_ignore().execute()
903
975
  if tag == "qBitrr-allowed_seeding":
904
976
  self.torrents.update(AllowedSeeding=True).where(
905
977
  (self.torrents.Hash == torrent.hash)
906
978
  & (self.torrents.Category == torrent.category)
979
+ & (self.torrents.QbitInstance == instance_name)
907
980
  ).execute()
908
981
  elif tag == "qBitrr-imported":
909
982
  self.torrents.update(Imported=True).where(
910
983
  (self.torrents.Hash == torrent.hash)
911
984
  & (self.torrents.Category == torrent.category)
985
+ & (self.torrents.QbitInstance == instance_name)
912
986
  ).execute()
913
987
  elif tag == "qBitrr-allowed_stalled":
914
988
  self.torrents.update(AllowedStalled=True).where(
915
989
  (self.torrents.Hash == torrent.hash)
916
990
  & (self.torrents.Category == torrent.category)
991
+ & (self.torrents.QbitInstance == instance_name)
917
992
  ).execute()
918
993
  elif tag == "qBitrr-free_space_paused":
919
994
  self.torrents.update(FreeSpacePaused=True).where(
920
995
  (self.torrents.Hash == torrent.hash)
921
996
  & (self.torrents.Category == torrent.category)
997
+ & (self.torrents.QbitInstance == instance_name)
922
998
  ).execute()
923
999
  else:
924
1000
  with contextlib.suppress(Exception):
@@ -1159,7 +1235,7 @@ class Arr:
1159
1235
  def _process_imports(self) -> None:
1160
1236
  if self.import_torrents:
1161
1237
  self.needs_cleanup = True
1162
- for torrent in self.import_torrents:
1238
+ for torrent, instance_name in self.import_torrents:
1163
1239
  if torrent.hash in self.sent_to_scan:
1164
1240
  continue
1165
1241
  path = validate_and_return_torrent_file(torrent.content_path)
@@ -1245,7 +1321,7 @@ class Arr:
1245
1321
  self.import_mode,
1246
1322
  ex,
1247
1323
  )
1248
- self.add_tags(torrent, ["qBitrr-imported"])
1324
+ self.add_tags(torrent, ["qBitrr-imported"], instance_name)
1249
1325
  self.sent_to_scan.add(path)
1250
1326
  self.import_torrents.clear()
1251
1327
 
@@ -4448,33 +4524,96 @@ class Arr:
4448
4524
 
4449
4525
  return payload
4450
4526
 
4527
+ def _get_torrents_from_all_instances(
4528
+ self,
4529
+ ) -> list[tuple[str, qbittorrentapi.TorrentDictionary]]:
4530
+ """
4531
+ Get torrents from ALL qBittorrent instances for this Arr's category.
4532
+
4533
+ Returns:
4534
+ list[tuple[str, TorrentDictionary]]: List of (instance_name, torrent) tuples
4535
+ """
4536
+ all_torrents = []
4537
+ qbit_manager = self.manager.qbit_manager
4538
+
4539
+ for instance_name in qbit_manager.get_all_instances():
4540
+ if not qbit_manager.is_instance_alive(instance_name):
4541
+ self.logger.debug(
4542
+ "Skipping unhealthy instance '%s' during torrent scan", instance_name
4543
+ )
4544
+ continue
4545
+
4546
+ client = qbit_manager.get_client(instance_name)
4547
+ if client is None:
4548
+ continue
4549
+
4550
+ try:
4551
+ torrents = client.torrents.info(
4552
+ status_filter="all",
4553
+ category=self.category,
4554
+ sort="added_on",
4555
+ reverse=False,
4556
+ )
4557
+ # Tag each torrent with its instance name
4558
+ for torrent in torrents:
4559
+ if hasattr(torrent, "category"):
4560
+ all_torrents.append((instance_name, torrent))
4561
+
4562
+ self.logger.trace(
4563
+ "Retrieved %d torrents from instance '%s' for category '%s'",
4564
+ len(torrents),
4565
+ instance_name,
4566
+ self.category,
4567
+ )
4568
+ except (qbittorrentapi.exceptions.APIError, JSONDecodeError) as e:
4569
+ self.logger.warning(
4570
+ "Failed to get torrents from instance '%s': %s", instance_name, e
4571
+ )
4572
+ continue
4573
+
4574
+ self.logger.debug(
4575
+ "Total torrents across %d instances: %d",
4576
+ len(qbit_manager.get_all_instances()),
4577
+ len(all_torrents),
4578
+ )
4579
+ return all_torrents
4580
+
4451
4581
  def process_torrents(self):
4452
4582
  try:
4453
4583
  try:
4454
4584
  while True:
4455
4585
  try:
4456
- torrents = self.manager.qbit_manager.client.torrents.info(
4457
- status_filter="all",
4458
- category=self.category,
4459
- sort="added_on",
4460
- reverse=False,
4461
- )
4586
+ # Multi-instance: Scan all qBit instances for category-matching torrents
4587
+ torrents_with_instances = self._get_torrents_from_all_instances()
4462
4588
  break
4463
4589
  except (qbittorrentapi.exceptions.APIError, JSONDecodeError) as e:
4464
4590
  if "JSONDecodeError" in str(e):
4465
4591
  continue
4466
4592
  else:
4467
4593
  raise qbittorrentapi.exceptions.APIError
4468
- torrents = [t for t in torrents if hasattr(t, "category")]
4469
- self.category_torrent_count = len(torrents)
4470
- if not len(torrents):
4594
+
4595
+ # Filter torrents that have category attribute
4596
+ torrents_with_instances = [
4597
+ (instance, t)
4598
+ for instance, t in torrents_with_instances
4599
+ if hasattr(t, "category")
4600
+ ]
4601
+ self.category_torrent_count = len(torrents_with_instances)
4602
+ if not len(torrents_with_instances):
4471
4603
  raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads")
4604
+
4605
+ # Internet check: Use default instance for backward compatibility
4472
4606
  if not has_internet(self.manager.qbit_manager.client):
4473
4607
  self.manager.qbit_manager.should_delay_torrent_scan = True
4474
4608
  raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet")
4475
4609
  if self.manager.qbit_manager.should_delay_torrent_scan:
4476
4610
  raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay")
4477
4611
 
4612
+ # Initialize database error tracking for exponential backoff
4613
+ if not hasattr(self, "_db_error_count"):
4614
+ self._db_error_count = 0
4615
+ self._db_last_error_time = 0
4616
+
4478
4617
  # Periodic database health check (every 10th iteration)
4479
4618
  if not hasattr(self, "_health_check_counter"):
4480
4619
  self._health_check_counter = 0
@@ -4502,9 +4641,10 @@ class Arr:
4502
4641
 
4503
4642
  self.api_calls()
4504
4643
  self.refresh_download_queue()
4505
- for torrent in torrents:
4644
+ # Multi-instance: Process torrents from all instances
4645
+ for instance_name, torrent in torrents_with_instances:
4506
4646
  with contextlib.suppress(qbittorrentapi.NotFound404Error):
4507
- self._process_single_torrent(torrent)
4647
+ self._process_single_torrent(torrent, instance_name=instance_name)
4508
4648
  self.process()
4509
4649
  except NoConnectionrException as e:
4510
4650
  self.logger.error(e.message)
@@ -4516,6 +4656,76 @@ class Arr:
4516
4656
  self.logger.error("The qBittorrent API returned an unexpected error")
4517
4657
  self.logger.debug("Unexpected APIError from qBitTorrent") # , exc_info=e)
4518
4658
  raise DelayLoopException(length=300, type="qbit")
4659
+ except (OperationalError, DatabaseError) as e:
4660
+ # Database errors after retry exhaustion - implement automatic recovery with backoff
4661
+ error_msg = str(e).lower()
4662
+ current_time = time.time()
4663
+
4664
+ # Track consecutive database errors for exponential backoff
4665
+ if (
4666
+ current_time - self._db_last_error_time > 300
4667
+ ): # Reset if >5min since last error
4668
+ self._db_error_count = 0
4669
+ self._db_error_count += 1
4670
+ self._db_last_error_time = current_time
4671
+
4672
+ # Calculate exponential backoff: 2min, 5min, 10min, 20min, 30min (max)
4673
+ delay_seconds = min(120 * (2 ** (self._db_error_count - 1)), 1800)
4674
+
4675
+ # Log detailed error information based on error type
4676
+ if "disk i/o error" in error_msg:
4677
+ self.logger.critical(
4678
+ "Persistent database I/O error detected (consecutive error #%d). "
4679
+ "This indicates disk issues, filesystem corruption, or resource exhaustion. "
4680
+ "Attempting automatic recovery and retrying in %d seconds...",
4681
+ self._db_error_count,
4682
+ delay_seconds,
4683
+ )
4684
+ elif "database is locked" in error_msg:
4685
+ self.logger.error(
4686
+ "Database locked error (consecutive error #%d). "
4687
+ "Retrying in %d seconds...",
4688
+ self._db_error_count,
4689
+ delay_seconds,
4690
+ )
4691
+ elif "disk image is malformed" in error_msg:
4692
+ self.logger.critical(
4693
+ "Database corruption detected (consecutive error #%d). "
4694
+ "Attempting automatic recovery and retrying in %d seconds...",
4695
+ self._db_error_count,
4696
+ delay_seconds,
4697
+ )
4698
+ else:
4699
+ self.logger.error(
4700
+ "Database error (consecutive error #%d): %s. " "Retrying in %d seconds...",
4701
+ self._db_error_count,
4702
+ error_msg,
4703
+ delay_seconds,
4704
+ )
4705
+
4706
+ # Attempt automatic recovery for critical errors
4707
+ if "disk i/o error" in error_msg or "disk image is malformed" in error_msg:
4708
+ try:
4709
+ self.logger.warning(
4710
+ "Attempting enhanced database recovery (WAL checkpoint, repair, and verification)..."
4711
+ )
4712
+ self._enhanced_database_recovery()
4713
+ self.logger.info(
4714
+ "Database recovery completed successfully - will retry operation after delay"
4715
+ )
4716
+ # Reduce error count on successful recovery (but don't reset completely)
4717
+ self._db_error_count = max(0, self._db_error_count - 1)
4718
+ except Exception as recovery_error:
4719
+ self.logger.critical(
4720
+ "Automatic database recovery failed: %s. "
4721
+ "MANUAL INTERVENTION REQUIRED: Check disk health (smartctl), "
4722
+ "filesystem integrity (fsck), available space (df -h), "
4723
+ "Docker volume mounts, permissions, and system logs (dmesg).",
4724
+ recovery_error,
4725
+ )
4726
+
4727
+ # Delay processing to avoid hammering failing database
4728
+ raise DelayLoopException(length=delay_seconds, type="database")
4519
4729
  except DelayLoopException:
4520
4730
  raise
4521
4731
  except KeyboardInterrupt:
@@ -4566,6 +4776,83 @@ class Arr:
4566
4776
  "Manual intervention may be required. Continuing with caution..."
4567
4777
  )
4568
4778
 
4779
+ def _enhanced_database_recovery(self):
4780
+ """
4781
+ Enhanced automatic database recovery with additional filesystem checks.
4782
+
4783
+ This method is called when disk I/O errors persist after retry logic has been exhausted.
4784
+ It implements a comprehensive recovery strategy:
4785
+ 1. Try WAL checkpoint (least invasive)
4786
+ 2. Try VACUUM to reclaim space and fix minor corruption
4787
+ 3. Try full database repair (dump/restore) if needed
4788
+ 4. Verify database integrity after recovery
4789
+ """
4790
+ from qBitrr.db_recovery import (
4791
+ DatabaseRecoveryError,
4792
+ checkpoint_wal,
4793
+ repair_database,
4794
+ vacuum_database,
4795
+ )
4796
+ from qBitrr.home_path import APPDATA_FOLDER
4797
+
4798
+ db_path = APPDATA_FOLDER / "qbitrr.db"
4799
+
4800
+ self.logger.info("Starting enhanced database recovery procedure...")
4801
+
4802
+ # Step 1: Try WAL checkpoint
4803
+ self.logger.info("Step 1/3: Attempting WAL checkpoint...")
4804
+ if checkpoint_wal(db_path, self.logger):
4805
+ self.logger.info("WAL checkpoint successful")
4806
+ # Try a quick health check
4807
+ from qBitrr.db_lock import check_database_health
4808
+
4809
+ healthy, msg = check_database_health(db_path, self.logger)
4810
+ if healthy:
4811
+ self.logger.info("Database health verified - recovery complete")
4812
+ return
4813
+ else:
4814
+ self.logger.warning(
4815
+ "WAL checkpoint completed but database still unhealthy: %s", msg
4816
+ )
4817
+
4818
+ # Step 2: Try VACUUM (only if WAL didn't fully fix it)
4819
+ self.logger.info("Step 2/3: Attempting VACUUM to reclaim space and fix minor issues...")
4820
+ if vacuum_database(db_path, self.logger):
4821
+ self.logger.info("VACUUM completed successfully")
4822
+ from qBitrr.db_lock import check_database_health
4823
+
4824
+ healthy, msg = check_database_health(db_path, self.logger)
4825
+ if healthy:
4826
+ self.logger.info("Database health verified after VACUUM - recovery complete")
4827
+ return
4828
+ else:
4829
+ self.logger.warning("VACUUM completed but database still unhealthy: %s", msg)
4830
+
4831
+ # Step 3: Try full repair (most invasive)
4832
+ self.logger.warning("Step 3/3: Attempting full database repair (dump/restore)...")
4833
+ try:
4834
+ if repair_database(db_path, backup=True, logger_override=self.logger):
4835
+ self.logger.info("Database repair successful")
4836
+ # Final health check
4837
+ from qBitrr.db_lock import check_database_health
4838
+
4839
+ healthy, msg = check_database_health(db_path, self.logger)
4840
+ if healthy:
4841
+ self.logger.info("Database health verified after repair - recovery complete")
4842
+ return
4843
+ else:
4844
+ self.logger.error("Repair completed but database still unhealthy: %s", msg)
4845
+ raise DatabaseRecoveryError(f"Database unhealthy after repair: {msg}")
4846
+ except DatabaseRecoveryError as e:
4847
+ self.logger.error("Database repair failed: %s", e)
4848
+ raise
4849
+ except Exception as e:
4850
+ self.logger.error("Unexpected error during database repair: %s", e)
4851
+ raise
4852
+
4853
+ # If we reach here, all recovery methods failed
4854
+ raise DatabaseRecoveryError("All automatic recovery methods failed")
4855
+
4569
4856
  def _process_single_torrent_failed_cat(self, torrent: qbittorrentapi.TorrentDictionary):
4570
4857
  self.logger.notice(
4571
4858
  "Deleting manually failed torrent: "
@@ -4825,7 +5112,10 @@ class Arr:
4825
5112
  self.recheck.add(torrent.hash)
4826
5113
 
4827
5114
  def _process_single_torrent_fully_completed_torrent(
4828
- self, torrent: qbittorrentapi.TorrentDictionary, leave_alone: bool
5115
+ self,
5116
+ torrent: qbittorrentapi.TorrentDictionary,
5117
+ leave_alone: bool,
5118
+ instance_name: str = "default",
4829
5119
  ):
4830
5120
  if leave_alone or torrent.state_enum == TorrentStates.FORCED_UPLOAD:
4831
5121
  self.logger.trace(
@@ -4843,7 +5133,7 @@ class Arr:
4843
5133
  torrent.name,
4844
5134
  torrent.hash,
4845
5135
  )
4846
- elif not self.in_tags(torrent, "qBitrr-imported"):
5136
+ elif not self.in_tags(torrent, "qBitrr-imported", instance_name):
4847
5137
  self.logger.info(
4848
5138
  "Importing Completed torrent: "
4849
5139
  "[Progress: %s%%][Added On: %s]"
@@ -4868,7 +5158,7 @@ class Arr:
4868
5158
  else:
4869
5159
  torrent_folder = content_path
4870
5160
  self.files_to_cleanup.add((torrent.hash, torrent_folder))
4871
- self.import_torrents.append(torrent)
5161
+ self.import_torrents.append((torrent, instance_name))
4872
5162
 
4873
5163
  def _process_single_torrent_missing_files(self, torrent: qbittorrentapi.TorrentDictionary):
4874
5164
  # Sometimes Sonarr/Radarr does not automatically remove the
@@ -5236,7 +5526,7 @@ class Arr:
5236
5526
  return data_settings, data_torrent
5237
5527
 
5238
5528
  def _should_leave_alone(
5239
- self, torrent: qbittorrentapi.TorrentDictionary
5529
+ self, torrent: qbittorrentapi.TorrentDictionary, instance_name: str = "default"
5240
5530
  ) -> tuple[bool, int, bool]:
5241
5531
  return_value = True
5242
5532
  remove_torrent = False
@@ -5262,18 +5552,18 @@ class Arr:
5262
5552
  return_value = not self.torrent_limit_check(torrent, seeding_time_limit, ratio_limit)
5263
5553
  if data_settings.get("super_seeding", False) or data_torrent.get("super_seeding", False):
5264
5554
  return_value = True
5265
- if self.in_tags(torrent, "qBitrr-free_space_paused"):
5555
+ if self.in_tags(torrent, "qBitrr-free_space_paused", instance_name):
5266
5556
  return_value = True
5267
5557
  if (
5268
5558
  return_value
5269
- and not self.in_tags(torrent, "qBitrr-allowed_seeding")
5270
- and not self.in_tags(torrent, "qBitrr-free_space_paused")
5559
+ and not self.in_tags(torrent, "qBitrr-allowed_seeding", instance_name)
5560
+ and not self.in_tags(torrent, "qBitrr-free_space_paused", instance_name)
5271
5561
  ):
5272
- self.add_tags(torrent, ["qBitrr-allowed_seeding"])
5562
+ self.add_tags(torrent, ["qBitrr-allowed_seeding"], instance_name)
5273
5563
  elif (
5274
- not return_value and self.in_tags(torrent, "qBitrr-allowed_seeding")
5275
- ) or self.in_tags(torrent, "qBitrr-free_space_paused"):
5276
- self.remove_tags(torrent, ["qBitrr-allowed_seeding"])
5564
+ not return_value and self.in_tags(torrent, "qBitrr-allowed_seeding", instance_name)
5565
+ ) or self.in_tags(torrent, "qBitrr-free_space_paused", instance_name):
5566
+ self.remove_tags(torrent, ["qBitrr-allowed_seeding"], instance_name)
5277
5567
 
5278
5568
  self.logger.trace("Config Settings returned [%s]: %r", torrent.name, data_settings)
5279
5569
  return (
@@ -5439,9 +5729,14 @@ class Arr:
5439
5729
  current_tags = set(torrent.tags.split(", "))
5440
5730
  add_tags = unique_tags.difference(current_tags)
5441
5731
  if add_tags:
5442
- self.add_tags(torrent, add_tags)
5732
+ self.add_tags(torrent, add_tags, instance_name)
5443
5733
 
5444
- def _stalled_check(self, torrent: qbittorrentapi.TorrentDictionary, time_now: float) -> bool:
5734
+ def _stalled_check(
5735
+ self,
5736
+ torrent: qbittorrentapi.TorrentDictionary,
5737
+ time_now: float,
5738
+ instance_name: str = "default",
5739
+ ) -> bool:
5445
5740
  stalled_ignore = True
5446
5741
  if not self.allowed_stalled:
5447
5742
  self.logger.trace("Stalled check: Stalled delay disabled")
@@ -5478,15 +5773,15 @@ class Arr:
5478
5773
  (
5479
5774
  torrent.state_enum
5480
5775
  in (TorrentStates.METADATA_DOWNLOAD, TorrentStates.STALLED_DOWNLOAD)
5481
- and not self.in_tags(torrent, "qBitrr-ignored")
5482
- and not self.in_tags(torrent, "qBitrr-free_space_paused")
5776
+ and not self.in_tags(torrent, "qBitrr-ignored", instance_name)
5777
+ and not self.in_tags(torrent, "qBitrr-free_space_paused", instance_name)
5483
5778
  )
5484
5779
  or (
5485
5780
  torrent.availability < 1
5486
5781
  and torrent.hash in self.cleaned_torrents
5487
5782
  and torrent.state_enum in (TorrentStates.DOWNLOADING)
5488
- and not self.in_tags(torrent, "qBitrr-ignored")
5489
- and not self.in_tags(torrent, "qBitrr-free_space_paused")
5783
+ and not self.in_tags(torrent, "qBitrr-ignored", instance_name)
5784
+ and not self.in_tags(torrent, "qBitrr-free_space_paused", instance_name)
5490
5785
  )
5491
5786
  ) and self.allowed_stalled:
5492
5787
  if (
@@ -5496,8 +5791,8 @@ class Arr:
5496
5791
  ):
5497
5792
  stalled_ignore = False
5498
5793
  self.logger.trace("Process stalled, delay expired: %s", torrent.name)
5499
- elif not self.in_tags(torrent, "qBitrr-allowed_stalled"):
5500
- self.add_tags(torrent, ["qBitrr-allowed_stalled"])
5794
+ elif not self.in_tags(torrent, "qBitrr-allowed_stalled", instance_name):
5795
+ self.add_tags(torrent, ["qBitrr-allowed_stalled"], instance_name)
5501
5796
  if self.re_search_stalled:
5502
5797
  self.logger.trace(
5503
5798
  "Stalled, adding tag, blocklosting and re-searching: %s", torrent.name
@@ -5514,7 +5809,7 @@ class Arr:
5514
5809
  )
5515
5810
  else:
5516
5811
  self.logger.trace("Stalled, adding tag: %s", torrent.name)
5517
- elif self.in_tags(torrent, "qBitrr-allowed_stalled"):
5812
+ elif self.in_tags(torrent, "qBitrr-allowed_stalled", instance_name):
5518
5813
  self.logger.trace(
5519
5814
  "Stalled: %s [Current:%s][Last Activity:%s][Limit:%s]",
5520
5815
  torrent.name,
@@ -5525,8 +5820,8 @@ class Arr:
5525
5820
  ),
5526
5821
  )
5527
5822
 
5528
- elif self.in_tags(torrent, "qBitrr-allowed_stalled"):
5529
- self.remove_tags(torrent, ["qBitrr-allowed_stalled"])
5823
+ elif self.in_tags(torrent, "qBitrr-allowed_stalled", instance_name):
5824
+ self.remove_tags(torrent, ["qBitrr-allowed_stalled"], instance_name)
5530
5825
  stalled_ignore = False
5531
5826
  self.logger.trace("Not stalled, removing tag: %s", torrent.name)
5532
5827
  else:
@@ -5534,13 +5829,17 @@ class Arr:
5534
5829
  self.logger.trace("Not stalled: %s", torrent.name)
5535
5830
  return stalled_ignore
5536
5831
 
5537
- def _process_single_torrent(self, torrent: qbittorrentapi.TorrentDictionary):
5832
+ def _process_single_torrent(
5833
+ self, torrent: qbittorrentapi.TorrentDictionary, instance_name: str = "default"
5834
+ ):
5538
5835
  if torrent.category != RECHECK_CATEGORY:
5539
5836
  self.manager.qbit_manager.cache[torrent.hash] = torrent.category
5540
5837
  self._process_single_torrent_trackers(torrent)
5541
5838
  self.manager.qbit_manager.name_cache[torrent.hash] = torrent.name
5542
5839
  time_now = time.time()
5543
- leave_alone, _tracker_max_eta, remove_torrent = self._should_leave_alone(torrent)
5840
+ leave_alone, _tracker_max_eta, remove_torrent = self._should_leave_alone(
5841
+ torrent, instance_name
5842
+ )
5544
5843
  self.logger.trace(
5545
5844
  "Torrent [%s]: Leave Alone (allow seeding): %s, Max ETA: %s, State[%s]",
5546
5845
  torrent.name,
@@ -5555,20 +5854,22 @@ class Arr:
5555
5854
  TorrentStates.STALLED_DOWNLOAD,
5556
5855
  TorrentStates.DOWNLOADING,
5557
5856
  ):
5558
- stalled_ignore = self._stalled_check(torrent, time_now)
5857
+ stalled_ignore = self._stalled_check(torrent, time_now, instance_name)
5559
5858
  else:
5560
5859
  stalled_ignore = False
5561
5860
 
5562
- if self.in_tags(torrent, "qBitrr-ignored"):
5563
- self.remove_tags(torrent, ["qBitrr-allowed_seeding", "qBitrr-free_space_paused"])
5861
+ if self.in_tags(torrent, "qBitrr-ignored", instance_name):
5862
+ self.remove_tags(
5863
+ torrent, ["qBitrr-allowed_seeding", "qBitrr-free_space_paused"], instance_name
5864
+ )
5564
5865
 
5565
5866
  if (
5566
5867
  self.custom_format_unmet_search
5567
5868
  and self.custom_format_unmet_check(torrent)
5568
- and not self.in_tags(torrent, "qBitrr-ignored")
5569
- and not self.in_tags(torrent, "qBitrr-free_space_paused")
5869
+ and not self.in_tags(torrent, "qBitrr-ignored", instance_name)
5870
+ and not self.in_tags(torrent, "qBitrr-free_space_paused", instance_name)
5570
5871
  ):
5571
- self._process_single_torrent_delete_cfunmet(torrent)
5872
+ self._process_single_torrent_delete_cfunmet(torrent, instance_name)
5572
5873
  elif remove_torrent and not leave_alone and torrent.amount_left == 0:
5573
5874
  self._process_single_torrent_delete_ratio_seed(torrent)
5574
5875
  elif torrent.category == FAILED_CATEGORY:
@@ -5581,8 +5882,8 @@ class Arr:
5581
5882
  self._process_single_torrent_ignored(torrent)
5582
5883
  elif (
5583
5884
  torrent.state_enum in (TorrentStates.METADATA_DOWNLOAD, TorrentStates.STALLED_DOWNLOAD)
5584
- and not self.in_tags(torrent, "qBitrr-ignored")
5585
- and not self.in_tags(torrent, "qBitrr-free_space_paused")
5885
+ and not self.in_tags(torrent, "qBitrr-ignored", instance_name)
5886
+ and not self.in_tags(torrent, "qBitrr-free_space_paused", instance_name)
5586
5887
  and not stalled_ignore
5587
5888
  ):
5588
5889
  self._process_single_torrent_stalled_torrent(torrent, "Stalled State")
@@ -5603,15 +5904,15 @@ class Arr:
5603
5904
  elif (
5604
5905
  torrent.state_enum == TorrentStates.PAUSED_DOWNLOAD
5605
5906
  and torrent.amount_left != 0
5606
- and not self.in_tags(torrent, "qBitrr-free_space_paused")
5607
- and not self.in_tags(torrent, "qBitrr-ignored")
5907
+ and not self.in_tags(torrent, "qBitrr-free_space_paused", instance_name)
5908
+ and not self.in_tags(torrent, "qBitrr-ignored", instance_name)
5608
5909
  ):
5609
5910
  self._process_single_torrent_paused(torrent)
5610
5911
  elif (
5611
5912
  torrent.progress <= self.maximum_deletable_percentage
5612
5913
  and not self.is_complete_state(torrent)
5613
- and not self.in_tags(torrent, "qBitrr-ignored")
5614
- and not self.in_tags(torrent, "qBitrr-free_space_paused")
5914
+ and not self.in_tags(torrent, "qBitrr-ignored", instance_name)
5915
+ and not self.in_tags(torrent, "qBitrr-free_space_paused", instance_name)
5615
5916
  and not stalled_ignore
5616
5917
  ) and torrent.hash in self.cleaned_torrents:
5617
5918
  self._process_single_torrent_percentage_threshold(torrent, maximum_eta)
@@ -5658,8 +5959,8 @@ class Arr:
5658
5959
  and time_now > torrent.added_on + self.ignore_torrents_younger_than
5659
5960
  and 0 < maximum_eta < torrent.eta
5660
5961
  and not self.do_not_remove_slow
5661
- and not self.in_tags(torrent, "qBitrr-ignored")
5662
- and not self.in_tags(torrent, "qBitrr-free_space_paused")
5962
+ and not self.in_tags(torrent, "qBitrr-ignored", instance_name)
5963
+ and not self.in_tags(torrent, "qBitrr-free_space_paused", instance_name)
5663
5964
  and not stalled_ignore
5664
5965
  ):
5665
5966
  self._process_single_torrent_delete_slow(torrent)
@@ -5674,8 +5975,8 @@ class Arr:
5674
5975
  )
5675
5976
  and torrent.hash in self.cleaned_torrents
5676
5977
  and self.is_downloading_state(torrent)
5677
- and not self.in_tags(torrent, "qBitrr-ignored")
5678
- and not self.in_tags(torrent, "qBitrr-free_space_paused")
5978
+ and not self.in_tags(torrent, "qBitrr-ignored", instance_name)
5979
+ and not self.in_tags(torrent, "qBitrr-free_space_paused", instance_name)
5679
5980
  and not stalled_ignore
5680
5981
  ):
5681
5982
  self._process_single_torrent_stalled_torrent(torrent, "Unavailable")
@@ -6898,7 +7199,7 @@ class FreeSpaceManager(Arr):
6898
7199
  )
6899
7200
  self.pause.add(torrent.hash)
6900
7201
 
6901
- def _process_single_torrent(self, torrent):
7202
+ def _process_single_torrent(self, torrent, instance_name: str = "default"):
6902
7203
  if self.is_downloading_state(torrent):
6903
7204
  free_space_test = self.current_free_space
6904
7205
  free_space_test -= torrent["amount_left"]
@@ -6917,8 +7218,8 @@ class FreeSpaceManager(Arr):
6917
7218
  format_bytes(torrent.amount_left),
6918
7219
  format_bytes(-free_space_test),
6919
7220
  )
6920
- self.add_tags(torrent, ["qBitrr-free_space_paused"])
6921
- self.remove_tags(torrent, ["qBitrr-allowed_seeding"])
7221
+ self.add_tags(torrent, ["qBitrr-free_space_paused"], instance_name)
7222
+ self.remove_tags(torrent, ["qBitrr-allowed_seeding"], instance_name)
6922
7223
  self._process_single_torrent_pause_disk_space(torrent)
6923
7224
  elif torrent.state_enum == TorrentStates.PAUSED_DOWNLOAD and free_space_test < 0:
6924
7225
  self.logger.info(
@@ -6928,8 +7229,8 @@ class FreeSpaceManager(Arr):
6928
7229
  format_bytes(torrent.amount_left),
6929
7230
  format_bytes(-free_space_test),
6930
7231
  )
6931
- self.add_tags(torrent, ["qBitrr-free_space_paused"])
6932
- self.remove_tags(torrent, ["qBitrr-allowed_seeding"])
7232
+ self.add_tags(torrent, ["qBitrr-free_space_paused"], instance_name)
7233
+ self.remove_tags(torrent, ["qBitrr-allowed_seeding"], instance_name)
6933
7234
  elif torrent.state_enum != TorrentStates.PAUSED_DOWNLOAD and free_space_test > 0:
6934
7235
  self.logger.info(
6935
7236
  "Continuing download (sufficient space) | Torrent: %s | Available: %s | Space after: %s",
@@ -6938,7 +7239,7 @@ class FreeSpaceManager(Arr):
6938
7239
  format_bytes(free_space_test + self._min_free_space_bytes),
6939
7240
  )
6940
7241
  self.current_free_space = free_space_test
6941
- self.remove_tags(torrent, ["qBitrr-free_space_paused"])
7242
+ self.remove_tags(torrent, ["qBitrr-free_space_paused"], instance_name)
6942
7243
  elif torrent.state_enum == TorrentStates.PAUSED_DOWNLOAD and free_space_test > 0:
6943
7244
  self.logger.info(
6944
7245
  "Resuming download (space available) | Torrent: %s | Available: %s | Space after: %s",
@@ -6947,16 +7248,16 @@ class FreeSpaceManager(Arr):
6947
7248
  format_bytes(free_space_test + self._min_free_space_bytes),
6948
7249
  )
6949
7250
  self.current_free_space = free_space_test
6950
- self.remove_tags(torrent, ["qBitrr-free_space_paused"])
7251
+ self.remove_tags(torrent, ["qBitrr-free_space_paused"], instance_name)
6951
7252
  elif not self.is_downloading_state(torrent) and self.in_tags(
6952
- torrent, "qBitrr-free_space_paused"
7253
+ torrent, "qBitrr-free_space_paused", instance_name
6953
7254
  ):
6954
7255
  self.logger.info(
6955
7256
  "Torrent completed, removing free space tag | Torrent: %s | Available: %s",
6956
7257
  torrent.name,
6957
7258
  format_bytes(self.current_free_space + self._min_free_space_bytes),
6958
7259
  )
6959
- self.remove_tags(torrent, ["qBitrr-free_space_paused"])
7260
+ self.remove_tags(torrent, ["qBitrr-free_space_paused"], instance_name)
6960
7261
 
6961
7262
  def process(self):
6962
7263
  self._process_paused()
@@ -6986,7 +7287,7 @@ class FreeSpaceManager(Arr):
6986
7287
  torrents = [t for t in torrents if "qBitrr-ignored" not in t.tags]
6987
7288
  self.category_torrent_count = len(torrents)
6988
7289
  self.free_space_tagged_count = sum(
6989
- 1 for t in torrents if self.in_tags(t, "qBitrr-free_space_paused")
7290
+ 1 for t in torrents if self.in_tags(t, "qBitrr-free_space_paused", "default")
6990
7291
  )
6991
7292
  if not len(torrents):
6992
7293
  raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads")