qBitrr2 5.7.0__py3-none-any.whl → 5.7.1__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.7.0"
2
- git_hash = "f7e3e092"
1
+ version = "5.7.1"
2
+ git_hash = "a8446ba3"
3
3
  license_text = (
4
4
  "Licence can be found on:\n\nhttps://github.com/Feramance/qBitrr/blob/master/LICENSE"
5
5
  )
qBitrr/config.py CHANGED
@@ -151,7 +151,7 @@ PING_URLS = ENVIRO_CONFIG.settings.ping_urls or CONFIG.get(
151
151
  "Settings.PingURLS", fallback=["one.one.one.one", "dns.google.com"]
152
152
  )
153
153
  IGNORE_TORRENTS_YOUNGER_THAN = ENVIRO_CONFIG.settings.ignore_torrents_younger_than or CONFIG.get(
154
- "Settings.IgnoreTorrentsYoungerThan", fallback=600
154
+ "Settings.IgnoreTorrentsYoungerThan", fallback=180
155
155
  )
156
156
  QBIT_DISABLED = (
157
157
  CONFIG.get("qBit.Disabled", fallback=False)
qBitrr/gen_config.py CHANGED
@@ -385,48 +385,56 @@ def _gen_default_torrent_table(category: str, cat_default: Table):
385
385
  "CaseSensitiveMatches",
386
386
  False,
387
387
  )
388
- if "anime" not in category.lower():
389
- _gen_default_line(
390
- torrent_table,
391
- [
392
- "These regex values will match any folder where the full name matches the specified values here, comma separated strings.",
393
- "These regex need to be escaped, that's why you see so many backslashes.",
394
- ],
395
- "FolderExclusionRegex",
396
- [
397
- r"\bextras?\b",
398
- r"\bfeaturettes?\b",
399
- r"\bsamples?\b",
400
- r"\bscreens?\b",
401
- r"\bnc(ed|op)?(\\d+)?\b",
402
- ],
403
- )
388
+ # Set folder exclusions based on category type
389
+ if "anime" in category.lower():
390
+ # Anime-specific exclusions (includes OVA, specials, NCOP/NCED)
391
+ folder_exclusions = [
392
+ r"\bextras?\b",
393
+ r"\bfeaturettes?\b",
394
+ r"\bsamples?\b",
395
+ r"\bscreens?\b",
396
+ r"\bspecials?\b",
397
+ r"\bova\b",
398
+ r"\bnc(ed|op)?(\\d+)?\b",
399
+ ]
400
+ elif "lidarr" in category.lower():
401
+ # Music-specific exclusions (no NCOP/NCED, no featurettes)
402
+ folder_exclusions = [
403
+ r"\bextras?\b",
404
+ r"\bsamples?\b",
405
+ r"\bscreens?\b",
406
+ ]
404
407
  else:
405
- _gen_default_line(
406
- torrent_table,
407
- [
408
- "These regex values will match any folder where the full name matches the specified values here, comma separated strings.",
409
- "These regex need to be escaped, that's why you see so many backslashes.",
410
- ],
411
- "FolderExclusionRegex",
412
- [
413
- r"\bextras?\b",
414
- r"\bfeaturettes?\b",
415
- r"\bsamples?\b",
416
- r"\bscreens?\b",
417
- r"\bspecials?\b",
418
- r"\bova\b",
419
- r"\bnc(ed|op)?(\\d+)?\b",
420
- ],
421
- )
408
+ # Standard video exclusions (movies/TV shows)
409
+ folder_exclusions = [
410
+ r"\bextras?\b",
411
+ r"\bfeaturettes?\b",
412
+ r"\bsamples?\b",
413
+ r"\bscreens?\b",
414
+ r"\bnc(ed|op)?(\\d+)?\b",
415
+ ]
416
+
422
417
  _gen_default_line(
423
418
  torrent_table,
424
419
  [
425
420
  "These regex values will match any folder where the full name matches the specified values here, comma separated strings.",
426
421
  "These regex need to be escaped, that's why you see so many backslashes.",
427
422
  ],
428
- "FileNameExclusionRegex",
429
- [
423
+ "FolderExclusionRegex",
424
+ folder_exclusions,
425
+ )
426
+ # Set filename exclusions based on category type
427
+ if "lidarr" in category.lower():
428
+ # Music-specific exclusions (no NCOP/NCED, no "music video" since that's actual music content)
429
+ filename_exclusions = [
430
+ r"\bsample\b",
431
+ r"brarbg.com\b",
432
+ r"\btrailer\b",
433
+ r"comandotorrents.com",
434
+ ]
435
+ else:
436
+ # Video exclusions (movies/TV/anime)
437
+ filename_exclusions = [
430
438
  r"\bncop\\d+?\b",
431
439
  r"\bnced\\d+?\b",
432
440
  r"\bsample\b",
@@ -434,13 +442,40 @@ def _gen_default_torrent_table(category: str, cat_default: Table):
434
442
  r"\btrailer\b",
435
443
  r"music video",
436
444
  r"comandotorrents.com",
445
+ ]
446
+
447
+ _gen_default_line(
448
+ torrent_table,
449
+ [
450
+ "These regex values will match any folder where the full name matches the specified values here, comma separated strings.",
451
+ "These regex need to be escaped, that's why you see so many backslashes.",
437
452
  ],
438
- )
453
+ "FileNameExclusionRegex",
454
+ filename_exclusions,
455
+ )
456
+ # Set appropriate file extensions based on category type
457
+ if "lidarr" in category.lower():
458
+ file_extensions = [
459
+ ".mp3",
460
+ ".flac",
461
+ ".m4a",
462
+ ".aac",
463
+ ".ogg",
464
+ ".opus",
465
+ ".wav",
466
+ ".ape",
467
+ ".wma",
468
+ ".!qB",
469
+ ".parts",
470
+ ]
471
+ else:
472
+ file_extensions = [".mp4", ".mkv", ".sub", ".ass", ".srt", ".!qB", ".parts"]
473
+
439
474
  _gen_default_line(
440
475
  torrent_table,
441
476
  "Only files with these extensions will be allowed to be downloaded, comma separated strings or regex, leave it empty to allow all extensions",
442
477
  "FileExtensionAllowlist",
443
- [".mp4", ".mkv", ".sub", ".ass", ".srt", ".!qB", ".parts"],
478
+ file_extensions,
444
479
  )
