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/arss.py +416 -70
- qBitrr/bundled_data.py +2 -2
- qBitrr/config.py +1 -1
- qBitrr/gen_config.py +84 -41
- qBitrr/main.py +216 -6
- qBitrr/static/assets/ConfigView.js +3 -3
- qBitrr/static/assets/ConfigView.js.map +1 -1
- {qbitrr2-5.7.0.dist-info → qbitrr2-5.7.1.dist-info}/METADATA +1 -1
- {qbitrr2-5.7.0.dist-info → qbitrr2-5.7.1.dist-info}/RECORD +13 -13
- {qbitrr2-5.7.0.dist-info → qbitrr2-5.7.1.dist-info}/WHEEL +0 -0
- {qbitrr2-5.7.0.dist-info → qbitrr2-5.7.1.dist-info}/entry_points.txt +0 -0
- {qbitrr2-5.7.0.dist-info → qbitrr2-5.7.1.dist-info}/licenses/LICENSE +0 -0
- {qbitrr2-5.7.0.dist-info → qbitrr2-5.7.1.dist-info}/top_level.txt +0 -0
qBitrr/bundled_data.py
CHANGED
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=
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
"
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
"
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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.
|
|
716
|
-
"
|
|
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.
|
|
722
|
-
|
|
723
|
-
|
|
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
|