445
480
  _gen_default_line(
446
481
  torrent_table,
@@ -698,9 +733,15 @@ def _gen_default_search_table(category: str, cat_default: Table):
698
733
  )
699
734
  # SearchByYear doesn't apply to Lidarr (music albums)
700
735
  if "lidarr" not in category.lower():
736
+ if "sonarr" in category.lower():
737
+ search_by_year_comment = (
738
+ "It will order searches by the year the episode was first aired"
739
+ )
740
+ else:
741
+ search_by_year_comment = "It will order searches by the year the movie was released"
701
742
  _gen_default_line(
702
743
  search_table,
703
- "It will order searches by the year the EPISODE was first aired",
744
+ search_by_year_comment,
704
745
  "SearchByYear",
705
746
  True,
706
747
  )
@@ -711,12 +752,14 @@ def _gen_default_search_table(category: str, cat_default: Table):
711
752
  False,
712
753
  )
713
754
  _gen_default_line(
714
- search_table, "Delay between request searches in seconds", "SearchRequestsEvery", 300
755
+ search_table,
756
+ "Delay (in seconds) between checking for new Overseerr/Ombi requests. Does NOT affect delay between individual search commands (use Settings.SearchLoopDelay for that).",
757
+ "SearchRequestsEvery",
758
+ 300,
715
759
  )
716
760
  _gen_default_line(
717
761
  search_table,
718
- "Search movies which already have a file in the database in hopes of finding a "
719
- "better quality version.",
762
+ "Search media which already have a file in hopes of finding a better quality version.",
720
763
  "DoUpgradeSearch",
721
764
  False,
722
765
  )
qBitrr/main.py CHANGED
@@ -86,6 +86,7 @@ class qBitManager:
86
86
  def __init__(self):
87
87
  self._name = "Manager"
88
88
  self.shutdown_event = Event()
89
+ self.database_restart_event = Event() # Signal for coordinated database recovery restart
89
90
  self.qBit_Host = CONFIG.get("qBit.Host", fallback="localhost")
90
91
  self.qBit_Port = CONFIG.get("qBit.Port", fallback=8105)
91
92
  self.qBit_UserName = CONFIG.get("qBit.UserName", fallback=None)
@@ -151,6 +152,8 @@ class qBitManager:
151
152
  self._process_restart_counts: dict[tuple[str, str], list[float]] = (
152
153
  {}
153
154
  ) # (category, role) -> [timestamps]
155
+ self._failed_spawn_attempts: dict[tuple[str, str], int] = {} # Track failed spawn attempts
156
+ self._pending_spawns: list[tuple] = [] # (arr_instance, meta) tuples to retry
154
157
  self.auto_restart_enabled = CONFIG.get("Settings.AutoRestartProcesses", fallback=True)
155
158
  self.max_process_restarts = CONFIG.get("Settings.MaxProcessRestarts", fallback=5)
156
159
  self.process_restart_window = CONFIG.get("Settings.ProcessRestartWindow", fallback=300)
@@ -696,6 +699,8 @@ class qBitManager:
696
699
  self.logger.warning(
697
700
  "Startup thread still running after 60s; managing available workers."
698
701
  )
702
+ started_processes = []
703
+ failed_processes = []
699
704
  for proc in list(self.child_processes):
700
705
  try:
701
706
  # Check if process has already been started
@@ -710,20 +715,153 @@ class qBitManager:
710
715
  )
711
716
  continue
712
717
 
713
- proc.start()
714
718
  meta = self._process_registry.get(proc, {})
715
- self.logger.debug(
716
- "Started %s worker for category '%s'",
719
+ self.logger.info(
720
+ "Starting %s worker for category '%s'...",
717
721
  meta.get("role", "worker"),
718
722
  meta.get("category", "unknown"),
719
723
  )
724
+ proc.start()
725
+
726
+ # Verify process actually started (give it a moment)
727
+ time.sleep(0.1)
728
+ if proc.is_alive():
729
+ self.logger.info(
730
+ "Successfully started %s worker for category '%s' (PID: %s)",
731
+ meta.get("role", "worker"),
732
+ meta.get("category", "unknown"),
733
+ proc.pid,
734
+ )
735
+ started_processes.append((meta.get("role"), meta.get("category")))
736
+ else:
737
+ self.logger.error(
738
+ "Process %s worker for category '%s' started but immediately died (exitcode: %s)",
739
+ meta.get("role", "worker"),
740
+ meta.get("category", "unknown"),
741
+ proc.exitcode,
742
+ )
743
+ failed_processes.append((meta.get("role"), meta.get("category")))
720
744
  except Exception as exc:
721
- self.logger.exception(
722
- "Failed to start worker process %s",
723
- getattr(proc, "name", repr(proc)),
745
+ meta = self._process_registry.get(proc, {})
746
+ self.logger.critical(
747
+ "FAILED to start %s worker for category '%s': %s",
748
+ meta.get("role", "worker"),
749
+ meta.get("category", "unknown"),
750
+ exc,
724
751
  exc_info=exc,
725
752
  )
753
+ failed_processes.append((meta.get("role"), meta.get("category")))
754
+
755
+ # Log summary
756
+ if started_processes:
757
+ self.logger.info(
758
+ "Started %d worker process(es): %s",
759
+ len(started_processes),
760
+ ", ".join(f"{role}({cat})" for role, cat in started_processes),
761
+ )
762
+ if failed_processes:
763
+ self.logger.critical(
764
+ "FAILED to start %d worker process(es): %s - Will retry periodically",
765
+ len(failed_processes),
766
+ ", ".join(f"{role}({cat})" for role, cat in failed_processes),
767
+ )
768
+ # Track failed processes for retry
769
+ for role, category in failed_processes:
770
+ key = (category, role)
771
+ self._failed_spawn_attempts[key] = self._failed_spawn_attempts.get(key, 0) + 1
772
+ # Add to retry queue if not already there
773
+ if hasattr(self, "arr_manager") and self.arr_manager:
774
+ for arr in self.arr_manager.managed_objects.values():
775
+ if arr.category == category:
776
+ # Check if already in pending spawns (avoid duplicates)
777
+ meta = {"category": category, "role": role, "name": arr._name}
778
+ already_pending = any(
779
+ m.get("category") == category and m.get("role") == role
780
+ for _, m in self._pending_spawns
781
+ )
782
+ if not already_pending:
783
+ self._pending_spawns.append((arr, meta))
784
+ break
726
785
  while not self.shutdown_event.is_set():
786
+ # Check for database restart signal
787
+ if self.database_restart_event.is_set():
788
+ self.logger.critical(
789
+ "Database restart signal detected - terminating ALL processes for coordinated restart..."
790
+ )
791
+ # Terminate all child processes
792
+ for proc in list(self.child_processes):
793
+ if proc.is_alive():
794
+ self.logger.warning(
795
+ "Terminating %s process for database recovery",
796
+ self._process_registry.get(proc, {}).get("role", "worker"),
797
+ )
798
+ proc.terminate()
799
+ # Wait for processes to terminate
800
+ time.sleep(2)
801
+ # Force kill any that didn't terminate
802
+ for proc in list(self.child_processes):
803
+ if proc.is_alive():
804
+ self.logger.error(
805
+ "Force killing %s process",
806
+ self._process_registry.get(proc, {}).get("role", "worker"),
807
+ )
808
+ proc.kill()
809
+ # Clear all processes
810
+ self.child_processes.clear()
811
+ self._process_registry.clear()
812
+ # Clear the event
813
+ self.database_restart_event.clear()
814
+ # Restart all Arr instances
815
+ self.logger.critical("Restarting all Arr instances after database recovery...")
816
+ if hasattr(self, "arr_manager") and self.arr_manager:
817
+ for arr in self.arr_manager.managed_objects.values():
818
+ try:
819
+ worker_count, procs = arr.spawn_child_processes()
820
+ for proc in procs:
821
+ role = (
822
+ "search"
823
+ if getattr(arr, "process_search_loop", None) is proc
824
+ else "torrent"
825
+ )
826
+ self._process_registry[proc] = {
827
+ "category": getattr(arr, "category", ""),
828
+ "name": getattr(arr, "_name", ""),
829
+ "role": role,
830
+ }
831
+ # CRITICAL: Actually start the process!
832
+ try:
833
+ proc.start()
834
+ time.sleep(0.1) # Brief pause to let process initialize
835
+ if proc.is_alive():
836
+ self.logger.info(
837
+ "Started %s worker for %s (PID: %s)",
838
+ role,
839
+ arr._name,
840
+ proc.pid,
841
+ )
842
+ else:
843
+ self.logger.error(
844
+ "Respawned %s worker for %s died immediately (exitcode: %s)",
845
+ role,
846
+ arr._name,
847
+ proc.exitcode,
848
+ )
849
+ except Exception as start_exc:
850
+ self.logger.error(
851
+ "Failed to start respawned %s worker for %s: %s",
852
+ role,
853
+ arr._name,
854
+ start_exc,
855
+ )
856
+ self.logger.info(
857
+ "Respawned %d process(es) for %s", worker_count, arr._name
858
+ )
859
+ except Exception as e:
860
+ self.logger.exception(
861
+ "Failed to respawn processes for %s: %s", arr._name, e
862
+ )
863
+ continue
864
+
727
865
  any_alive = False
728
866
  for proc in list(self.child_processes):
729
867
  if proc.is_alive():
@@ -766,6 +904,78 @@ class qBitManager:
766
904
  with contextlib.suppress(ValueError):
767
905
  self.child_processes.remove(proc)
768
906
 
907
+ # Retry failed process spawns
908
+ if self._pending_spawns and self.auto_restart_enabled:
909
+ retry_spawns = []
910
+ for arr, meta in self._pending_spawns:
911
+ category = meta.get("category", "")
912
+ role = meta.get("role", "")
913
+ key = (category, role)
914
+ attempts = self._failed_spawn_attempts.get(key, 0)
915
+
916
+ # Exponential backoff: 30s, 60s, 120s, 240s, 480s (max 8min)
917
+ # Retry indefinitely but with increasing delays
918
+ self.logger.info(
919
+ "Retrying spawn of %s worker for '%s' (attempt #%d)...",
920
+ role,
921
+ category,
922
+ attempts + 1,
923
+ )
924
+
925
+ try:
926
+ worker_count, procs = arr.spawn_child_processes()
927
+ if worker_count > 0:
928
+ for proc in procs:
929
+ proc_role = (
930
+ "search"
931
+ if getattr(arr, "process_search_loop", None) is proc
932
+ else "torrent"
933
+ )
934
+ if proc_role == role: # Only start the one we're retrying
935
+ try:
936
+ proc.start()
937
+ time.sleep(0.1)
938
+ if proc.is_alive():
939
+ self.logger.info(
940
+ "Successfully spawned %s worker for '%s' on retry (PID: %s)",
941
+ role,
942
+ category,
943
+ proc.pid,
944
+ )
945
+ self._process_registry[proc] = meta
946
+ # CRITICAL: Add to child_processes so it's monitored
947
+ if proc not in self.child_processes:
948
+ self.child_processes.append(proc)
949
+ # Clear failed attempts on success
950
+ self._failed_spawn_attempts.pop(key, None)
951
+ else:
952
+ self.logger.error(
953
+ "Retry spawn failed: %s worker for '%s' died immediately",
954
+ role,
955
+ category,
956
+ )
957
+ retry_spawns.append((arr, meta))
958
+ self._failed_spawn_attempts[key] = attempts + 1
959
+ except Exception as exc:
960
+ self.logger.error(
961
+ "Retry spawn failed for %s worker '%s': %s",
962
+ role,
963
+ category,
964
+ exc,
965
+ )
966
+ retry_spawns.append((arr, meta))
967
+ self._failed_spawn_attempts[key] = attempts + 1
968
+ except Exception as exc:
969
+ self.logger.error(
970
+ "Failed to respawn processes for retry: %s",
971
+ exc,
972
+ )
973
+ retry_spawns.append((arr, meta))
974
+ self._failed_spawn_attempts[key] = attempts + 1
975
+
976
+ # Update pending spawns list
977
+ self._pending_spawns = retry_spawns
978
+
769
979
  if not self.child_processes:
770
980
  if not any_alive:
771
981
  break