qBitrr2 4.10.9__py3-none-any.whl → 5.4.5__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.
Files changed (59) hide show
  1. qBitrr/arss.py +2165 -889
  2. qBitrr/auto_update.py +382 -0
  3. qBitrr/bundled_data.py +3 -2
  4. qBitrr/config.py +20 -3
  5. qBitrr/db_lock.py +79 -0
  6. qBitrr/env_config.py +19 -7
  7. qBitrr/gen_config.py +287 -26
  8. qBitrr/logger.py +87 -3
  9. qBitrr/main.py +453 -101
  10. qBitrr/search_activity_store.py +88 -0
  11. qBitrr/static/assets/ArrView.js +2 -0
  12. qBitrr/static/assets/ArrView.js.map +1 -0
  13. qBitrr/static/assets/ConfigView.js +4 -0
  14. qBitrr/static/assets/ConfigView.js.map +1 -0
  15. qBitrr/static/assets/LogsView.js +230 -0
  16. qBitrr/static/assets/LogsView.js.map +1 -0
  17. qBitrr/static/assets/ProcessesView.js +2 -0
  18. qBitrr/static/assets/ProcessesView.js.map +1 -0
  19. qBitrr/static/assets/app.css +1 -0
  20. qBitrr/static/assets/app.js +11 -0
  21. qBitrr/static/assets/app.js.map +1 -0
  22. qBitrr/static/assets/build.svg +3 -0
  23. qBitrr/static/assets/check-mark.svg +5 -0
  24. qBitrr/static/assets/close.svg +4 -0
  25. qBitrr/static/assets/download.svg +5 -0
  26. qBitrr/static/assets/gear.svg +5 -0
  27. qBitrr/static/assets/lidarr.svg +1 -0
  28. qBitrr/static/assets/live-streaming.svg +8 -0
  29. qBitrr/static/assets/log.svg +3 -0
  30. qBitrr/static/assets/plus.svg +4 -0
  31. qBitrr/static/assets/process.svg +15 -0
  32. qBitrr/static/assets/react-select.esm.js +14 -0
  33. qBitrr/static/assets/react-select.esm.js.map +1 -0
  34. qBitrr/static/assets/refresh-arrow.svg +3 -0
  35. qBitrr/static/assets/table.js +23 -0
  36. qBitrr/static/assets/table.js.map +1 -0
  37. qBitrr/static/assets/trash.svg +8 -0
  38. qBitrr/static/assets/up-arrow.svg +3 -0
  39. qBitrr/static/assets/useInterval.js +2 -0
  40. qBitrr/static/assets/useInterval.js.map +1 -0
  41. qBitrr/static/assets/vendor.js +33 -0
  42. qBitrr/static/assets/vendor.js.map +1 -0
  43. qBitrr/static/assets/visibility.svg +9 -0
  44. qBitrr/static/index.html +47 -0
  45. qBitrr/static/manifest.json +23 -0
  46. qBitrr/static/sw.js +105 -0
  47. qBitrr/static/vite.svg +1 -0
  48. qBitrr/tables.py +44 -0
  49. qBitrr/utils.py +82 -15
  50. qBitrr/versioning.py +136 -0
  51. qBitrr/webui.py +2612 -0
  52. qbitrr2-5.4.5.dist-info/METADATA +1116 -0
  53. qbitrr2-5.4.5.dist-info/RECORD +61 -0
  54. {qBitrr2-4.10.9.dist-info → qbitrr2-5.4.5.dist-info}/WHEEL +1 -1
  55. qBitrr2-4.10.9.dist-info/METADATA +0 -233
  56. qBitrr2-4.10.9.dist-info/RECORD +0 -19
  57. {qBitrr2-4.10.9.dist-info → qbitrr2-5.4.5.dist-info}/entry_points.txt +0 -0
  58. {qBitrr2-4.10.9.dist-info → qbitrr2-5.4.5.dist-info/licenses}/LICENSE +0 -0
  59. {qBitrr2-4.10.9.dist-info → qbitrr2-5.4.5.dist-info}/top_level.txt +0 -0
qBitrr/arss.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import atexit
3
4
  import contextlib
4
5
  import itertools
5
6
  import logging
@@ -19,9 +20,9 @@ import qbittorrentapi
19
20
  import qbittorrentapi.exceptions
20
21
  import requests
21
22
  from packaging import version as version_parser
22
- from peewee import SqliteDatabase
23
- from pyarr import RadarrAPI, SonarrAPI
24
- from pyarr.exceptions import PyarrResourceNotFound
23
+ from peewee import Model, SqliteDatabase
24
+ from pyarr import LidarrAPI, RadarrAPI, SonarrAPI
25
+ from pyarr.exceptions import PyarrResourceNotFound, PyarrServerError
25
26
  from pyarr.types import JsonObject
26
27
  from qbittorrentapi import TorrentDictionary, TorrentStates
27
28
  from ujson import JSONDecodeError
@@ -31,7 +32,6 @@ from qBitrr.config import (
31
32
  AUTO_PAUSE_RESUME,
32
33
  COMPLETED_DOWNLOAD_FOLDER,
33
34
  CONFIG,
34
- ENABLE_LOGS,
35
35
  FAILED_CATEGORY,
36
36
  FREE_SPACE,
37
37
  FREE_SPACE_FOLDER,
@@ -51,9 +51,16 @@ from qBitrr.errors import (
51
51
  SkipException,
52
52
  UnhandledError,
53
53
  )
54
- from qBitrr.home_path import HOME_PATH
55
54
  from qBitrr.logger import run_logs
55
+ from qBitrr.search_activity_store import (
56
+ clear_search_activity,
57
+ fetch_search_activities,
58
+ record_search_activity,
59
+ )
56
60
  from qBitrr.tables import (
61
+ AlbumFilesModel,
62
+ AlbumQueueModel,
63
+ ArtistFilesModel,
57
64
  EpisodeFilesModel,
58
65
  EpisodeQueueModel,
59
66
  FilesQueued,
@@ -61,22 +68,65 @@ from qBitrr.tables import (
61
68
  MoviesFilesModel,
62
69
  SeriesFilesModel,
63
70
  TorrentLibrary,
71
+ TrackFilesModel,
64
72
  )
65
73
  from qBitrr.utils import (
66
74
  ExpiringSet,
67
75
  absolute_file_paths,
76
+ format_bytes,
68
77
  has_internet,
69
78
  parse_size,
70
79
  validate_and_return_torrent_file,
80
+ with_retry,
71
81
  )
72
82
 
83
+
84
+ def _mask_secret(secret: str | None) -> str:
85
+ if not secret:
86
+ return ""
87
+ return "[redacted]"
88
+
89
+
90
+ def _normalize_media_status(value: int | str | None) -> str:
91
+ """Normalise Overseerr media status values across API versions."""
92
+ int_mapping = {
93
+ 1: "UNKNOWN",
94
+ 2: "PENDING",
95
+ 3: "PROCESSING",
96
+ 4: "PARTIALLY_AVAILABLE",
97
+ 5: "AVAILABLE",
98
+ 6: "DELETED",
99
+ }
100
+ if value is None:
101
+ return "UNKNOWN"
102
+ if isinstance(value, str):
103
+ token = value.strip().upper().replace("-", "_").replace(" ", "_")
104
+ # Newer Overseerr builds can return strings such as "PARTIALLY_AVAILABLE"
105
+ return token or "UNKNOWN"
106
+ try:
107
+ return int_mapping.get(int(value), "UNKNOWN")
108
+ except (TypeError, ValueError):
109
+ return "UNKNOWN"
110
+
111
+
112
+ def _is_media_available(status: str) -> bool:
113
+ return status in {"AVAILABLE", "DELETED"}
114
+
115
+
116
+ def _is_media_processing(status: str) -> bool:
117
+ return status in {"PROCESSING", "PARTIALLY_AVAILABLE"}
118
+
119
+
73
120
  if TYPE_CHECKING:
74
121
  from qBitrr.main import qBitManager
75
122
 
76
123
 
77
124
  class Arr:
78
125
  def __init__(
79
- self, name: str, manager: ArrManager, client_cls: type[Callable | RadarrAPI | SonarrAPI]
126
+ self,
127
+ name: str,
128
+ manager: ArrManager,
129
+ client_cls: type[Callable | RadarrAPI | SonarrAPI | LidarrAPI],
80
130
  ):
81
131
  if name in manager.groups:
82
132
  raise OSError(f"Group '{name}' has already been registered.")
@@ -94,19 +144,7 @@ class Arr:
94
144
  self.manager = manager
95
145
  self._LOG_LEVEL = self.manager.qbit_manager.logger.level
96
146
  self.logger = logging.getLogger(f"qBitrr.{self._name}")
97
- if ENABLE_LOGS:
98
- logs_folder = HOME_PATH.joinpath("logs")
99
- logs_folder.mkdir(parents=True, exist_ok=True)
100
- logs_folder.chmod(mode=0o777)
101
- logfile = logs_folder.joinpath(self._name + ".log")
102
- if pathlib.Path(logfile).is_file():
103
- logold = logs_folder.joinpath(self._name + ".log.old")
104
- if pathlib.Path(logold).exists():
105
- logold.unlink()
106
- logfile.rename(logold)
107
- fh = logging.FileHandler(logfile)
108
- self.logger.addHandler(fh)
109
- run_logs(self.logger)
147
+ run_logs(self.logger, self._name)
110
148
 
111
149
  if not QBIT_DISABLED:
112
150
  categories = self.manager.qbit_manager.client.torrent_categories.categories
@@ -191,19 +229,33 @@ class Arr:
191
229
  self.seeding_mode_global_bad_tracker_msg = CONFIG.get(
192
230
  f"{name}.Torrent.SeedingMode.RemoveTrackerWithMessage", fallback=[]
193
231
  )
232
+ if isinstance(self.seeding_mode_global_bad_tracker_msg, str):
233
+ self.seeding_mode_global_bad_tracker_msg = [self.seeding_mode_global_bad_tracker_msg]
234
+ else:
235
+ self.seeding_mode_global_bad_tracker_msg = list(
236
+ self.seeding_mode_global_bad_tracker_msg
237
+ )
194
238
 
195
239
  self.monitored_trackers = CONFIG.get(f"{name}.Torrent.Trackers", fallback=[])
196
240
  self._remove_trackers_if_exists: set[str] = {
197
- i.get("URI") for i in self.monitored_trackers if i.get("RemoveIfExists") is True
241
+ uri
242
+ for i in self.monitored_trackers
243
+ if i.get("RemoveIfExists") is True and (uri := (i.get("URI") or "").strip())
198
244
  }
199
245
  self._monitored_tracker_urls: set[str] = {
200
- r
246
+ uri
201
247
  for i in self.monitored_trackers
202
- if not (r := i.get("URI")) not in self._remove_trackers_if_exists
248
+ if (uri := (i.get("URI") or "").strip()) and uri not in self._remove_trackers_if_exists
203
249
  }
204
250
  self._add_trackers_if_missing: set[str] = {
205
- i.get("URI") for i in self.monitored_trackers if i.get("AddTrackerIfMissing") is True
251
+ uri
252
+ for i in self.monitored_trackers
253
+ if i.get("AddTrackerIfMissing") is True and (uri := (i.get("URI") or "").strip())
206
254
  }
255
+ self._normalized_bad_tracker_msgs: set[str] = {
256
+ msg.lower() for msg in self.seeding_mode_global_bad_tracker_msg if isinstance(msg, str)
257
+ }
258
+
207
259
  if (
208
260
  self.auto_delete is True
209
261
  and not self.completed_folder.parent.exists()
@@ -250,7 +302,7 @@ class Arr:
250
302
 
251
303
  self.do_not_remove_slow = CONFIG.get(f"{name}.Torrent.DoNotRemoveSlow", fallback=False)
252
304
  self.re_search_stalled = CONFIG.get(f"{name}.Torrent.ReSearchStalled", fallback=False)
253
- self.stalled_delay = CONFIG.get(f"{name}.Torrent.StalledDelay", fallback=0)
305
+ self.stalled_delay = CONFIG.get(f"{name}.Torrent.StalledDelay", fallback=15)
254
306
  self.allowed_stalled = True if self.stalled_delay != -1 else False
255
307
 
256
308
  self.search_current_year = None
@@ -258,6 +310,7 @@ class Arr:
258
310
  self._delta = 1
259
311
  else:
260
312
  self._delta = -1
313
+
261
314
  self._app_data_folder = APPDATA_FOLDER
262
315
  self.search_db_file = self._app_data_folder.joinpath(f"{self._name}.db")
263
316
 
@@ -267,7 +320,14 @@ class Arr:
267
320
  self.overseerr_requests = CONFIG.get(
268
321
  f"{name}.EntrySearch.Overseerr.SearchOverseerrRequests", fallback=False
269
322
  )
270
- self.series_search = CONFIG.get(f"{name}.EntrySearch.SearchBySeries", fallback=False)
323
+ # SearchBySeries can be: True (always series), False (always episode), or "smart" (automatic)
324
+ series_search_config = CONFIG.get(f"{name}.EntrySearch.SearchBySeries", fallback=False)
325
+ if isinstance(series_search_config, str) and series_search_config.lower() == "smart":
326
+ self.series_search = "smart"
327
+ elif series_search_config in (True, "true", "True", "TRUE", 1):
328
+ self.series_search = True
329
+ else:
330
+ self.series_search = False
271
331
  if self.ombi_search_requests:
272
332
  self.ombi_uri = CONFIG.get_or_raise(f"{name}.EntrySearch.Ombi.OmbiURI")
273
333
  self.ombi_api_key = CONFIG.get_or_raise(f"{name}.EntrySearch.Ombi.OmbiAPIKey")
@@ -339,6 +399,18 @@ class Arr:
339
399
  self.type = "sonarr"
340
400
  elif isinstance(self.client, RadarrAPI):
341
401
  self.type = "radarr"
402
+ elif isinstance(self.client, LidarrAPI):
403
+ self.type = "lidarr"
404
+
405
+ # Disable unsupported features for Lidarr
406
+ if self.type == "lidarr":
407
+ self.search_by_year = False
408
+ self.ombi_search_requests = False
409
+ self.overseerr_requests = False
410
+ self.ombi_uri = None
411
+ self.ombi_api_key = None
412
+ self.overseerr_uri = None
413
+ self.overseerr_api_key = None
342
414
 
343
415
  try:
344
416
  version_info = self.client.get_update()
@@ -363,10 +435,15 @@ class Arr:
363
435
  and self.main_quality_profiles
364
436
  and self.temp_quality_profiles
365
437
  )
438
+ self.keep_temp_profile = CONFIG.get(f"{name}.EntrySearch.KeepTempProfile", fallback=False)
366
439
 
367
440
  if self.use_temp_for_missing:
368
441
  self.temp_quality_profile_ids = self.parse_quality_profiles()
369
442
 
443
+ # Cache for valid quality profile IDs to avoid repeated API calls and warnings
444
+ self._quality_profile_cache: dict[int, dict] = {}
445
+ self._invalid_quality_profiles: set[int] = set()
446
+
370
447
  if self.rss_sync_timer > 0:
371
448
  self.rss_sync_timer_last_checked = datetime(1970, 1, 1)
372
449
  else:
@@ -398,7 +475,12 @@ class Arr:
398
475
  self.missing_files_post_delete = set()
399
476
  self.downloads_with_bad_error_message_blocklist = set()
400
477
  self.needs_cleanup = False
401
- self.recently_queue = {}
478
+
479
+ self.last_search_description: str | None = None
480
+ self.last_search_timestamp: str | None = None
481
+ self.queue_active_count: int = 0
482
+ self.category_torrent_count: int = 0
483
+ self.free_space_tagged_count: int = 0
402
484
 
403
485
  self.timed_ignore_cache = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than)
404
486
  self.timed_ignore_cache_2 = ExpiringSet(
@@ -412,6 +494,7 @@ class Arr:
412
494
  self.cleaned_torrents = set()
413
495
  self.search_api_command = None
414
496
 
497
+ self._webui_db_loaded = False
415
498
  self.manager.completed_folders.add(self.completed_folder)
416
499
  self.manager.category_allowlist.add(self.category)
417
500
 
@@ -431,7 +514,7 @@ class Arr:
431
514
  self.re_search,
432
515
  self.category,
433
516
  self.uri,
434
- self.apikey,
517
+ _mask_secret(self.apikey),
435
518
  self.refresh_downloads_timer,
436
519
  self.rss_sync_timer,
437
520
  )
@@ -477,24 +560,34 @@ class Arr:
477
560
  self.logger.debug("Script Config: SearchOmbiRequests=%s", self.ombi_search_requests)
478
561
  if self.ombi_search_requests:
479
562
  self.logger.debug("Script Config: OmbiURI=%s", self.ombi_uri)
480
- self.logger.debug("Script Config: OmbiAPIKey=%s", self.ombi_api_key)
563
+ self.logger.debug("Script Config: OmbiAPIKey=%s", _mask_secret(self.ombi_api_key))
481
564
  self.logger.debug("Script Config: ApprovedOnly=%s", self.ombi_approved_only)
482
565
  self.logger.debug(
483
566
  "Script Config: SearchOverseerrRequests=%s", self.overseerr_requests
484
567
  )
485
568
  if self.overseerr_requests:
486
569
  self.logger.debug("Script Config: OverseerrURI=%s", self.overseerr_uri)
487
- self.logger.debug("Script Config: OverseerrAPIKey=%s", self.overseerr_api_key)
570
+ self.logger.debug(
571
+ "Script Config: OverseerrAPIKey=%s", _mask_secret(self.overseerr_api_key)
572
+ )
488
573
  if self.ombi_search_requests or self.overseerr_requests:
489
574
  self.logger.debug(
490
575
  "Script Config: SearchRequestsEvery=%s", self.search_requests_every_x_seconds
491
576
  )
492
577
 
493
- if self.type == "sonarr":
494
- if self.quality_unmet_search or self.do_upgrade_search:
495
- self.search_api_command = "SeriesSearch"
496
- else:
497
- self.search_api_command = "MissingEpisodeSearch"
578
+ if self.type == "sonarr":
579
+ if (
580
+ self.quality_unmet_search
581
+ or self.do_upgrade_search
582
+ or self.custom_format_unmet_search
583
+ or self.series_search == True
584
+ ):
585
+ self.search_api_command = "SeriesSearch"
586
+ elif self.series_search == "smart":
587
+ # In smart mode, the command will be determined dynamically
588
+ self.search_api_command = "SeriesSearch" # Default, will be overridden per search
589
+ else:
590
+ self.search_api_command = "MissingEpisodeSearch"
498
591
 
499
592
  if not QBIT_DISABLED and not TAGLESS:
500
593
  self.manager.qbit_manager.client.torrents_create_tags(
@@ -508,13 +601,83 @@ class Arr:
508
601
  elif not QBIT_DISABLED and TAGLESS:
509
602
  self.manager.qbit_manager.client.torrents_create_tags(["qBitrr-ignored"])
510
603
  self.search_setup_completed = False
511
- self.model_file: EpisodeFilesModel | MoviesFilesModel = None
512
- self.series_file_model: SeriesFilesModel = None
513
- self.model_queue: EpisodeQueueModel | MovieQueueModel = None
514
- self.persistent_queue: FilesQueued = None
515
- self.torrents: TorrentLibrary = None
604
+ self.model_file: Model | None = None
605
+ self.series_file_model: Model | None = None
606
+ self.model_queue: Model | None = None
607
+ self.persistent_queue: Model | None = None
608
+ self.track_file_model: Model | None = None
609
+ self.torrents: TorrentLibrary | None = None
610
+ self.torrent_db: SqliteDatabase | None = None
611
+ self.db: SqliteDatabase | None = None
612
+ # Initialize search mode (and torrent tag-emulation DB in TAGLESS)
613
+ # early and fail fast if it cannot be set up.
614
+ self.register_search_mode()
615
+ atexit.register(
616
+ lambda: (
617
+ hasattr(self, "db") and self.db and not self.db.is_closed() and self.db.close()
618
+ )
619
+ )
620
+ atexit.register(
621
+ lambda: (
622
+ hasattr(self, "torrent_db")
623
+ and self.torrent_db
624
+ and not self.torrent_db.is_closed()
625
+ and self.torrent_db.close()
626
+ )
627
+ )
516
628
  self.logger.hnotice("Starting %s monitor", self._name)
517
629
 
630
+ @staticmethod
631
+ def _humanize_request_tag(tag: str) -> str | None:
632
+ if not tag:
633
+ return None
634
+ cleaned = tag.strip().strip(": ")
635
+ cleaned = cleaned.strip("[]")
636
+ upper = cleaned.upper()
637
+ if "OVERSEERR" in upper:
638
+ return "Overseerr request"
639
+ if "OMBI" in upper:
640
+ return "Ombi request"
641
+ if "PRIORITY SEARCH - TODAY" in upper:
642
+ return "Today's releases"
643
+ return cleaned or None
644
+
645
+ def _record_search_activity(
646
+ self,
647
+ description: str | None,
648
+ *,
649
+ context: str | None = None,
650
+ detail: str | None = None,
651
+ ) -> None:
652
+ self.last_search_description = description
653
+ self.last_search_timestamp = datetime.now(timezone.utc).isoformat()
654
+ if detail == "loop-complete":
655
+ detail = "Searches completed, waiting till next loop"
656
+ elif detail == "no-pending-searches":
657
+ detail = "No pending searches"
658
+ self.last_search_description = None if description is None else description
659
+ segments = [
660
+ segment for segment in (context, self.last_search_description, detail) if segment
661
+ ]
662
+ if segments and segments.count("No pending searches") > 1:
663
+ seen = set()
664
+ deduped = []
665
+ for segment in segments:
666
+ key = segment.strip().lower()
667
+ if key == "no pending searches" and key in seen:
668
+ continue
669
+ seen.add(key)
670
+ deduped.append(segment)
671
+ segments = deduped
672
+ if not segments:
673
+ return
674
+ self.last_search_description = " · ".join(segments)
675
+ record_search_activity(
676
+ str(self.category),
677
+ self.last_search_description,
678
+ self.last_search_timestamp,
679
+ )
680
+
518
681
  @property
519
682
  def is_alive(self) -> bool:
520
683
  try:
@@ -579,17 +742,29 @@ class Arr:
579
742
  if tag == "qBitrr-ignored":
580
743
  return_value = "qBitrr-ignored" in torrent.tags
581
744
  else:
582
- condition = (
583
- self.torrents.Hash == torrent.hash & self.torrents.Category == torrent.category
745
+ query = (
746
+ self.torrents.select()
747
+ .where(
748
+ (self.torrents.Hash == torrent.hash)
749
+ & (self.torrents.Category == torrent.category)
750
+ )
751
+ .execute()
752
+ )
753
+ if not query:
754
+ self.torrents.insert(
755
+ Hash=torrent.hash, Category=torrent.category
756
+ ).on_conflict_ignore().execute()
757
+ condition = (self.torrents.Hash == torrent.hash) & (
758
+ self.torrents.Category == torrent.category
584
759
  )
585
760
  if tag == "qBitrr-allowed_seeding":
586
- condition &= self.torrents.AllowedSeeding is True
761
+ condition &= self.torrents.AllowedSeeding == True
587
762
  elif tag == "qBitrr-imported":
588
- condition &= self.torrents.Imported is True
763
+ condition &= self.torrents.Imported == True
589
764
  elif tag == "qBitrr-allowed_stalled":
590
- condition &= self.torrents.AllowedStalled is True
765
+ condition &= self.torrents.AllowedStalled == True
591
766
  elif tag == "qBitrr-free_space_paused":
592
- condition &= self.torrents.FreeSpacePaused is True
767
+ condition &= self.torrents.FreeSpacePaused == True
593
768
  query = self.torrents.select().where(condition).execute()
594
769
  if query:
595
770
  return_value = True
@@ -611,13 +786,13 @@ class Arr:
611
786
  def remove_tags(self, torrent: TorrentDictionary, tags: list) -> None:
612
787
  for tag in tags:
613
788
  self.logger.trace("Removing tag %s from %s", tag, torrent.name)
614
- if TAGLESS:
789
+ if TAGLESS:
790
+ for tag in tags:
615
791
  query = (
616
792
  self.torrents.select()
617
793
  .where(
618
- self.torrents.Hash
619
- == torrent.hash & self.torrents.Category
620
- == torrent.category
794
+ (self.torrents.Hash == torrent.hash)
795
+ & (self.torrents.Category == torrent.category)
621
796
  )
622
797
  .execute()
623
798
  )
@@ -627,48 +802,48 @@ class Arr:
627
802
  ).on_conflict_ignore().execute()
628
803
  if tag == "qBitrr-allowed_seeding":
629
804
  self.torrents.update(AllowedSeeding=False).where(
630
- self.torrents.Hash
631
- == torrent.hash & self.torrents.Category
632
- == torrent.category
633
- )
805
+ (self.torrents.Hash == torrent.hash)
806
+ & (self.torrents.Category == torrent.category)
807
+ ).execute()
634
808
  elif tag == "qBitrr-imported":
635
809
  self.torrents.update(Imported=False).where(
636
- self.torrents.Hash
637
- == torrent.hash & self.torrents.Category
638
- == torrent.category
639
- )
810
+ (self.torrents.Hash == torrent.hash)
811
+ & (self.torrents.Category == torrent.category)
812
+ ).execute()
640
813
  elif tag == "qBitrr-allowed_stalled":
641
814
  self.torrents.update(AllowedStalled=False).where(
642
- self.torrents.Hash
643
- == torrent.hash & self.torrents.Category
644
- == torrent.category
645
- )
815
+ (self.torrents.Hash == torrent.hash)
816
+ & (self.torrents.Category == torrent.category)
817
+ ).execute()
646
818
  elif tag == "qBitrr-free_space_paused":
647
819
  self.torrents.update(FreeSpacePaused=False).where(
648
- self.torrents.Hash
649
- == torrent.hash & self.torrents.Category
650
- == torrent.category
651
- )
652
- else:
653
- if tag == "qBitrr-allowed_seeding":
654
- torrent.remove_tags(["qBitrr-allowed_seeding"])
655
- elif tag == "qBitrr-imported":
656
- torrent.remove_tags(["qBitrr-imported"])
657
- elif tag == "qBitrr-allowed_stalled":
658
- torrent.remove_tags(["qBitrr-allowed_stalled"])
659
- elif tag == "qBitrr-free_space_paused":
660
- torrent.remove_tags(["qBitrr-free_space_paused"])
820
+ (self.torrents.Hash == torrent.hash)
821
+ & (self.torrents.Category == torrent.category)
822
+ ).execute()
823
+ else:
824
+ with contextlib.suppress(Exception):
825
+ with_retry(
826
+ lambda: torrent.remove_tags(tags),
827
+ retries=3,
828
+ backoff=0.5,
829
+ max_backoff=3,
830
+ exceptions=(
831
+ qbittorrentapi.exceptions.APIError,
832
+ qbittorrentapi.exceptions.APIConnectionError,
833
+ requests.exceptions.RequestException,
834
+ ),
835
+ )
661
836
 
662
837
  def add_tags(self, torrent: TorrentDictionary, tags: list) -> None:
663
838
  for tag in tags:
664
839
  self.logger.trace("Adding tag %s from %s", tag, torrent.name)
665
- if TAGLESS:
840
+ if TAGLESS:
841
+ for tag in tags:
666
842
  query = (
667
843
  self.torrents.select()
668
844
  .where(
669
- self.torrents.Hash
670
- == torrent.hash & self.torrents.Category
671
- == torrent.category
845
+ (self.torrents.Hash == torrent.hash)
846
+ & (self.torrents.Category == torrent.category)
672
847
  )
673
848
  .execute()
674
849
  )
@@ -678,137 +853,138 @@ class Arr:
678
853
  ).on_conflict_ignore().execute()
679
854
  if tag == "qBitrr-allowed_seeding":
680
855
  self.torrents.update(AllowedSeeding=True).where(
681
- self.torrents.Hash
682
- == torrent.hash & self.torrents.Category
683
- == torrent.category
684
- )
856
+ (self.torrents.Hash == torrent.hash)
857
+ & (self.torrents.Category == torrent.category)
858
+ ).execute()
685
859
  elif tag == "qBitrr-imported":
686
860
  self.torrents.update(Imported=True).where(
687
- self.torrents.Hash
688
- == torrent.hash & self.torrents.Category
689
- == torrent.category
690
- )
861
+ (self.torrents.Hash == torrent.hash)
862
+ & (self.torrents.Category == torrent.category)
863
+ ).execute()
691
864
  elif tag == "qBitrr-allowed_stalled":
692
865
  self.torrents.update(AllowedStalled=True).where(
693
- self.torrents.Hash
694
- == torrent.hash & self.torrents.Category
695
- == torrent.category
696
- )
866
+ (self.torrents.Hash == torrent.hash)
867
+ & (self.torrents.Category == torrent.category)
868
+ ).execute()
697
869
  elif tag == "qBitrr-free_space_paused":
698
870
  self.torrents.update(FreeSpacePaused=True).where(
699
- self.torrents.Hash
700
- == torrent.hash & self.torrents.Category
701
- == torrent.category
702
- )
703
- else:
704
- if tag == "qBitrr-allowed_seeding":
705
- torrent.add_tags(["qBitrr-allowed_seeding"])
706
- elif tag == "qBitrr-imported":
707
- torrent.add_tags(["qBitrr-imported"])
708
- elif tag == "qBitrr-allowed_stalled":
709
- torrent.add_tags(["qBitrr-allowed_stalled"])
710
- elif tag == "qBitrr-free_space_paused":
711
- torrent.add_tags(["qBitrr-free_space_paused"])
712
-
713
- def _get_models(
714
- self,
715
- ) -> tuple[
716
- type[EpisodeFilesModel] | type[MoviesFilesModel],
717
- type[EpisodeQueueModel] | type[MovieQueueModel],
718
- type[SeriesFilesModel] | None,
719
- type[TorrentLibrary] | None,
720
- ]:
721
- if self.type == "sonarr":
722
- if self.series_search:
723
- return (
724
- EpisodeFilesModel,
725
- EpisodeQueueModel,
726
- SeriesFilesModel,
727
- TorrentLibrary if TAGLESS else None,
728
- )
729
- return EpisodeFilesModel, EpisodeQueueModel, None, TorrentLibrary if TAGLESS else None
730
- elif self.type == "radarr":
731
- return MoviesFilesModel, MovieQueueModel, None, TorrentLibrary if TAGLESS else None
871
+ (self.torrents.Hash == torrent.hash)
872
+ & (self.torrents.Category == torrent.category)
873
+ ).execute()
732
874
  else:
733
- raise UnhandledError(f"Well you shouldn't have reached here, Arr.type={self.type}")
875
+ with contextlib.suppress(Exception):
876
+ with_retry(
877
+ lambda: torrent.add_tags(tags),
878
+ retries=3,
879
+ backoff=0.5,
880
+ max_backoff=3,
881
+ exceptions=(
882
+ qbittorrentapi.exceptions.APIError,
883
+ qbittorrentapi.exceptions.APIConnectionError,
884
+ requests.exceptions.RequestException,
885
+ ),
886
+ )
734
887
 
735
888
  def _get_oversee_requests_all(self) -> dict[str, set]:
736
889
  try:
737
- key = "approved" if self.overseerr_approved_only else "unavailable"
738
890
  data = defaultdict(set)
739
- response = self.session.get(
740
- url=f"{self.overseerr_uri}/api/v1/request",
741
- headers={"X-Api-Key": self.overseerr_api_key},
742
- params={"take": 100, "skip": 0, "sort": "added", "filter": key},
743
- timeout=2,
744
- )
745
- response = response.json().get("results", [])
891
+ key = "approved" if self.overseerr_approved_only else "unavailable"
892
+ take = 100
893
+ skip = 0
746
894
  type_ = None
747
895
  if self.type == "radarr":
748
896
  type_ = "movie"
749
897
  elif self.type == "sonarr":
750
898
  type_ = "tv"
751
899
  _now = datetime.now()
752
- for entry in response:
753
- type__ = entry.get("type")
754
- if type__ == "movie":
755
- id__ = entry.get("media", {}).get("tmdbId")
756
- elif type__ == "tv":
757
- id__ = entry.get("media", {}).get("tvdbId")
758
- if type_ != type__:
759
- continue
760
- if self.overseerr_is_4k and entry.get("is4k"):
761
- if self.overseerr_approved_only:
762
- if entry.get("media", {}).get("status4k") != 3:
900
+ while True:
901
+ response = self.session.get(
902
+ url=f"{self.overseerr_uri}/api/v1/request",
903
+ headers={"X-Api-Key": self.overseerr_api_key},
904
+ params={"take": take, "skip": skip, "sort": "added", "filter": key},
905
+ timeout=5,
906
+ )
907
+ response.raise_for_status()
908
+ payload = response.json()
909
+ results = []
910
+ if isinstance(payload, list):
911
+ results = payload
912
+ elif isinstance(payload, dict):
913
+ if isinstance(payload.get("results"), list):
914
+ results = payload["results"]
915
+ elif isinstance(payload.get("data"), list):
916
+ results = payload["data"]
917
+ if not results:
918
+ break
919
+ for entry in results:
920
+ type__ = entry.get("type")
921
+ if type__ == "movie":
922
+ id__ = entry.get("media", {}).get("tmdbId")
923
+ elif type__ == "tv":
924
+ id__ = entry.get("media", {}).get("tvdbId")
925
+ else:
926
+ id__ = None
927
+ if not id__ or type_ != type__:
928
+ continue
929
+ media = entry.get("media") or {}
930
+ status_key = "status4k" if entry.get("is4k") else "status"
931
+ status_value = _normalize_media_status(media.get(status_key))
932
+ if entry.get("is4k"):
933
+ if not self.overseerr_is_4k:
763
934
  continue
764
- elif entry.get("media", {}).get("status4k") == 5:
935
+ elif self.overseerr_is_4k:
765
936
  continue
766
- elif not self.overseerr_is_4k and not entry.get("is4k"):
767
937
  if self.overseerr_approved_only:
768
- if entry.get("media", {}).get("status") != 3:
938
+ if not _is_media_processing(status_value):
769
939
  continue
770
- elif entry.get("media", {}).get("status") == 5:
771
- continue
772
- else:
773
- continue
774
- if id__ in self.overseerr_requests_release_cache:
775
- date = self.overseerr_requests_release_cache[id__]
776
- else:
777
- date = datetime(day=1, month=1, year=1970)
778
- date_string_backup = f"{_now.year}-{_now.month:02}-{_now.day:02}"
779
- date_string = None
780
- try:
781
- if type_ == "movie":
782
- _entry_data = self.session.get(
783
- url=f"{self.overseerr_uri}/api/v1/movies/{id__}",
784
- headers={"X-Api-Key": self.overseerr_api_key},
785
- timeout=2,
786
- )
787
- date_string = _entry_data.json().get("releaseDate")
788
- elif type__ == "tv":
789
- _entry_data = self.session.get(
790
- url=f"{self.overseerr_uri}/api/v1/tv/{id__}",
791
- headers={"X-Api-Key": self.overseerr_api_key},
792
- timeout=2,
793
- )
794
- # We don't do granular (episode/season) searched here so no need to
795
- # suppose them
796
- date_string = _entry_data.json().get("firstAirDate")
797
- if not date_string:
798
- date_string = date_string_backup
799
- date = datetime.strptime(date_string, "%Y-%m-%d")
800
- if date > _now:
940
+ else:
941
+ if _is_media_available(status_value):
801
942
  continue
802
- self.overseerr_requests_release_cache[id__] = date
803
- except Exception as e:
804
- self.logger.warning("Failed to query release date from Overseerr: %s", e)
805
- if media := entry.get("media"):
806
- if imdbId := media.get("imdbId"):
807
- data["ImdbId"].add(imdbId)
808
- if self.type == "sonarr" and (tvdbId := media.get("tvdbId")):
809
- data["TvdbId"].add(tvdbId)
810
- elif self.type == "radarr" and (tmdbId := media.get("tmdbId")):
811
- data["TmdbId"].add(tmdbId)
943
+ if id__ in self.overseerr_requests_release_cache:
944
+ date = self.overseerr_requests_release_cache[id__]
945
+ else:
946
+ date = datetime(day=1, month=1, year=1970)
947
+ date_string_backup = f"{_now.year}-{_now.month:02}-{_now.day:02}"
948
+ date_string = None
949
+ try:
950
+ if type_ == "movie":
951
+ _entry = self.session.get(
952
+ url=f"{self.overseerr_uri}/api/v1/movies/{id__}",
953
+ headers={"X-Api-Key": self.overseerr_api_key},
954
+ timeout=5,
955
+ )
956
+ _entry.raise_for_status()
957
+ date_string = _entry.json().get("releaseDate")
958
+ elif type__ == "tv":
959
+ _entry = self.session.get(
960
+ url=f"{self.overseerr_uri}/api/v1/tv/{id__}",
961
+ headers={"X-Api-Key": self.overseerr_api_key},
962
+ timeout=5,
963
+ )
964
+ _entry.raise_for_status()
965
+ # We don't do granular (episode/season) searched here so no need to
966
+ # suppose them
967
+ date_string = _entry.json().get("firstAirDate")
968
+ if not date_string:
969
+ date_string = date_string_backup
970
+ date = datetime.strptime(date_string[:10], "%Y-%m-%d")
971
+ if date > _now:
972
+ continue
973
+ self.overseerr_requests_release_cache[id__] = date
974
+ except Exception as e:
975
+ self.logger.warning(
976
+ "Failed to query release date from Overseerr: %s", e
977
+ )
978
+ if media:
979
+ if imdbId := media.get("imdbId"):
980
+ data["ImdbId"].add(imdbId)
981
+ if self.type == "sonarr" and (tvdbId := media.get("tvdbId")):
982
+ data["TvdbId"].add(tvdbId)
983
+ elif self.type == "radarr" and (tmdbId := media.get("tmdbId")):
984
+ data["TmdbId"].add(tmdbId)
985
+ if len(results) < take:
986
+ break
987
+ skip += take
812
988
  self._temp_overseer_request_cache = data
813
989
  except requests.exceptions.ConnectionError:
814
990
  self.logger.warning("Couldn't connect to Overseerr")
@@ -846,15 +1022,24 @@ class Arr:
846
1022
  extras = "/api/v1/Request/movie/total"
847
1023
  else:
848
1024
  raise UnhandledError(f"Well you shouldn't have reached here, Arr.type={self.type}")
1025
+ total = 0
849
1026
  try:
850
1027
  response = self.session.get(
851
- url=f"{self.ombi_uri}{extras}", headers={"ApiKey": self.ombi_api_key}
1028
+ url=f"{self.ombi_uri}{extras}", headers={"ApiKey": self.ombi_api_key}, timeout=5
852
1029
  )
1030
+ response.raise_for_status()
1031
+ payload = response.json()
1032
+ if isinstance(payload, dict):
1033
+ for key in ("total", "count", "totalCount", "totalRecords", "pending", "value"):
1034
+ value = payload.get(key)
1035
+ if isinstance(value, int):
1036
+ total = value
1037
+ break
1038
+ elif isinstance(payload, list):
1039
+ total = len(payload)
853
1040
  except Exception as e:
854
1041
  self.logger.exception(e, exc_info=sys.exc_info())
855
- return 0
856
- else:
857
- return response.json()
1042
+ return total
858
1043
 
859
1044
  def _get_ombi_requests(self) -> list[dict]:
860
1045
  if self.type == "sonarr":
@@ -865,9 +1050,18 @@ class Arr:
865
1050
  raise UnhandledError(f"Well you shouldn't have reached here, Arr.type={self.type}")
866
1051
  try:
867
1052
  response = self.session.get(
868
- url=f"{self.ombi_uri}{extras}", headers={"ApiKey": self.ombi_api_key}
1053
+ url=f"{self.ombi_uri}{extras}", headers={"ApiKey": self.ombi_api_key}, timeout=5
869
1054
  )
870
- return response.json()
1055
+ response.raise_for_status()
1056
+ payload = response.json()
1057
+ if isinstance(payload, list):
1058
+ return payload
1059
+ if isinstance(payload, dict):
1060
+ for key in ("result", "results", "requests", "data", "items"):
1061
+ value = payload.get(key)
1062
+ if isinstance(value, list):
1063
+ return value
1064
+ return []
871
1065
  except Exception as e:
872
1066
  self.logger.exception(e, exc_info=sys.exc_info())
873
1067
  return []
@@ -899,7 +1093,18 @@ class Arr:
899
1093
  self.logger.debug(
900
1094
  "Pausing %s (%s)", i, self.manager.qbit_manager.name_cache.get(i)
901
1095
  )
902
- self.manager.qbit.torrents_pause(torrent_hashes=self.pause)
1096
+ with contextlib.suppress(Exception):
1097
+ with_retry(
1098
+ lambda: self.manager.qbit.torrents_pause(torrent_hashes=self.pause),
1099
+ retries=3,
1100
+ backoff=0.5,
1101
+ max_backoff=3,
1102
+ exceptions=(
1103
+ qbittorrentapi.exceptions.APIError,
1104
+ qbittorrentapi.exceptions.APIConnectionError,
1105
+ requests.exceptions.RequestException,
1106
+ ),
1107
+ )
903
1108
  self.pause.clear()
904
1109
 
905
1110
  def _process_imports(self) -> None:
@@ -924,41 +1129,65 @@ class Arr:
924
1129
  self.sent_to_scan_hashes.add(torrent.hash)
925
1130
  try:
926
1131
  if self.type == "sonarr":
927
- while True:
928
- try:
929
- self.client.post_command(
930
- "DownloadedEpisodesScan",
931
- path=str(path),
932
- downloadClientId=torrent.hash.upper(),
933
- importMode=self.import_mode,
934
- )
935
- break
936
- except (
1132
+ with_retry(
1133
+ lambda: self.client.post_command(
1134
+ "DownloadedEpisodesScan",
1135
+ path=str(path),
1136
+ downloadClientId=torrent.hash.upper(),
1137
+ importMode=self.import_mode,
1138
+ ),
1139
+ retries=3,
1140
+ backoff=0.5,
1141
+ max_backoff=3,
1142
+ exceptions=(
937
1143
  requests.exceptions.ChunkedEncodingError,
938
1144
  requests.exceptions.ContentDecodingError,
939
1145
  requests.exceptions.ConnectionError,
940
1146
  JSONDecodeError,
941
- ):
942
- continue
1147
+ requests.exceptions.RequestException,
1148
+ ),
1149
+ )
943
1150
  self.logger.success("DownloadedEpisodesScan: %s", path)
944
1151
  elif self.type == "radarr":
945
- while True:
946
- try:
947
- self.client.post_command(
948
- "DownloadedMoviesScan",
949
- path=str(path),
950
- downloadClientId=torrent.hash.upper(),
951
- importMode=self.import_mode,
952
- )
953
- break
954
- except (
1152
+ with_retry(
1153
+ lambda: self.client.post_command(
1154
+ "DownloadedMoviesScan",
1155
+ path=str(path),
1156
+ downloadClientId=torrent.hash.upper(),
1157
+ importMode=self.import_mode,
1158
+ ),
1159
+ retries=3,
1160
+ backoff=0.5,
1161
+ max_backoff=3,
1162
+ exceptions=(
955
1163
  requests.exceptions.ChunkedEncodingError,
956
1164
  requests.exceptions.ContentDecodingError,
957
1165
  requests.exceptions.ConnectionError,
958
1166
  JSONDecodeError,
959
- ):
960
- continue
1167
+ requests.exceptions.RequestException,
1168
+ ),
1169
+ )
961
1170
  self.logger.success("DownloadedMoviesScan: %s", path)
1171
+ elif self.type == "lidarr":
1172
+ with_retry(
1173
+ lambda: self.client.post_command(
1174
+ "DownloadedAlbumsScan",
1175
+ path=str(path),
1176
+ downloadClientId=torrent.hash.upper(),
1177
+ importMode=self.import_mode,
1178
+ ),
1179
+ retries=3,
1180
+ backoff=0.5,
1181
+ max_backoff=3,
1182
+ exceptions=(
1183
+ requests.exceptions.ChunkedEncodingError,
1184
+ requests.exceptions.ContentDecodingError,
1185
+ requests.exceptions.ConnectionError,
1186
+ JSONDecodeError,
1187
+ requests.exceptions.RequestException,
1188
+ ),
1189
+ )
1190
+ self.logger.success("DownloadedAlbumsScan: %s", path)
962
1191
  except Exception as ex:
963
1192
  self.logger.error(
964
1193
  "Downloaded scan error: [%s][%s][%s][%s]",
@@ -989,8 +1218,6 @@ class Arr:
989
1218
  self.delete_from_queue(
990
1219
  id_=entry, remove_from_client=remove_from_client, blacklist=False
991
1220
  )
992
- if hash_ in self.recently_queue:
993
- del self.recently_queue[hash_]
994
1221
  object_id = self.requeue_cache.get(entry)
995
1222
  if self.re_search and object_id:
996
1223
  if self.type == "sonarr":
@@ -1140,6 +1367,48 @@ class Arr:
1140
1367
  continue
1141
1368
  if self.persistent_queue:
1142
1369
  self.persistent_queue.insert(EntryId=object_id).on_conflict_ignore()
1370
+ elif self.type == "lidarr":
1371
+ self.logger.trace("Requeue cache entry: %s", object_id)
1372
+ while True:
1373
+ try:
1374
+ data = self.client.get_album(object_id)
1375
+ name = data.get("title")
1376
+ if name:
1377
+ artist_title = data.get("artist", {}).get("artistName", "")
1378
+ foreign_album_id = data.get("foreignAlbumId", "")
1379
+ self.logger.notice(
1380
+ "Re-Searching album: %s - %s | [foreignAlbumId=%s|id=%s]",
1381
+ artist_title,
1382
+ name,
1383
+ foreign_album_id,
1384
+ object_id,
1385
+ )
1386
+ else:
1387
+ self.logger.notice("Re-Searching album: %s", object_id)
1388
+ break
1389
+ except (
1390
+ requests.exceptions.ChunkedEncodingError,
1391
+ requests.exceptions.ContentDecodingError,
1392
+ requests.exceptions.ConnectionError,
1393
+ JSONDecodeError,
1394
+ AttributeError,
1395
+ ):
1396
+ continue
1397
+ if object_id in self.queue_file_ids:
1398
+ self.queue_file_ids.remove(object_id)
1399
+ while True:
1400
+ try:
1401
+ self.client.post_command("AlbumSearch", albumIds=[object_id])
1402
+ break
1403
+ except (
1404
+ requests.exceptions.ChunkedEncodingError,
1405
+ requests.exceptions.ContentDecodingError,
1406
+ requests.exceptions.ConnectionError,
1407
+ JSONDecodeError,
1408
+ ):
1409
+ continue
1410
+ if self.persistent_queue:
1411
+ self.persistent_queue.insert(EntryId=object_id).on_conflict_ignore()
1143
1412
 
1144
1413
  def _process_errored(self) -> None:
1145
1414
  # Recheck all torrents marked for rechecking.
@@ -1157,10 +1426,6 @@ class Arr:
1157
1426
  to_delete_all = self.delete.union(
1158
1427
  self.missing_files_post_delete, self.downloads_with_bad_error_message_blocklist
1159
1428
  )
1160
- if self.missing_files_post_delete or self.downloads_with_bad_error_message_blocklist:
1161
- delete_ = True
1162
- else:
1163
- delete_ = False
1164
1429
  skip_blacklist = {
1165
1430
  i.upper() for i in self.skip_blacklist.union(self.missing_files_post_delete)
1166
1431
  }
@@ -1189,7 +1454,7 @@ class Arr:
1189
1454
  del self.manager.qbit_manager.name_cache[h]
1190
1455
  if h in self.manager.qbit_manager.cache:
1191
1456
  del self.manager.qbit_manager.cache[h]
1192
- if delete_:
1457
+ if self.missing_files_post_delete or self.downloads_with_bad_error_message_blocklist:
1193
1458
  self.missing_files_post_delete.clear()
1194
1459
  self.downloads_with_bad_error_message_blocklist.clear()
1195
1460
  self.skip_blacklist.clear()
@@ -1246,17 +1511,19 @@ class Arr:
1246
1511
  self.rss_sync_timer_last_checked is not None
1247
1512
  and self.rss_sync_timer_last_checked < now - timedelta(minutes=self.rss_sync_timer)
1248
1513
  ):
1249
- while True:
1250
- try:
1251
- self.client.post_command("RssSync")
1252
- break
1253
- except (
1514
+ with_retry(
1515
+ lambda: self.client.post_command("RssSync"),
1516
+ retries=3,
1517
+ backoff=0.5,
1518
+ max_backoff=3,
1519
+ exceptions=(
1254
1520
  requests.exceptions.ChunkedEncodingError,
1255
1521
  requests.exceptions.ContentDecodingError,
1256
1522
  requests.exceptions.ConnectionError,
1257
1523
  JSONDecodeError,
1258
- ):
1259
- continue
1524
+ requests.exceptions.RequestException,
1525
+ ),
1526
+ )
1260
1527
  self.rss_sync_timer_last_checked = now
1261
1528
 
1262
1529
  if (
@@ -1264,22 +1531,24 @@ class Arr:
1264
1531
  and self.refresh_downloads_timer_last_checked
1265
1532
  < now - timedelta(minutes=self.refresh_downloads_timer)
1266
1533
  ):
1267
- while True:
1268
- try:
1269
- self.client.post_command("RefreshMonitoredDownloads")
1270
- break
1271
- except (
1534
+ with_retry(
1535
+ lambda: self.client.post_command("RefreshMonitoredDownloads"),
1536
+ retries=3,
1537
+ backoff=0.5,
1538
+ max_backoff=3,
1539
+ exceptions=(
1272
1540
  requests.exceptions.ChunkedEncodingError,
1273
1541
  requests.exceptions.ContentDecodingError,
1274
1542
  requests.exceptions.ConnectionError,
1275
1543
  JSONDecodeError,
1276
- ):
1277
- continue
1544
+ requests.exceptions.RequestException,
1545
+ ),
1546
+ )
1278
1547
  self.refresh_downloads_timer_last_checked = now
1279
1548
 
1280
1549
  def arr_db_query_commands_count(self) -> int:
1281
1550
  search_commands = 0
1282
- if not self.search_missing:
1551
+ if not (self.search_missing or self.do_upgrade_search):
1283
1552
  return 0
1284
1553
  while True:
1285
1554
  try:
@@ -1323,11 +1592,55 @@ class Arr:
1323
1592
  ) -> Iterable[
1324
1593
  tuple[MoviesFilesModel | EpisodeFilesModel | SeriesFilesModel, bool, bool, bool, int]
1325
1594
  ]:
1326
- if self.type == "sonarr" and self.series_search:
1595
+ if self.type == "sonarr" and self.series_search == True:
1327
1596
  serieslist = self.db_get_files_series()
1328
1597
  for series in serieslist:
1329
1598
  yield series[0], series[1], series[2], series[2] is not True, len(serieslist)
1330
- elif self.type == "sonarr" and not self.series_search:
1599
+ elif self.type == "sonarr" and self.series_search == "smart":
1600
+ # Smart mode: decide dynamically based on what needs to be searched
1601
+ episodelist = self.db_get_files_episodes()
1602
+ if episodelist:
1603
+ # Group episodes by series to determine if we should search by series or episode
1604
+ series_episodes_map = {}
1605
+ for episode_entry in episodelist:
1606
+ episode = episode_entry[0]
1607
+ series_id = episode.SeriesId
1608
+ if series_id not in series_episodes_map:
1609
+ series_episodes_map[series_id] = []
1610
+ series_episodes_map[series_id].append(episode_entry)
1611
+
1612
+ # Process each series
1613
+ for series_id, episodes in series_episodes_map.items():
1614
+ if len(episodes) > 1:
1615
+ # Multiple episodes from same series - use series search (smart decision)
1616
+ self.logger.info(
1617
+ "[SMART MODE] Using series search for %s episodes from series ID %s",
1618
+ len(episodes),
1619
+ series_id,
1620
+ )
1621
+ # Create a series entry for searching
1622
+ series_model = (
1623
+ self.series_file_model.select()
1624
+ .where(self.series_file_model.EntryId == series_id)
1625
+ .first()
1626
+ )
1627
+ if series_model:
1628
+ yield series_model, episodes[0][1], episodes[0][2], True, len(
1629
+ episodelist
1630
+ )
1631
+ else:
1632
+ # Single episode - use episode search (smart decision)
1633
+ episode = episodes[0][0]
1634
+ self.logger.info(
1635
+ "[SMART MODE] Using episode search for single episode: %s S%02dE%03d",
1636
+ episode.SeriesTitle,
1637
+ episode.SeasonNumber,
1638
+ episode.EpisodeNumber,
1639
+ )
1640
+ yield episodes[0][0], episodes[0][1], episodes[0][2], False, len(
1641
+ episodelist
1642
+ )
1643
+ elif self.type == "sonarr" and self.series_search == False:
1331
1644
  episodelist = self.db_get_files_episodes()
1332
1645
  for episodes in episodelist:
1333
1646
  yield episodes[0], episodes[1], episodes[2], False, len(episodelist)
@@ -1335,6 +1648,10 @@ class Arr:
1335
1648
  movielist = self.db_get_files_movies()
1336
1649
  for movies in movielist:
1337
1650
  yield movies[0], movies[1], movies[2], False, len(movielist)
1651
+ elif self.type == "lidarr":
1652
+ albumlist = self.db_get_files_movies() # This calls the lidarr section we added
1653
+ for albums in albumlist:
1654
+ yield albums[0], albums[1], albums[2], False, len(albumlist)
1338
1655
 
1339
1656
  def db_maybe_reset_entry_searched_state(self):
1340
1657
  if self.type == "sonarr":
@@ -1342,6 +1659,8 @@ class Arr:
1342
1659
  self.db_reset__episode_searched_state()
1343
1660
  elif self.type == "radarr":
1344
1661
  self.db_reset__movie_searched_state()
1662
+ elif self.type == "lidarr":
1663
+ self.db_reset__album_searched_state()
1345
1664
  self.loop_completed = False
1346
1665
 
1347
1666
  def db_reset__series_searched_state(self):
@@ -1352,7 +1671,7 @@ class Arr:
1352
1671
  self.loop_completed and self.reset_on_completion and self.series_search
1353
1672
  ): # Only wipe if a loop completed was tagged
1354
1673
  self.series_file_model.update(Searched=False, Upgrade=False).where(
1355
- self.series_file_model.Searched is True
1674
+ self.series_file_model.Searched == True
1356
1675
  ).execute()
1357
1676
  while True:
1358
1677
  try:
@@ -1379,13 +1698,12 @@ class Arr:
1379
1698
  self.loop_completed is True and self.reset_on_completion
1380
1699
  ): # Only wipe if a loop completed was tagged
1381
1700
  self.model_file.update(Searched=False, Upgrade=False).where(
1382
- self.model_file.Searched is True
1701
+ self.model_file.Searched == True
1383
1702
  ).execute()
1384
1703
  while True:
1385
1704
  try:
1386
1705
  series = self.client.get_series()
1387
1706
  for s in series:
1388
- self.api_call_count += 1
1389
1707
  episodes = self.client.get_episode(s["id"], True)
1390
1708
  for e in episodes:
1391
1709
  ids.append(e["id"])
@@ -1407,7 +1725,7 @@ class Arr:
1407
1725
  self.loop_completed is True and self.reset_on_completion
1408
1726
  ): # Only wipe if a loop completed was tagged
1409
1727
  self.model_file.update(Searched=False, Upgrade=False).where(
1410
- self.model_file.Searched is True
1728
+ self.model_file.Searched == True
1411
1729
  ).execute()
1412
1730
  while True:
1413
1731
  try:
@@ -1425,9 +1743,36 @@ class Arr:
1425
1743
  self.model_file.delete().where(self.model_file.EntryId.not_in(ids)).execute()
1426
1744
  self.loop_completed = False
1427
1745
 
1746
+ def db_reset__album_searched_state(self):
1747
+ ids = []
1748
+ self.model_file: AlbumFilesModel
1749
+ if (
1750
+ self.loop_completed is True and self.reset_on_completion
1751
+ ): # Only wipe if a loop completed was tagged
1752
+ self.model_file.update(Searched=False, Upgrade=False).where(
1753
+ self.model_file.Searched == True
1754
+ ).execute()
1755
+ while True:
1756
+ try:
1757
+ artists = self.client.get_artist()
1758
+ for artist in artists:
1759
+ albums = self.client.get_album(artistId=artist["id"])
1760
+ for album in albums:
1761
+ ids.append(album["id"])
1762
+ break
1763
+ except (
1764
+ requests.exceptions.ChunkedEncodingError,
1765
+ requests.exceptions.ContentDecodingError,
1766
+ requests.exceptions.ConnectionError,
1767
+ JSONDecodeError,
1768
+ ):
1769
+ continue
1770
+ self.model_file.delete().where(self.model_file.EntryId.not_in(ids)).execute()
1771
+ self.loop_completed = False
1772
+
1428
1773
  def db_get_files_series(self) -> list[list[SeriesFilesModel, bool, bool]] | None:
1429
1774
  entries = []
1430
- if not self.search_missing:
1775
+ if not (self.search_missing or self.do_upgrade_search):
1431
1776
  return None
1432
1777
  elif not self.series_search:
1433
1778
  return None
@@ -1439,21 +1784,18 @@ class Arr:
1439
1784
  condition &= self.model_file.Upgrade == False
1440
1785
  else:
1441
1786
  if self.quality_unmet_search and not self.custom_format_unmet_search:
1442
- condition &= (
1443
- self.model_file.Searched == False | self.model_file.QualityMet == False
1787
+ condition &= (self.model_file.Searched == False) | (
1788
+ self.model_file.QualityMet == False
1444
1789
  )
1445
1790
  elif not self.quality_unmet_search and self.custom_format_unmet_search:
1446
- condition &= (
1447
- self.model_file.Searched
1448
- == False | self.model_file.CustomFormatMet
1449
- == False
1791
+ condition &= (self.model_file.Searched == False) | (
1792
+ self.model_file.CustomFormatMet == False
1450
1793
  )
1451
1794
  elif self.quality_unmet_search and self.custom_format_unmet_search:
1452
1795
  condition &= (
1453
- self.model_file.Searched
1454
- == False | self.model_file.QualityMet
1455
- == False | self.model_file.CustomFormatMet
1456
- == False
1796
+ (self.model_file.Searched == False)
1797
+ | (self.model_file.QualityMet == False)
1798
+ | (self.model_file.CustomFormatMet == False)
1457
1799
  )
1458
1800
  else:
1459
1801
  condition &= self.model_file.EpisodeFileId == 0
@@ -1496,7 +1838,7 @@ class Arr:
1496
1838
 
1497
1839
  def db_get_files_episodes(self) -> list[list[EpisodeFilesModel, bool, bool]] | None:
1498
1840
  entries = []
1499
- if not self.search_missing:
1841
+ if not (self.search_missing or self.do_upgrade_search):
1500
1842
  return None
1501
1843
  elif self.type == "sonarr":
1502
1844
  condition = self.model_file.AirDateUtc.is_null(False)
@@ -1506,21 +1848,18 @@ class Arr:
1506
1848
  condition &= self.model_file.Upgrade == False
1507
1849
  else:
1508
1850
  if self.quality_unmet_search and not self.custom_format_unmet_search:
1509
- condition &= (
1510
- self.model_file.Searched == False | self.model_file.QualityMet == False
1851
+ condition &= (self.model_file.Searched == False) | (
1852
+ self.model_file.QualityMet == False
1511
1853
  )
1512
1854
  elif not self.quality_unmet_search and self.custom_format_unmet_search:
1513
- condition &= (
1514
- self.model_file.Searched
1515
- == False | self.model_file.CustomFormatMet
1516
- == False
1855
+ condition &= (self.model_file.Searched == False) | (
1856
+ self.model_file.CustomFormatMet == False
1517
1857
  )
1518
1858
  elif self.quality_unmet_search and self.custom_format_unmet_search:
1519
1859
  condition &= (
1520
- self.model_file.Searched
1521
- == False | self.model_file.QualityMet
1522
- == False | self.model_file.CustomFormatMet
1523
- == False
1860
+ (self.model_file.Searched == False)
1861
+ | (self.model_file.QualityMet == False)
1862
+ | (self.model_file.CustomFormatMet == False)
1524
1863
  )
1525
1864
  else:
1526
1865
  condition &= self.model_file.EpisodeFileId == 0
@@ -1564,7 +1903,7 @@ class Arr:
1564
1903
 
1565
1904
  def db_get_files_movies(self) -> list[list[MoviesFilesModel, bool, bool]] | None:
1566
1905
  entries = []
1567
- if not self.search_missing:
1906
+ if not (self.search_missing or self.do_upgrade_search):
1568
1907
  return None
1569
1908
  if self.type == "radarr":
1570
1909
  condition = self.model_file.Year.is_null(False)
@@ -1572,21 +1911,18 @@ class Arr:
1572
1911
  condition &= self.model_file.Upgrade == False
1573
1912
  else:
1574
1913
  if self.quality_unmet_search and not self.custom_format_unmet_search:
1575
- condition &= (
1576
- self.model_file.Searched == False | self.model_file.QualityMet == False
1914
+ condition &= (self.model_file.Searched == False) | (
1915
+ self.model_file.QualityMet == False
1577
1916
  )
1578
1917
  elif not self.quality_unmet_search and self.custom_format_unmet_search:
1579
- condition &= (
1580
- self.model_file.Searched
1581
- == False | self.model_file.CustomFormatMet
1582
- == False
1918
+ condition &= (self.model_file.Searched == False) | (
1919
+ self.model_file.CustomFormatMet == False
1583
1920
  )
1584
1921
  elif self.quality_unmet_search and self.custom_format_unmet_search:
1585
1922
  condition &= (
1586
- self.model_file.Searched
1587
- == False | self.model_file.QualityMet
1588
- == False | self.model_file.CustomFormatMet
1589
- == False
1923
+ (self.model_file.Searched == False)
1924
+ | (self.model_file.QualityMet == False)
1925
+ | (self.model_file.CustomFormatMet == False)
1590
1926
  )
1591
1927
  else:
1592
1928
  condition &= self.model_file.MovieFileId == 0
@@ -1601,24 +1937,54 @@ class Arr:
1601
1937
  ):
1602
1938
  entries.append([entry, False, False])
1603
1939
  return entries
1604
-
1605
- def db_get_request_files(self) -> Iterable[tuple[MoviesFilesModel | EpisodeFilesModel, int]]:
1606
- entries = []
1607
- self.logger.trace("Getting request files")
1608
- if self.type == "sonarr":
1609
- condition = self.model_file.IsRequest == True
1610
- condition &= self.model_file.AirDateUtc.is_null(False)
1611
- condition &= self.model_file.EpisodeFileId == 0
1612
- condition &= self.model_file.Searched == False
1613
- condition &= self.model_file.AirDateUtc < (
1614
- datetime.now(timezone.utc) - timedelta(days=1)
1615
- )
1616
- entries = list(
1617
- self.model_file.select()
1618
- .where(condition)
1619
- .order_by(
1620
- self.model_file.SeriesTitle,
1621
- self.model_file.SeasonNumber.desc(),
1940
+ elif self.type == "lidarr":
1941
+ condition = True # Placeholder, will be refined
1942
+ if self.do_upgrade_search:
1943
+ condition &= self.model_file.Upgrade == False
1944
+ else:
1945
+ if self.quality_unmet_search and not self.custom_format_unmet_search:
1946
+ condition &= (self.model_file.Searched == False) | (
1947
+ self.model_file.QualityMet == False
1948
+ )
1949
+ elif not self.quality_unmet_search and self.custom_format_unmet_search:
1950
+ condition &= (self.model_file.Searched == False) | (
1951
+ self.model_file.CustomFormatMet == False
1952
+ )
1953
+ elif self.quality_unmet_search and self.custom_format_unmet_search:
1954
+ condition &= (
1955
+ (self.model_file.Searched == False)
1956
+ | (self.model_file.QualityMet == False)
1957
+ | (self.model_file.CustomFormatMet == False)
1958
+ )
1959
+ else:
1960
+ condition &= self.model_file.AlbumFileId == 0
1961
+ condition &= self.model_file.Searched == False
1962
+ for entry in (
1963
+ self.model_file.select()
1964
+ .where(condition)
1965
+ .order_by(self.model_file.AlbumFileId.asc())
1966
+ .execute()
1967
+ ):
1968
+ entries.append([entry, False, False])
1969
+ return entries
1970
+
1971
+ def db_get_request_files(self) -> Iterable[tuple[MoviesFilesModel | EpisodeFilesModel, int]]:
1972
+ entries = []
1973
+ self.logger.trace("Getting request files")
1974
+ if self.type == "sonarr":
1975
+ condition = self.model_file.IsRequest == True
1976
+ condition &= self.model_file.AirDateUtc.is_null(False)
1977
+ condition &= self.model_file.EpisodeFileId == 0
1978
+ condition &= self.model_file.Searched == False
1979
+ condition &= self.model_file.AirDateUtc < (
1980
+ datetime.now(timezone.utc) - timedelta(days=1)
1981
+ )
1982
+ entries = list(
1983
+ self.model_file.select()
1984
+ .where(condition)
1985
+ .order_by(
1986
+ self.model_file.SeriesTitle,
1987
+ self.model_file.SeasonNumber.desc(),
1622
1988
  self.model_file.AirDateUtc.desc(),
1623
1989
  )
1624
1990
  .execute()
@@ -1659,7 +2025,6 @@ class Arr:
1659
2025
  ):
1660
2026
  continue
1661
2027
  for s in series:
1662
- self.api_call_count += 1
1663
2028
  episodes = self.client.get_episode(s["id"], True)
1664
2029
  for e in episodes:
1665
2030
  if "airDateUtc" in e:
@@ -1756,7 +2121,6 @@ class Arr:
1756
2121
  ):
1757
2122
  continue
1758
2123
  for s in series:
1759
- self.api_call_count += 1
1760
2124
  episodes = self.client.get_episode(s["id"], True)
1761
2125
  for e in episodes:
1762
2126
  if "airDateUtc" in e:
@@ -1783,17 +2147,28 @@ class Arr:
1783
2147
  self.logger.debug("No episode releases found for today")
1784
2148
 
1785
2149
  def db_update(self):
1786
- if not self.search_missing:
1787
- return
1788
- self.db_update_todays_releases()
1789
- if self.db_update_processed and not self.search_by_year:
2150
+ if not (
2151
+ self.search_missing
2152
+ or self.do_upgrade_search
2153
+ or self.quality_unmet_search
2154
+ or self.custom_format_unmet_search
2155
+ ):
1790
2156
  return
1791
- if self.search_by_year:
1792
- self.logger.info("Started updating database for %s", self.search_current_year)
1793
- else:
2157
+ placeholder_summary = "Updating database"
2158
+ placeholder_set = False
2159
+ try:
2160
+ self._webui_db_loaded = False
2161
+ try:
2162
+ self._record_search_activity(placeholder_summary)
2163
+ placeholder_set = True
2164
+ except Exception:
2165
+ pass
2166
+ self.db_update_todays_releases()
2167
+ if self.db_update_processed:
2168
+ return
1794
2169
  self.logger.info("Started updating database")
1795
- if self.type == "sonarr":
1796
- if not self.series_search:
2170
+ if self.type == "sonarr":
2171
+ # Always fetch series list for both episode and series-level tracking
1797
2172
  while True:
1798
2173
  try:
1799
2174
  series = self.client.get_series()
@@ -1805,64 +2180,35 @@ class Arr:
1805
2180
  JSONDecodeError,
1806
2181
  ):
1807
2182
  continue
1808
- if self.search_by_year:
1809
- for s in series:
1810
- if isinstance(s, str):
1811
- continue
1812
- self.api_call_count += 1
1813
- episodes = self.client.get_episode(s["id"], True)
1814
- for e in episodes:
1815
- if isinstance(e, str):
1816
- continue
1817
- if "airDateUtc" in e:
1818
- if datetime.strptime(
1819
- e["airDateUtc"], "%Y-%m-%dT%H:%M:%SZ"
1820
- ).replace(tzinfo=timezone.utc) > datetime.now(timezone.utc):
1821
- continue
1822
- if (
1823
- datetime.strptime(e["airDateUtc"], "%Y-%m-%dT%H:%M:%SZ")
1824
- .replace(tzinfo=timezone.utc)
1825
- .date()
1826
- < datetime(
1827
- month=1, day=1, year=int(self.search_current_year)
1828
- ).date()
1829
- ):
1830
- continue
1831
- if (
1832
- datetime.strptime(e["airDateUtc"], "%Y-%m-%dT%H:%M:%SZ")
1833
- .replace(tzinfo=timezone.utc)
1834
- .date()
1835
- > datetime(
1836
- month=12, day=31, year=int(self.search_current_year)
1837
- ).date()
1838
- ):
1839
- continue
1840
- if not self.search_specials and e["seasonNumber"] == 0:
1841
- continue
1842
- self.db_update_single_series(db_entry=e)
1843
2183
 
1844
- else:
1845
- for s in series:
1846
- if isinstance(s, str):
2184
+ # Process episodes for episode-level tracking (all episodes)
2185
+ for s in series:
2186
+ if isinstance(s, str):
2187
+ continue
2188
+ episodes = self.client.get_episode(s["id"], True)
2189
+ for e in episodes:
2190
+ if isinstance(e, str):
1847
2191
  continue
1848
- self.api_call_count += 1
1849
- episodes = self.client.get_episode(s["id"], True)
1850
- for e in episodes:
1851
- if isinstance(e, str):
2192
+ if "airDateUtc" in e:
2193
+ if datetime.strptime(e["airDateUtc"], "%Y-%m-%dT%H:%M:%SZ").replace(
2194
+ tzinfo=timezone.utc
2195
+ ) > datetime.now(timezone.utc):
1852
2196
  continue
1853
- if "airDateUtc" in e:
1854
- if datetime.strptime(
1855
- e["airDateUtc"], "%Y-%m-%dT%H:%M:%SZ"
1856
- ).replace(tzinfo=timezone.utc) > datetime.now(timezone.utc):
1857
- continue
1858
- if not self.search_specials and e["seasonNumber"] == 0:
1859
- continue
1860
- self.db_update_single_series(db_entry=e)
2197
+ if not self.search_specials and e["seasonNumber"] == 0:
2198
+ continue
2199
+ self.db_update_single_series(db_entry=e, series=False)
2200
+
2201
+ # Process series for series-level tracking (all series)
2202
+ for s in series:
2203
+ if isinstance(s, str):
2204
+ continue
2205
+ self.db_update_single_series(db_entry=s, series=True)
2206
+
1861
2207
  self.db_update_processed = True
1862
- else:
2208
+ elif self.type == "radarr":
1863
2209
  while True:
1864
2210
  try:
1865
- series = self.client.get_series()
2211
+ movies = self.client.get_movie()
1866
2212
  break
1867
2213
  except (
1868
2214
  requests.exceptions.ChunkedEncodingError,
@@ -1871,49 +2217,70 @@ class Arr:
1871
2217
  JSONDecodeError,
1872
2218
  ):
1873
2219
  continue
1874
- if self.search_by_year:
1875
- for s in series:
1876
- if isinstance(s, str):
1877
- continue
1878
- if s["year"] < self.search_current_year:
1879
- continue
1880
- if s["year"] > self.search_current_year:
1881
- continue
1882
- self.db_update_single_series(db_entry=s, series=True)
1883
- else:
1884
- for s in series:
1885
- if isinstance(s, str):
1886
- continue
1887
- self.db_update_single_series(db_entry=s, series=True)
1888
- self.db_update_processed = True
1889
- elif self.type == "radarr":
1890
- while True:
1891
- try:
1892
- movies = self.client.get_movie()
1893
- break
1894
- except (
1895
- requests.exceptions.ChunkedEncodingError,
1896
- requests.exceptions.ContentDecodingError,
1897
- requests.exceptions.ConnectionError,
1898
- JSONDecodeError,
1899
- ):
1900
- continue
1901
- if self.search_by_year:
2220
+ # Process all movies
1902
2221
  for m in movies:
1903
2222
  if isinstance(m, str):
1904
2223
  continue
1905
- if m["year"] < self.search_current_year:
2224
+ self.db_update_single_series(db_entry=m)
2225
+ self.db_update_processed = True
2226
+ elif self.type == "lidarr":
2227
+ while True:
2228
+ try:
2229
+ artists = self.client.get_artist()
2230
+ break
2231
+ except (
2232
+ requests.exceptions.ChunkedEncodingError,
2233
+ requests.exceptions.ContentDecodingError,
2234
+ requests.exceptions.ConnectionError,
2235
+ JSONDecodeError,
2236
+ ):
1906
2237
  continue
1907
- if m["year"] > self.search_current_year:
2238
+ for artist in artists:
2239
+ if isinstance(artist, str):
1908
2240
  continue
1909
- self.db_update_single_series(db_entry=m)
1910
- else:
1911
- for m in movies:
1912
- if isinstance(m, str):
2241
+ while True:
2242
+ try:
2243
+ # allArtistAlbums=True includes full album data with media/tracks
2244
+ albums = self.client.get_album(
2245
+ artistId=artist["id"], allArtistAlbums=True
2246
+ )
2247
+ break
2248
+ except (
2249
+ requests.exceptions.ChunkedEncodingError,
2250
+ requests.exceptions.ContentDecodingError,
2251
+ requests.exceptions.ConnectionError,
2252
+ JSONDecodeError,
2253
+ ):
2254
+ continue
2255
+ for album in albums:
2256
+ if isinstance(album, str):
2257
+ continue
2258
+ # For Lidarr, we don't have a specific releaseDate field
2259
+ # Check if album has been released
2260
+ if "releaseDate" in album:
2261
+ release_date = datetime.strptime(
2262
+ album["releaseDate"], "%Y-%m-%dT%H:%M:%SZ"
2263
+ )
2264
+ if release_date > datetime.now():
2265
+ continue
2266
+ self.db_update_single_series(db_entry=album)
2267
+ # Process artists for artist-level tracking
2268
+ for artist in artists:
2269
+ if isinstance(artist, str):
1913
2270
  continue
1914
- self.db_update_single_series(db_entry=m)
1915
- self.db_update_processed = True
1916
- self.logger.trace("Finished updating database")
2271
+ self.db_update_single_series(db_entry=artist, artist=True)
2272
+ self.db_update_processed = True
2273
+ self.logger.trace("Finished updating database")
2274
+ finally:
2275
+ if placeholder_set:
2276
+ try:
2277
+ activities = fetch_search_activities()
2278
+ entry = activities.get(str(self.category))
2279
+ if entry and entry.get("summary") == placeholder_summary:
2280
+ clear_search_activity(str(self.category))
2281
+ except Exception:
2282
+ pass
2283
+ self._webui_db_loaded = True
1917
2284
 
1918
2285
  def minimum_availability_check(self, db_entry: JsonObject) -> bool:
1919
2286
  inCinemas = (
@@ -2148,9 +2515,18 @@ class Arr:
2148
2515
  return False
2149
2516
 
2150
2517
  def db_update_single_series(
2151
- self, db_entry: JsonObject = None, request: bool = False, series: bool = False
2518
+ self,
2519
+ db_entry: JsonObject = None,
2520
+ request: bool = False,
2521
+ series: bool = False,
2522
+ artist: bool = False,
2152
2523
  ):
2153
- if not self.search_missing:
2524
+ if not (
2525
+ self.search_missing
2526
+ or self.do_upgrade_search
2527
+ or self.quality_unmet_search
2528
+ or self.custom_format_unmet_search
2529
+ ):
2154
2530
  return
2155
2531
  try:
2156
2532
  searched = False
@@ -2171,42 +2547,60 @@ class Arr:
2171
2547
  JSONDecodeError,
2172
2548
  ):
2173
2549
  continue
2174
- if episode["monitored"] or self.search_unmonitored:
2550
+ if episode.get("monitored", True) or self.search_unmonitored:
2175
2551
  while True:
2176
2552
  try:
2177
- if episodeData:
2178
- if not episodeData.MinCustomFormatScore:
2179
- self.api_call_count += 1
2180
- minCustomFormat = self.client.get_quality_profile(
2181
- episode["series"]["qualityProfileId"]
2182
- )["minFormatScore"]
2183
- else:
2184
- minCustomFormat = episodeData.MinCustomFormatScore
2185
- if episode["hasFile"]:
2186
- if (
2187
- episode["episodeFile"]["id"]
2188
- != episodeData.EpisodeFileId
2189
- ):
2190
- self.api_call_count += 1
2191
- customFormat = self.client.get_episode_file(
2192
- episode["episodeFile"]["id"]
2193
- )["customFormatScore"]
2194
- else:
2195
- customFormat = 0
2553
+ series_info = episode.get("series") or {}
2554
+ if isinstance(series_info, dict):
2555
+ quality_profile_id = series_info.get("qualityProfileId")
2556
+ else:
2557
+ quality_profile_id = getattr(
2558
+ series_info, "qualityProfileId", None
2559
+ )
2560
+ if not quality_profile_id:
2561
+ quality_profile_id = db_entry.get("qualityProfileId")
2562
+ minCustomFormat = (
2563
+ getattr(episodeData, "MinCustomFormatScore", 0)
2564
+ if episodeData
2565
+ else 0
2566
+ )
2567
+ if not minCustomFormat:
2568
+ if quality_profile_id:
2569
+ profile = (
2570
+ self.client.get_quality_profile(quality_profile_id)
2571
+ or {}
2572
+ )
2573
+ minCustomFormat = profile.get("minFormatScore") or 0
2196
2574
  else:
2197
- customFormat = 0
2575
+ self.logger.warning(
2576
+ "Episode %s missing qualityProfileId; defaulting custom format threshold to 0",
2577
+ episode.get("id"),
2578
+ )
2579
+ minCustomFormat = 0
2580
+ episode_file = episode.get("episodeFile") or {}
2581
+ if isinstance(episode_file, dict):
2582
+ episode_file_id = episode_file.get("id")
2198
2583
  else:
2199
- self.api_call_count += 1
2200
- minCustomFormat = self.client.get_quality_profile(
2201
- episode["series"]["qualityProfileId"]
2202
- )["minFormatScore"]
2203
- if episode["hasFile"]:
2204
- self.api_call_count += 1
2205
- customFormat = self.client.get_episode_file(
2206
- episode["episodeFile"]["id"]
2207
- )["customFormatScore"]
2584
+ episode_file_id = getattr(episode_file, "id", None)
2585
+ has_file = bool(episode.get("hasFile"))
2586
+ episode_data_file_id = (
2587
+ getattr(episodeData, "EpisodeFileId", None)
2588
+ if episodeData
2589
+ else None
2590
+ )
2591
+ if has_file and episode_file_id:
2592
+ if (
2593
+ episode_data_file_id
2594
+ and episode_file_id == episode_data_file_id
2595
+ ):
2596
+ customFormat = getattr(episodeData, "CustomFormatScore", 0)
2208
2597
  else:
2209
- customFormat = 0
2598
+ file_info = (
2599
+ self.client.get_episode_file(episode_file_id) or {}
2600
+ )
2601
+ customFormat = file_info.get("customFormatScore") or 0
2602
+ else:
2603
+ customFormat = 0
2210
2604
  break
2211
2605
  except (
2212
2606
  requests.exceptions.ChunkedEncodingError,
@@ -2215,9 +2609,6 @@ class Arr:
2215
2609
  JSONDecodeError,
2216
2610
  ):
2217
2611
  continue
2218
- except KeyError:
2219
- self.logger.warning("Key Error [%s]", db_entry["id"])
2220
- continue
2221
2612
 
2222
2613
  QualityUnmet = (
2223
2614
  episode["episodeFile"]["qualityCutoffNotMet"]
@@ -2237,55 +2628,47 @@ class Arr:
2237
2628
  ).execute()
2238
2629
 
2239
2630
  if self.use_temp_for_missing:
2240
- try:
2241
- self.logger.trace(
2242
- "Temp quality profile tests [%s][%s]",
2243
- searched,
2244
- db_entry["qualityProfileId"],
2245
- )
2246
- if (
2247
- searched
2248
- and db_entry["qualityProfileId"]
2249
- in self.temp_quality_profile_ids.values()
2250
- ):
2251
- data: JsonObject = {
2252
- "qualityProfileId": list(
2253
- self.temp_quality_profile_ids.keys()
2254
- )[
2255
- list(self.temp_quality_profile_ids.values()).index(
2256
- db_entry["qualityProfileId"]
2257
- )
2258
- ]
2259
- }
2260
- self.logger.debug(
2261
- "Upgrading quality profile for %s to %s",
2262
- db_entry["title"],
2263
- list(self.temp_quality_profile_ids.keys())[
2264
- list(self.temp_quality_profile_ids.values()).index(
2265
- db_entry["qualityProfileId"]
2266
- )
2267
- ],
2268
- )
2269
- elif (
2270
- not searched
2271
- and db_entry["qualityProfileId"]
2272
- in self.temp_quality_profile_ids.keys()
2273
- ):
2274
- data: JsonObject = {
2275
- "qualityProfileId": self.temp_quality_profile_ids[
2276
- db_entry["qualityProfileId"]
2277
- ]
2278
- }
2279
- self.logger.debug(
2280
- "Downgrading quality profile for %s to %s",
2281
- db_entry["title"],
2282
- self.temp_quality_profile_ids[
2631
+ data = None
2632
+ quality_profile_id = db_entry.get("qualityProfileId")
2633
+ self.logger.trace(
2634
+ "Temp quality profile [%s][%s]",
2635
+ searched,
2636
+ quality_profile_id,
2637
+ )
2638
+ if (
2639
+ searched
2640
+ and quality_profile_id in self.temp_quality_profile_ids.values()
2641
+ and not self.keep_temp_profile
2642
+ ):
2643
+ data: JsonObject = {
2644
+ "qualityProfileId": list(self.temp_quality_profile_ids.keys())[
2645
+ list(self.temp_quality_profile_ids.values()).index(
2646
+ quality_profile_id
2647
+ )
2648
+ ]
2649
+ }
2650
+ self.logger.debug(
2651
+ "Upgrading quality profile for %s to %s",
2652
+ db_entry["title"],
2653
+ list(self.temp_quality_profile_ids.keys())[
2654
+ list(self.temp_quality_profile_ids.values()).index(
2283
2655
  db_entry["qualityProfileId"]
2284
- ],
2285
- )
2286
- except KeyError:
2287
- self.logger.warning(
2288
- "Check quality profile settings for %s", db_entry["title"]
2656
+ )
2657
+ ],
2658
+ )
2659
+ elif (
2660
+ not searched
2661
+ and quality_profile_id in self.temp_quality_profile_ids.keys()
2662
+ ):
2663
+ data: JsonObject = {
2664
+ "qualityProfileId": self.temp_quality_profile_ids[
2665
+ quality_profile_id
2666
+ ]
2667
+ }
2668
+ self.logger.debug(
2669
+ "Downgrading quality profile for %s to %s",
2670
+ db_entry["title"],
2671
+ self.temp_quality_profile_ids[quality_profile_id],
2289
2672
  )
2290
2673
  if data:
2291
2674
  while True:
@@ -2318,11 +2701,12 @@ class Arr:
2318
2701
  else None
2319
2702
  )
2320
2703
  AirDateUtc = episode["airDateUtc"]
2321
- Monitored = episode["monitored"]
2704
+ Monitored = episode.get("monitored", True)
2322
2705
  QualityMet = not QualityUnmet if db_entry["hasFile"] else False
2323
- customFormatMet = customFormat > minCustomFormat
2706
+ customFormatMet = customFormat >= minCustomFormat
2324
2707
 
2325
2708
  if not episode["hasFile"]:
2709
+ # Episode is missing a file - always mark as Missing
2326
2710
  reason = "Missing"
2327
2711
  elif self.quality_unmet_search and QualityUnmet:
2328
2712
  reason = "Quality"
@@ -2330,8 +2714,11 @@ class Arr:
2330
2714
  reason = "CustomFormat"
2331
2715
  elif self.do_upgrade_search:
2332
2716
  reason = "Upgrade"
2717
+ elif searched:
2718
+ # Episode has file and search is complete
2719
+ reason = "Not being searched"
2333
2720
  else:
2334
- reason = None
2721
+ reason = "Not being searched"
2335
2722
 
2336
2723
  to_update = {
2337
2724
  self.model_file.Monitored: Monitored,
@@ -2397,18 +2784,39 @@ class Arr:
2397
2784
  else:
2398
2785
  self.series_file_model: SeriesFilesModel
2399
2786
  EntryId = db_entry["id"]
2400
- seriesData = self.model_file.get_or_none(self.model_file.EntryId == EntryId)
2787
+ seriesData = self.series_file_model.get_or_none(
2788
+ self.series_file_model.EntryId == EntryId
2789
+ )
2401
2790
  if db_entry["monitored"] or self.search_unmonitored:
2402
2791
  while True:
2403
2792
  try:
2404
- seriesMetadata = self.client.get_series(id_=EntryId)
2793
+ seriesMetadata = self.client.get_series(id_=EntryId) or {}
2794
+ quality_profile_id = None
2795
+ if isinstance(seriesMetadata, dict):
2796
+ quality_profile_id = seriesMetadata.get("qualityProfileId")
2797
+ else:
2798
+ quality_profile_id = getattr(
2799
+ seriesMetadata, "qualityProfileId", None
2800
+ )
2405
2801
  if not seriesData:
2406
- self.api_call_count += 1
2407
- minCustomFormat = self.client.get_quality_profile(
2408
- seriesMetadata["qualityProfileId"]
2409
- )["minFormatScore"]
2802
+ if quality_profile_id:
2803
+ profile = (
2804
+ self.client.get_quality_profile(quality_profile_id)
2805
+ or {}
2806
+ )
2807
+ minCustomFormat = profile.get("minFormatScore") or 0
2808
+ else:
2809
+ self.logger.warning(
2810
+ "Series %s (%s) missing qualityProfileId; "
2811
+ "defaulting custom format score to 0",
2812
+ db_entry.get("title"),
2813
+ EntryId,
2814
+ )
2815
+ minCustomFormat = 0
2410
2816
  else:
2411
- minCustomFormat = seriesMetadata.MinCustomFormatScore
2817
+ minCustomFormat = getattr(
2818
+ seriesData, "MinCustomFormatScore", 0
2819
+ )
2412
2820
  break
2413
2821
  except (
2414
2822
  requests.exceptions.ChunkedEncodingError,
@@ -2417,11 +2825,6 @@ class Arr:
2417
2825
  JSONDecodeError,
2418
2826
  ):
2419
2827
  continue
2420
- except KeyError:
2421
- self.logger.warning(
2422
- "Key Error [%s][%s]", db_entry["id"], seriesMetadata
2423
- )
2424
- continue
2425
2828
  episodeCount = 0
2426
2829
  episodeFileCount = 0
2427
2830
  totalEpisodeCount = 0
@@ -2455,16 +2858,18 @@ class Arr:
2455
2858
  searched = (episodeCount + monitoredEpisodeCount) == episodeFileCount
2456
2859
  if self.use_temp_for_missing:
2457
2860
  try:
2861
+ quality_profile_id = db_entry.get("qualityProfileId")
2458
2862
  if (
2459
2863
  searched
2460
- and db_entry["qualityProfileId"]
2864
+ and quality_profile_id
2461
2865
  in self.temp_quality_profile_ids.values()
2866
+ and not self.keep_temp_profile
2462
2867
  ):
2463
2868
  db_entry["qualityProfileId"] = list(
2464
2869
  self.temp_quality_profile_ids.keys()
2465
2870
  )[
2466
2871
  list(self.temp_quality_profile_ids.values()).index(
2467
- db_entry["qualityProfileId"]
2872
+ quality_profile_id
2468
2873
  )
2469
2874
  ]
2470
2875
  self.logger.debug(
@@ -2474,11 +2879,10 @@ class Arr:
2474
2879
  )
2475
2880
  elif (
2476
2881
  not searched
2477
- and db_entry["qualityProfileId"]
2478
- in self.temp_quality_profile_ids.keys()
2882
+ and quality_profile_id in self.temp_quality_profile_ids.keys()
2479
2883
  ):
2480
2884
  db_entry["qualityProfileId"] = self.temp_quality_profile_ids[
2481
- db_entry["qualityProfileId"]
2885
+ quality_profile_id
2482
2886
  ]
2483
2887
  self.logger.debug(
2484
2888
  "Updating quality profile for %s to %s",
@@ -2532,6 +2936,9 @@ class Arr:
2532
2936
  conflict_target=[self.series_file_model.EntryId], update=to_update
2533
2937
  )
2534
2938
  db_commands.execute()
2939
+
2940
+ # Note: Episodes are now handled separately in db_update()
2941
+ # No need to recursively process episodes here to avoid duplication
2535
2942
  else:
2536
2943
  db_commands = self.series_file_model.delete().where(
2537
2944
  self.series_file_model.EntryId == EntryId
@@ -2549,7 +2956,6 @@ class Arr:
2549
2956
  try:
2550
2957
  if movieData:
2551
2958
  if not movieData.MinCustomFormatScore:
2552
- self.api_call_count += 1
2553
2959
  minCustomFormat = self.client.get_quality_profile(
2554
2960
  db_entry["qualityProfileId"]
2555
2961
  )["minFormatScore"]
@@ -2557,22 +2963,18 @@ class Arr:
2557
2963
  minCustomFormat = movieData.MinCustomFormatScore
2558
2964
  if db_entry["hasFile"]:
2559
2965
  if db_entry["movieFile"]["id"] != movieData.MovieFileId:
2560
- self.api_call_count += 1
2561
2966
  customFormat = self.client.get_movie_file(
2562
2967
  db_entry["movieFile"]["id"]
2563
2968
  )["customFormatScore"]
2564
2969
  else:
2565
- customFormat = 0
2970
+ customFormat = movieData.CustomFormatScore
2566
2971
  else:
2567
2972
  customFormat = 0
2568
-
2569
2973
  else:
2570
- self.api_call_count += 1
2571
2974
  minCustomFormat = self.client.get_quality_profile(
2572
2975
  db_entry["qualityProfileId"]
2573
2976
  )["minFormatScore"]
2574
2977
  if db_entry["hasFile"]:
2575
- self.api_call_count += 1
2576
2978
  customFormat = self.client.get_movie_file(
2577
2979
  db_entry["movieFile"]["id"]
2578
2980
  )["customFormatScore"]
@@ -2584,14 +2986,11 @@ class Arr:
2584
2986
  requests.exceptions.ContentDecodingError,
2585
2987
  requests.exceptions.ConnectionError,
2586
2988
  JSONDecodeError,
2587
- KeyError,
2588
2989
  ):
2589
2990
  continue
2590
- # except KeyError:
2591
- # self.logger.warning("Key Error [%s]", db_entry["id"])
2592
2991
  QualityUnmet = (
2593
- db_entry["episodeFile"]["qualityCutoffNotMet"]
2594
- if "episodeFile" in db_entry
2992
+ db_entry["movieFile"]["qualityCutoffNotMet"]
2993
+ if "movieFile" in db_entry
2595
2994
  else False
2596
2995
  )
2597
2996
  if (
@@ -2607,17 +3006,314 @@ class Arr:
2607
3006
  ).execute()
2608
3007
 
2609
3008
  if self.use_temp_for_missing:
2610
- try:
3009
+ quality_profile_id = db_entry.get("qualityProfileId")
3010
+ if (
3011
+ searched
3012
+ and quality_profile_id in self.temp_quality_profile_ids.values()
3013
+ and not self.keep_temp_profile
3014
+ ):
3015
+ db_entry["qualityProfileId"] = list(
3016
+ self.temp_quality_profile_ids.keys()
3017
+ )[
3018
+ list(self.temp_quality_profile_ids.values()).index(
3019
+ quality_profile_id
3020
+ )
3021
+ ]
3022
+ self.logger.debug(
3023
+ "Updating quality profile for %s to %s",
3024
+ db_entry["title"],
3025
+ db_entry["qualityProfileId"],
3026
+ )
3027
+ elif (
3028
+ not searched
3029
+ and quality_profile_id in self.temp_quality_profile_ids.keys()
3030
+ ):
3031
+ db_entry["qualityProfileId"] = self.temp_quality_profile_ids[
3032
+ quality_profile_id
3033
+ ]
3034
+ self.logger.debug(
3035
+ "Updating quality profile for %s to %s",
3036
+ db_entry["title"],
3037
+ db_entry["qualityProfileId"],
3038
+ )
3039
+ while True:
3040
+ try:
3041
+ self.client.upd_movie(db_entry)
3042
+ break
3043
+ except (
3044
+ requests.exceptions.ChunkedEncodingError,
3045
+ requests.exceptions.ContentDecodingError,
3046
+ requests.exceptions.ConnectionError,
3047
+ JSONDecodeError,
3048
+ ):
3049
+ continue
3050
+
3051
+ title = db_entry["title"]
3052
+ monitored = db_entry["monitored"]
3053
+ tmdbId = db_entry["tmdbId"]
3054
+ year = db_entry["year"]
3055
+ entryId = db_entry["id"]
3056
+ movieFileId = db_entry["movieFileId"]
3057
+ qualityMet = not QualityUnmet if db_entry["hasFile"] else False
3058
+ customFormatMet = customFormat >= minCustomFormat
3059
+
3060
+ if not db_entry["hasFile"]:
3061
+ # Movie is missing a file - always mark as Missing
3062
+ reason = "Missing"
3063
+ elif self.quality_unmet_search and QualityUnmet:
3064
+ reason = "Quality"
3065
+ elif self.custom_format_unmet_search and not customFormatMet:
3066
+ reason = "CustomFormat"
3067
+ elif self.do_upgrade_search:
3068
+ reason = "Upgrade"
3069
+ elif searched:
3070
+ # Movie has file and search is complete
3071
+ reason = "Not being searched"
3072
+ else:
3073
+ reason = "Not being searched"
3074
+
3075
+ to_update = {
3076
+ self.model_file.MovieFileId: movieFileId,
3077
+ self.model_file.Monitored: monitored,
3078
+ self.model_file.QualityMet: qualityMet,
3079
+ self.model_file.Searched: searched,
3080
+ self.model_file.Upgrade: False,
3081
+ self.model_file.MinCustomFormatScore: minCustomFormat,
3082
+ self.model_file.CustomFormatScore: customFormat,
3083
+ self.model_file.CustomFormatMet: customFormatMet,
3084
+ self.model_file.Reason: reason,
3085
+ }
3086
+
3087
+ if request:
3088
+ to_update[self.model_file.IsRequest] = request
3089
+
3090
+ self.logger.debug(
3091
+ "Updating database entry | %s [Searched:%s][Upgrade:%s][QualityMet:%s][CustomFormatMet:%s]",
3092
+ title.ljust(60, "."),
3093
+ str(searched).ljust(5),
3094
+ str(False).ljust(5),
3095
+ str(qualityMet).ljust(5),
3096
+ str(customFormatMet).ljust(5),
3097
+ )
3098
+
3099
+ db_commands = self.model_file.insert(
3100
+ Title=title,
3101
+ Monitored=monitored,
3102
+ TmdbId=tmdbId,
3103
+ Year=year,
3104
+ EntryId=entryId,
3105
+ Searched=searched,
3106
+ MovieFileId=movieFileId,
3107
+ IsRequest=request,
3108
+ QualityMet=qualityMet,
3109
+ Upgrade=False,
3110
+ MinCustomFormatScore=minCustomFormat,
3111
+ CustomFormatScore=customFormat,
3112
+ CustomFormatMet=customFormatMet,
3113
+ Reason=reason,
3114
+ ).on_conflict(conflict_target=[self.model_file.EntryId], update=to_update)
3115
+ db_commands.execute()
3116
+ else:
3117
+ db_commands = self.model_file.delete().where(
3118
+ self.model_file.EntryId == db_entry["id"]
3119
+ )
3120
+ db_commands.execute()
3121
+ elif self.type == "lidarr":
3122
+ if not artist:
3123
+ # Album handling
3124
+ self.model_file: AlbumFilesModel
3125
+ searched = False
3126
+ albumData = self.model_file.get_or_none(
3127
+ self.model_file.EntryId == db_entry["id"]
3128
+ )
3129
+ if db_entry["monitored"] or self.search_unmonitored:
3130
+ while True:
3131
+ try:
3132
+ if albumData:
3133
+ if not albumData.MinCustomFormatScore:
3134
+ try:
3135
+ profile_id = db_entry["profileId"]
3136
+ # Check if this profile ID is known to be invalid
3137
+ if profile_id in self._invalid_quality_profiles:
3138
+ minCustomFormat = 0
3139
+ # Check cache first
3140
+ elif profile_id in self._quality_profile_cache:
3141
+ minCustomFormat = self._quality_profile_cache[
3142
+ profile_id
3143
+ ].get("minFormatScore", 0)
3144
+ else:
3145
+ # Fetch from API and cache
3146
+ try:
3147
+ profile = self.client.get_quality_profile(
3148
+ profile_id
3149
+ )
3150
+ self._quality_profile_cache[profile_id] = (
3151
+ profile
3152
+ )
3153
+ minCustomFormat = profile.get(
3154
+ "minFormatScore", 0
3155
+ )
3156
+ except PyarrResourceNotFound:
3157
+ # Mark as invalid to avoid repeated warnings
3158
+ self._invalid_quality_profiles.add(profile_id)
3159
+ self.logger.warning(
3160
+ "Quality profile %s not found for album %s, defaulting to 0",
3161
+ db_entry.get("profileId"),
3162
+ db_entry.get("title", "Unknown"),
3163
+ )
3164
+ minCustomFormat = 0
3165
+ except Exception:
3166
+ minCustomFormat = 0
3167
+ else:
3168
+ minCustomFormat = albumData.MinCustomFormatScore
3169
+ if (
3170
+ db_entry.get("statistics", {}).get("percentOfTracks", 0)
3171
+ == 100
3172
+ ):
3173
+ # Album has files
3174
+ albumFileId = db_entry.get("statistics", {}).get(
3175
+ "sizeOnDisk", 0
3176
+ )
3177
+ if albumFileId != albumData.AlbumFileId:
3178
+ # Get custom format score from album files
3179
+ customFormat = (
3180
+ 0 # Lidarr may not have customFormatScore
3181
+ )
3182
+ else:
3183
+ customFormat = albumData.CustomFormatScore
3184
+ else:
3185
+ customFormat = 0
3186
+ else:
3187
+ try:
3188
+ profile_id = db_entry["profileId"]
3189
+ # Check if this profile ID is known to be invalid
3190
+ if profile_id in self._invalid_quality_profiles:
3191
+ minCustomFormat = 0
3192
+ # Check cache first
3193
+ elif profile_id in self._quality_profile_cache:
3194
+ minCustomFormat = self._quality_profile_cache[
3195
+ profile_id
3196
+ ].get("minFormatScore", 0)
3197
+ else:
3198
+ # Fetch from API and cache
3199
+ try:
3200
+ profile = self.client.get_quality_profile(
3201
+ profile_id
3202
+ )
3203
+ self._quality_profile_cache[profile_id] = profile
3204
+ minCustomFormat = profile.get("minFormatScore", 0)
3205
+ except PyarrResourceNotFound:
3206
+ # Mark as invalid to avoid repeated warnings
3207
+ self._invalid_quality_profiles.add(profile_id)
3208
+ self.logger.warning(
3209
+ "Quality profile %s not found for album %s, defaulting to 0",
3210
+ db_entry.get("profileId"),
3211
+ db_entry.get("title", "Unknown"),
3212
+ )
3213
+ minCustomFormat = 0
3214
+ except Exception:
3215
+ minCustomFormat = 0
3216
+ if (
3217
+ db_entry.get("statistics", {}).get("percentOfTracks", 0)
3218
+ == 100
3219
+ ):
3220
+ customFormat = 0 # Lidarr may not have customFormatScore
3221
+ else:
3222
+ customFormat = 0
3223
+ break
3224
+ except (
3225
+ requests.exceptions.ChunkedEncodingError,
3226
+ requests.exceptions.ContentDecodingError,
3227
+ requests.exceptions.ConnectionError,
3228
+ JSONDecodeError,
3229
+ ):
3230
+ continue
3231
+
3232
+ # Determine if album has all tracks
3233
+ hasAllTracks = (
3234
+ db_entry.get("statistics", {}).get("percentOfTracks", 0) == 100
3235
+ )
3236
+
3237
+ # Check if quality cutoff is met for Lidarr
3238
+ # Unlike Sonarr/Radarr which have a qualityCutoffNotMet boolean field,
3239
+ # Lidarr requires us to check the track file quality against the profile cutoff
3240
+ QualityUnmet = False
3241
+ if hasAllTracks:
3242
+ try:
3243
+ # Get the artist's quality profile to find the cutoff
3244
+ artist_id = db_entry.get("artistId")
3245
+ artist_data = self.client.get_artist(artist_id)
3246
+ profile_id = artist_data.get("qualityProfileId")
3247
+
3248
+ if profile_id:
3249
+ # Get or use cached profile
3250
+ if profile_id in self._quality_profile_cache:
3251
+ profile = self._quality_profile_cache[profile_id]
3252
+ else:
3253
+ profile = self.client.get_quality_profile(profile_id)
3254
+ self._quality_profile_cache[profile_id] = profile
3255
+
3256
+ cutoff_quality_id = profile.get("cutoff")
3257
+ upgrade_allowed = profile.get("upgradeAllowed", False)
3258
+
3259
+ if cutoff_quality_id and upgrade_allowed:
3260
+ # Get track files for this album to check their quality
3261
+ album_id = db_entry.get("id")
3262
+ track_files = self.client.get_track_file(
3263
+ albumId=[album_id]
3264
+ )
3265
+
3266
+ if track_files:
3267
+ # Check if any track file's quality is below the cutoff
3268
+ for track_file in track_files:
3269
+ file_quality = track_file.get("quality", {}).get(
3270
+ "quality", {}
3271
+ )
3272
+ file_quality_id = file_quality.get("id", 0)
3273
+
3274
+ if file_quality_id < cutoff_quality_id:
3275
+ QualityUnmet = True
3276
+ self.logger.trace(
3277
+ "Album '%s' has quality below cutoff: %s (ID: %d) < cutoff (ID: %d)",
3278
+ db_entry.get("title", "Unknown"),
3279
+ file_quality.get("name", "Unknown"),
3280
+ file_quality_id,
3281
+ cutoff_quality_id,
3282
+ )
3283
+ break
3284
+ except Exception as e:
3285
+ self.logger.trace(
3286
+ "Could not determine quality cutoff status for album '%s': %s",
3287
+ db_entry.get("title", "Unknown"),
3288
+ str(e),
3289
+ )
3290
+ # Default to False if we can't determine
3291
+ QualityUnmet = False
3292
+
3293
+ if (
3294
+ hasAllTracks
3295
+ and not (self.quality_unmet_search and QualityUnmet)
3296
+ and not (
3297
+ self.custom_format_unmet_search and customFormat < minCustomFormat
3298
+ )
3299
+ ):
3300
+ searched = True
3301
+ self.model_queue.update(Completed=True).where(
3302
+ self.model_queue.EntryId == db_entry["id"]
3303
+ ).execute()
3304
+
3305
+ if self.use_temp_for_missing:
3306
+ quality_profile_id = db_entry.get("qualityProfileId")
2611
3307
  if (
2612
3308
  searched
2613
- and db_entry["qualityProfileId"]
2614
- in self.temp_quality_profile_ids.values()
3309
+ and quality_profile_id in self.temp_quality_profile_ids.values()
3310
+ and not self.keep_temp_profile
2615
3311
  ):
2616
3312
  db_entry["qualityProfileId"] = list(
2617
3313
  self.temp_quality_profile_ids.keys()
2618
3314
  )[
2619
3315
  list(self.temp_quality_profile_ids.values()).index(
2620
- db_entry["qualityProfileId"]
3316
+ quality_profile_id
2621
3317
  )
2622
3318
  ]
2623
3319
  self.logger.debug(
@@ -2627,24 +3323,209 @@ class Arr:
2627
3323
  )
2628
3324
  elif (
2629
3325
  not searched
2630
- and db_entry["qualityProfileId"]
2631
- in self.temp_quality_profile_ids.keys()
3326
+ and quality_profile_id in self.temp_quality_profile_ids.keys()
2632
3327
  ):
2633
3328
  db_entry["qualityProfileId"] = self.temp_quality_profile_ids[
2634
- db_entry["qualityProfileId"]
3329
+ quality_profile_id
2635
3330
  ]
2636
3331
  self.logger.debug(
2637
3332
  "Updating quality profile for %s to %s",
2638
3333
  db_entry["title"],
2639
- self.temp_quality_profile_ids[db_entry["qualityProfileId"]],
3334
+ db_entry["qualityProfileId"],
2640
3335
  )
2641
- except KeyError:
2642
- self.logger.warning(
2643
- "Check quality profile settings for %s", db_entry["title"]
3336
+ while True:
3337
+ try:
3338
+ self.client.upd_album(db_entry)
3339
+ break
3340
+ except (
3341
+ requests.exceptions.ChunkedEncodingError,
3342
+ requests.exceptions.ContentDecodingError,
3343
+ requests.exceptions.ConnectionError,
3344
+ JSONDecodeError,
3345
+ ):
3346
+ continue
3347
+
3348
+ title = db_entry.get("title", "Unknown Album")
3349
+ monitored = db_entry.get("monitored", False)
3350
+ # Handle artist field which can be an object or might not exist
3351
+ artist_obj = db_entry.get("artist", {})
3352
+ if isinstance(artist_obj, dict):
3353
+ # Try multiple possible field names for artist name
3354
+ artistName = (
3355
+ artist_obj.get("artistName")
3356
+ or artist_obj.get("name")
3357
+ or artist_obj.get("title")
3358
+ or "Unknown Artist"
2644
3359
  )
3360
+ else:
3361
+ artistName = "Unknown Artist"
3362
+ artistId = db_entry.get("artistId", 0)
3363
+ foreignAlbumId = db_entry.get("foreignAlbumId", "")
3364
+ releaseDate = db_entry.get("releaseDate")
3365
+ entryId = db_entry.get("id", 0)
3366
+ albumFileId = 1 if hasAllTracks else 0 # Use 1/0 to indicate presence
3367
+ qualityMet = not QualityUnmet if hasAllTracks else False
3368
+ customFormatMet = customFormat >= minCustomFormat
3369
+
3370
+ if not hasAllTracks:
3371
+ # Album is missing tracks - always mark as Missing
3372
+ reason = "Missing"
3373
+ elif self.quality_unmet_search and QualityUnmet:
3374
+ reason = "Quality"
3375
+ elif self.custom_format_unmet_search and not customFormatMet:
3376
+ reason = "CustomFormat"
3377
+ elif self.do_upgrade_search:
3378
+ reason = "Upgrade"
3379
+ elif searched:
3380
+ # Album is complete and not being searched
3381
+ reason = "Not being searched"
3382
+ else:
3383
+ reason = "Not being searched"
3384
+
3385
+ to_update = {
3386
+ self.model_file.AlbumFileId: albumFileId,
3387
+ self.model_file.Monitored: monitored,
3388
+ self.model_file.QualityMet: qualityMet,
3389
+ self.model_file.Searched: searched,
3390
+ self.model_file.Upgrade: False,
3391
+ self.model_file.MinCustomFormatScore: minCustomFormat,
3392
+ self.model_file.CustomFormatScore: customFormat,
3393
+ self.model_file.CustomFormatMet: customFormatMet,
3394
+ self.model_file.Reason: reason,
3395
+ self.model_file.ArtistTitle: artistName,
3396
+ self.model_file.ArtistId: artistId,
3397
+ self.model_file.ForeignAlbumId: foreignAlbumId,
3398
+ self.model_file.ReleaseDate: releaseDate,
3399
+ }
3400
+
3401
+ if request:
3402
+ to_update[self.model_file.IsRequest] = request
3403
+
3404
+ self.logger.debug(
3405
+ "Updating database entry | %s - %s [Searched:%s][Upgrade:%s][QualityMet:%s][CustomFormatMet:%s]",
3406
+ artistName.ljust(30, "."),
3407
+ title.ljust(30, "."),
3408
+ str(searched).ljust(5),
3409
+ str(False).ljust(5),
3410
+ str(qualityMet).ljust(5),
3411
+ str(customFormatMet).ljust(5),
3412
+ )
3413
+
3414
+ db_commands = self.model_file.insert(
3415
+ Title=title,
3416
+ Monitored=monitored,
3417
+ ArtistTitle=artistName,
3418
+ ArtistId=artistId,
3419
+ ForeignAlbumId=foreignAlbumId,
3420
+ ReleaseDate=releaseDate,
3421
+ EntryId=entryId,
3422
+ Searched=searched,
3423
+ AlbumFileId=albumFileId,
3424
+ IsRequest=request,
3425
+ QualityMet=qualityMet,
3426
+ Upgrade=False,
3427
+ MinCustomFormatScore=minCustomFormat,
3428
+ CustomFormatScore=customFormat,
3429
+ CustomFormatMet=customFormatMet,
3430
+ Reason=reason,
3431
+ ).on_conflict(conflict_target=[self.model_file.EntryId], update=to_update)
3432
+ db_commands.execute()
3433
+
3434
+ # Store tracks for this album (Lidarr only)
3435
+ if self.track_file_model:
3436
+ try:
3437
+ # Fetch tracks for this album via the track API
3438
+ # Tracks are NOT in the media field, they're a separate endpoint
3439
+ tracks = self.client.get_tracks(albumId=entryId)
3440
+ self.logger.debug(
3441
+ f"Fetched {len(tracks) if isinstance(tracks, list) else 0} tracks for album {entryId}"
3442
+ )
3443
+
3444
+ if tracks and isinstance(tracks, list):
3445
+ # First, delete existing tracks for this album
3446
+ self.track_file_model.delete().where(
3447
+ self.track_file_model.AlbumId == entryId
3448
+ ).execute()
3449
+
3450
+ # Insert new tracks
3451
+ track_insert_count = 0
3452
+ for track in tracks:
3453
+ # Get monitored status from track or default to album's monitored status
3454
+ track_monitored = track.get(
3455
+ "monitored", db_entry.get("monitored", False)
3456
+ )
3457
+
3458
+ self.track_file_model.insert(
3459
+ EntryId=track.get("id"),
3460
+ AlbumId=entryId,
3461
+ TrackNumber=track.get("trackNumber", ""),
3462
+ Title=track.get("title", ""),
3463
+ Duration=track.get("duration", 0),
3464
+ HasFile=track.get("hasFile", False),
3465
+ TrackFileId=track.get("trackFileId", 0),
3466
+ Monitored=track_monitored,
3467
+ ).execute()
3468
+ track_insert_count += 1
3469
+
3470
+ if track_insert_count > 0:
3471
+ self.logger.info(
3472
+ f"Stored {track_insert_count} tracks for album {entryId} ({title})"
3473
+ )
3474
+ else:
3475
+ self.logger.debug(
3476
+ f"No tracks found for album {entryId} ({title})"
3477
+ )
3478
+ except Exception as e:
3479
+ self.logger.warning(
3480
+ f"Could not fetch tracks for album {entryId} ({title}): {e}"
3481
+ )
3482
+ else:
3483
+ db_commands = self.model_file.delete().where(
3484
+ self.model_file.EntryId == db_entry["id"]
3485
+ )
3486
+ db_commands.execute()
3487
+ # Also delete tracks for this album (Lidarr only)
3488
+ if self.track_file_model:
3489
+ self.track_file_model.delete().where(
3490
+ self.track_file_model.AlbumId == db_entry["id"]
3491
+ ).execute()
3492
+ else:
3493
+ # Artist handling
3494
+ self.artists_file_model: ArtistFilesModel
3495
+ EntryId = db_entry["id"]
3496
+ artistData = self.artists_file_model.get_or_none(
3497
+ self.artists_file_model.EntryId == EntryId
3498
+ )
3499
+ if db_entry["monitored"] or self.search_unmonitored:
2645
3500
  while True:
2646
3501
  try:
2647
- self.client.upd_movie(db_entry)
3502
+ artistMetadata = self.client.get_artist(id_=EntryId) or {}
3503
+ quality_profile_id = None
3504
+ if isinstance(artistMetadata, dict):
3505
+ quality_profile_id = artistMetadata.get("qualityProfileId")
3506
+ else:
3507
+ quality_profile_id = getattr(
3508
+ artistMetadata, "qualityProfileId", None
3509
+ )
3510
+ if not artistData:
3511
+ if quality_profile_id:
3512
+ profile = (
3513
+ self.client.get_quality_profile(quality_profile_id)
3514
+ or {}
3515
+ )
3516
+ minCustomFormat = profile.get("minFormatScore") or 0
3517
+ else:
3518
+ self.logger.warning(
3519
+ "Artist %s (%s) missing qualityProfileId; "
3520
+ "defaulting custom format score to 0",
3521
+ db_entry.get("artistName"),
3522
+ EntryId,
3523
+ )
3524
+ minCustomFormat = 0
3525
+ else:
3526
+ minCustomFormat = getattr(
3527
+ artistData, "MinCustomFormatScore", 0
3528
+ )
2648
3529
  break
2649
3530
  except (
2650
3531
  requests.exceptions.ChunkedEncodingError,
@@ -2653,73 +3534,52 @@ class Arr:
2653
3534
  JSONDecodeError,
2654
3535
  ):
2655
3536
  continue
3537
+ # Calculate if artist is fully searched based on album statistics
3538
+ statistics = artistMetadata.get("statistics", {})
3539
+ albumCount = statistics.get("albumCount", 0)
3540
+ statistics.get("totalAlbumCount", 0)
3541
+ # Check if there's any album with files (sizeOnDisk > 0)
3542
+ sizeOnDisk = statistics.get("sizeOnDisk", 0)
3543
+ # Artist is considered searched if it has albums and at least some have files
3544
+ searched = albumCount > 0 and sizeOnDisk > 0
3545
+
3546
+ Title = artistMetadata.get("artistName")
3547
+ Monitored = db_entry["monitored"]
2656
3548
 
2657
- title = db_entry["title"]
2658
- monitored = db_entry["monitored"]
2659
- tmdbId = db_entry["tmdbId"]
2660
- year = db_entry["year"]
2661
- entryId = db_entry["id"]
2662
- movieFileId = db_entry["movieFileId"]
2663
- qualityMet = not QualityUnmet if db_entry["hasFile"] else False
2664
- customFormatMet = customFormat > minCustomFormat
2665
-
2666
- if not db_entry["hasFile"]:
2667
- reason = "Missing"
2668
- elif self.quality_unmet_search and QualityUnmet:
2669
- reason = "Quality"
2670
- elif self.custom_format_unmet_search and not customFormatMet:
2671
- reason = "CustomFormat"
2672
- elif self.do_upgrade_search:
2673
- reason = "Upgrade"
2674
- else:
2675
- reason = None
2676
-
2677
- to_update = {
2678
- self.model_file.MovieFileId: movieFileId,
2679
- self.model_file.Monitored: monitored,
2680
- self.model_file.QualityMet: qualityMet,
2681
- self.model_file.Searched: searched,
2682
- self.model_file.Upgrade: False,
2683
- self.model_file.MinCustomFormatScore: minCustomFormat,
2684
- self.model_file.CustomFormatScore: customFormat,
2685
- self.model_file.CustomFormatMet: customFormatMet,
2686
- self.model_file.Reason: reason,
2687
- }
3549
+ to_update = {
3550
+ self.artists_file_model.Monitored: Monitored,
3551
+ self.artists_file_model.Title: Title,
3552
+ self.artists_file_model.Searched: searched,
3553
+ self.artists_file_model.Upgrade: False,
3554
+ self.artists_file_model.MinCustomFormatScore: minCustomFormat,
3555
+ }
2688
3556
 
2689
- if request:
2690
- to_update[self.model_file.IsRequest] = request
3557
+ self.logger.debug(
3558
+ "Updating database entry | %s [Searched:%s][Upgrade:%s]",
3559
+ Title.ljust(60, "."),
3560
+ str(searched).ljust(5),
3561
+ str(False).ljust(5),
3562
+ )
2691
3563
 
2692
- self.logger.debug(
2693
- "Updating database entry | %s [Searched:%s][Upgrade:%s][QualityMet:%s][CustomFormatMet:%s]",
2694
- title.ljust(60, "."),
2695
- str(searched).ljust(5),
2696
- str(False).ljust(5),
2697
- str(qualityMet).ljust(5),
2698
- str(customFormatMet).ljust(5),
2699
- )
3564
+ db_commands = self.artists_file_model.insert(
3565
+ EntryId=EntryId,
3566
+ Title=Title,
3567
+ Searched=searched,
3568
+ Monitored=Monitored,
3569
+ Upgrade=False,
3570
+ MinCustomFormatScore=minCustomFormat,
3571
+ ).on_conflict(
3572
+ conflict_target=[self.artists_file_model.EntryId], update=to_update
3573
+ )
3574
+ db_commands.execute()
2700
3575
 
2701
- db_commands = self.model_file.insert(
2702
- Title=title,
2703
- Monitored=monitored,
2704
- TmdbId=tmdbId,
2705
- Year=year,
2706
- EntryId=entryId,
2707
- Searched=searched,
2708
- MovieFileId=movieFileId,
2709
- IsRequest=request,
2710
- QualityMet=qualityMet,
2711
- Upgrade=False,
2712
- MinCustomFormatScore=minCustomFormat,
2713
- CustomFormatScore=customFormat,
2714
- CustomFormatMet=customFormatMet,
2715
- Reason=reason,
2716
- ).on_conflict(conflict_target=[self.model_file.EntryId], update=to_update)
2717
- db_commands.execute()
2718
- else:
2719
- db_commands = self.model_file.delete().where(
2720
- self.model_file.EntryId == db_entry["id"]
2721
- )
2722
- db_commands.execute()
3576
+ # Note: Albums are now handled separately in db_update()
3577
+ # No need to recursively process albums here to avoid duplication
3578
+ else:
3579
+ db_commands = self.artists_file_model.delete().where(
3580
+ self.artists_file_model.EntryId == EntryId
3581
+ )
3582
+ db_commands.execute()
2723
3583
 
2724
3584
  except requests.exceptions.ConnectionError as e:
2725
3585
  self.logger.debug(
@@ -2751,10 +3611,11 @@ class Arr:
2751
3611
  try:
2752
3612
  while True:
2753
3613
  try:
2754
- res = self.client._delete(
2755
- f"queue/{id_}?removeFromClient={remove_from_client}&blocklist={blacklist}",
2756
- self.client.ver_uri,
2757
- )
3614
+ res = self.client.del_queue(id_, remove_from_client, blacklist)
3615
+ # res = self.client._delete(
3616
+ # f"queue/{id_}?removeFromClient={remove_from_client}&blocklist={blacklist}",
3617
+ # self.client.ver_uri,
3618
+ # )
2758
3619
  break
2759
3620
  except (
2760
3621
  requests.exceptions.ChunkedEncodingError,
@@ -2763,8 +3624,8 @@ class Arr:
2763
3624
  JSONDecodeError,
2764
3625
  ):
2765
3626
  continue
2766
- except PyarrResourceNotFound:
2767
- self.logger.error("Connection Error")
3627
+ except PyarrResourceNotFound as e:
3628
+ self.logger.error("Connection Error: " + e.message)
2768
3629
  raise DelayLoopException(length=300, type=self._name)
2769
3630
  return res
2770
3631
 
@@ -2778,6 +3639,9 @@ class Arr:
2778
3639
  if file.is_dir():
2779
3640
  self.logger.trace("Not probeable: File is a directory: %s", file)
2780
3641
  return False
3642
+ if file.name.endswith(".!qB"):
3643
+ self.logger.trace("Not probeable: File is still downloading: %s", file)
3644
+ return False
2781
3645
  output = ffmpeg.probe(
2782
3646
  str(file.absolute()), cmd=self.manager.qbit_manager.ffprobe_downloader.probe_path
2783
3647
  )
@@ -2909,16 +3773,26 @@ class Arr:
2909
3773
  request_tag = (
2910
3774
  "[OVERSEERR REQUEST]: "
2911
3775
  if request and self.overseerr_requests
2912
- else "[OMBI REQUEST]: "
2913
- if request and self.ombi_search_requests
2914
- else "[PRIORITY SEARCH - TODAY]: "
2915
- if todays
2916
- else ""
3776
+ else (
3777
+ "[OMBI REQUEST]: "
3778
+ if request and self.ombi_search_requests
3779
+ else "[PRIORITY SEARCH - TODAY]: " if todays else ""
3780
+ )
2917
3781
  )
2918
3782
  self.refresh_download_queue()
2919
3783
  if request or todays:
2920
3784
  bypass_limit = True
2921
- if (not self.search_missing) or (file_model is None):
3785
+ if file_model is None:
3786
+ return None
3787
+ features_enabled = (
3788
+ self.search_missing
3789
+ or self.do_upgrade_search
3790
+ or self.quality_unmet_search
3791
+ or self.custom_format_unmet_search
3792
+ or self.ombi_search_requests
3793
+ or self.overseerr_requests
3794
+ )
3795
+ if not features_enabled and not (request or todays):
2922
3796
  return None
2923
3797
  elif not self.is_alive:
2924
3798
  raise NoConnectionrException(f"Could not connect to {self.uri}", type="arr")
@@ -2992,7 +3866,8 @@ class Arr:
2992
3866
  self.model_file.update(Searched=True, Upgrade=True).where(
2993
3867
  file_model.EntryId == file_model.EntryId
2994
3868
  ).execute()
2995
- if file_model.Reason:
3869
+ reason_text = getattr(file_model, "Reason", None) or None
3870
+ if reason_text:
2996
3871
  self.logger.hnotice(
2997
3872
  "%sSearching for: %s | S%02dE%03d | %s | [id=%s|AirDateUTC=%s][%s]",
2998
3873
  request_tag,
@@ -3002,7 +3877,7 @@ class Arr:
3002
3877
  file_model.Title,
3003
3878
  file_model.EntryId,
3004
3879
  file_model.AirDateUtc,
3005
- file_model.Reason,
3880
+ reason_text,
3006
3881
  )
3007
3882
  else:
3008
3883
  self.logger.hnotice(
@@ -3015,6 +3890,15 @@ class Arr:
3015
3890
  file_model.EntryId,
3016
3891
  file_model.AirDateUtc,
3017
3892
  )
3893
+ description = f"{file_model.SeriesTitle} S{file_model.SeasonNumber:02d}E{file_model.EpisodeNumber:02d}"
3894
+ if getattr(file_model, "Title", None):
3895
+ description = f"{description} · {file_model.Title}"
3896
+ context_label = self._humanize_request_tag(request_tag)
3897
+ self._record_search_activity(
3898
+ description,
3899
+ context=context_label,
3900
+ detail=str(reason_text) if reason_text else None,
3901
+ )
3018
3902
  return True
3019
3903
  else:
3020
3904
  file_model: SeriesFilesModel
@@ -3056,12 +3940,22 @@ class Arr:
3056
3940
  self.logger.hnotice(
3057
3941
  "%sSearching for: %s | %s | [id=%s]",
3058
3942
  request_tag,
3059
- "Missing episodes in"
3060
- if "Missing" in self.search_api_command
3061
- else "All episodes in",
3943
+ (
3944
+ "Missing episodes in"
3945
+ if "Missing" in self.search_api_command
3946
+ else "All episodes in"
3947
+ ),
3062
3948
  file_model.Title,
3063
3949
  file_model.EntryId,
3064
3950
  )
3951
+ context_label = self._humanize_request_tag(request_tag)
3952
+ scope = (
3953
+ "Missing episodes in"
3954
+ if "Missing" in self.search_api_command
3955
+ else "All episodes in"
3956
+ )
3957
+ description = f"{scope} {file_model.Title}"
3958
+ self._record_search_activity(description, context=context_label)
3065
3959
  return True
3066
3960
  elif self.type == "radarr":
3067
3961
  file_model: MoviesFilesModel
@@ -3113,7 +4007,8 @@ class Arr:
3113
4007
  self.model_file.update(Searched=True, Upgrade=True).where(
3114
4008
  file_model.EntryId == file_model.EntryId
3115
4009
  ).execute()
3116
- if file_model.Reason:
4010
+ reason_text = getattr(file_model, "Reason", None)
4011
+ if reason_text:
3117
4012
  self.logger.hnotice(
3118
4013
  "%sSearching for: %s (%s) [tmdbId=%s|id=%s][%s]",
3119
4014
  request_tag,
@@ -3121,7 +4016,7 @@ class Arr:
3121
4016
  file_model.Year,
3122
4017
  file_model.TmdbId,
3123
4018
  file_model.EntryId,
3124
- file_model.Reason,
4019
+ reason_text,
3125
4020
  )
3126
4021
  else:
3127
4022
  self.logger.hnotice(
@@ -3132,6 +4027,97 @@ class Arr:
3132
4027
  file_model.TmdbId,
3133
4028
  file_model.EntryId,
3134
4029
  )
4030
+ context_label = self._humanize_request_tag(request_tag)
4031
+ description = (
4032
+ f"{file_model.Title} ({file_model.Year})"
4033
+ if getattr(file_model, "Year", None)
4034
+ else f"{file_model.Title}"
4035
+ )
4036
+ self._record_search_activity(
4037
+ description,
4038
+ context=context_label,
4039
+ detail=str(reason_text) if reason_text else None,
4040
+ )
4041
+ return True
4042
+ elif self.type == "lidarr":
4043
+ file_model: AlbumFilesModel
4044
+ if not (request or todays):
4045
+ (
4046
+ self.model_queue.select(self.model_queue.Completed)
4047
+ .where(self.model_queue.EntryId == file_model.EntryId)
4048
+ .execute()
4049
+ )
4050
+ else:
4051
+ pass
4052
+ if file_model.EntryId in self.queue_file_ids:
4053
+ self.logger.debug(
4054
+ "%sSkipping: Already Searched: %s - %s (%s)",
4055
+ request_tag,
4056
+ file_model.ArtistTitle,
4057
+ file_model.Title,
4058
+ file_model.EntryId,
4059
+ )
4060
+ self.model_file.update(Searched=True, Upgrade=True).where(
4061
+ file_model.EntryId == file_model.EntryId
4062
+ ).execute()
4063
+ return True
4064
+ active_commands = self.arr_db_query_commands_count()
4065
+ self.logger.info("%s active search commands, %s remaining", active_commands, commands)
4066
+ if not bypass_limit and active_commands >= self.search_command_limit:
4067
+ self.logger.trace(
4068
+ "Idle: Too many commands in queue: %s - %s | [id=%s]",
4069
+ file_model.ArtistTitle,
4070
+ file_model.Title,
4071
+ file_model.EntryId,
4072
+ )
4073
+ return False
4074
+ self.persistent_queue.insert(EntryId=file_model.EntryId).on_conflict_ignore().execute()
4075
+
4076
+ self.model_queue.insert(
4077
+ Completed=False, EntryId=file_model.EntryId
4078
+ ).on_conflict_replace().execute()
4079
+ if file_model.EntryId:
4080
+ while True:
4081
+ try:
4082
+ self.client.post_command("AlbumSearch", albumIds=[file_model.EntryId])
4083
+ break
4084
+ except (
4085
+ requests.exceptions.ChunkedEncodingError,
4086
+ requests.exceptions.ContentDecodingError,
4087
+ requests.exceptions.ConnectionError,
4088
+ JSONDecodeError,
4089
+ ):
4090
+ continue
4091
+ self.model_file.update(Searched=True, Upgrade=True).where(
4092
+ file_model.EntryId == file_model.EntryId
4093
+ ).execute()
4094
+ reason_text = getattr(file_model, "Reason", None)
4095
+ if reason_text:
4096
+ self.logger.hnotice(
4097
+ "%sSearching for: %s - %s [foreignAlbumId=%s|id=%s][%s]",
4098
+ request_tag,
4099
+ file_model.ArtistTitle,
4100
+ file_model.Title,
4101
+ file_model.ForeignAlbumId,
4102
+ file_model.EntryId,
4103
+ reason_text,
4104
+ )
4105
+ else:
4106
+ self.logger.hnotice(
4107
+ "%sSearching for: %s - %s [foreignAlbumId=%s|id=%s]",
4108
+ request_tag,
4109
+ file_model.ArtistTitle,
4110
+ file_model.Title,
4111
+ file_model.ForeignAlbumId,
4112
+ file_model.EntryId,
4113
+ )
4114
+ context_label = self._humanize_request_tag(request_tag)
4115
+ description = f"{file_model.ArtistTitle} - {file_model.Title}"
4116
+ self._record_search_activity(
4117
+ description,
4118
+ context=context_label,
4119
+ detail=str(reason_text) if reason_text else None,
4120
+ )
3135
4121
  return True
3136
4122
 
3137
4123
  def process(self):
@@ -3170,6 +4156,7 @@ class Arr:
3170
4156
  else:
3171
4157
  raise qbittorrentapi.exceptions.APIError
3172
4158
  torrents = [t for t in torrents if hasattr(t, "category")]
4159
+ self.category_torrent_count = len(torrents)
3173
4160
  if not len(torrents):
3174
4161
  raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads")
3175
4162
  if not has_internet(self.manager.qbit_manager.client):
@@ -3214,7 +4201,7 @@ class Arr:
3214
4201
  "[Last active: %s] "
3215
4202
  "| [%s] | %s (%s)",
3216
4203
  round(torrent.progress * 100, 2),
3217
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4204
+ datetime.fromtimestamp(torrent.added_on),
3218
4205
  round(torrent.availability * 100, 2),
3219
4206
  timedelta(seconds=torrent.eta),
3220
4207
  datetime.fromtimestamp(torrent.last_activity),
@@ -3232,7 +4219,7 @@ class Arr:
3232
4219
  "[Last active: %s] "
3233
4220
  "| [%s] | %s (%s)",
3234
4221
  round(torrent.progress * 100, 2),
3235
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4222
+ datetime.fromtimestamp(torrent.added_on),
3236
4223
  round(torrent.availability * 100, 2),
3237
4224
  timedelta(seconds=torrent.eta),
3238
4225
  datetime.fromtimestamp(torrent.last_activity),
@@ -3251,7 +4238,7 @@ class Arr:
3251
4238
  "[Last active: %s] "
3252
4239
  "| [%s] | %s (%s)",
3253
4240
  round(torrent.progress * 100, 2),
3254
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4241
+ datetime.fromtimestamp(torrent.added_on),
3255
4242
  round(torrent.availability * 100, 2),
3256
4243
  timedelta(seconds=torrent.eta),
3257
4244
  datetime.fromtimestamp(torrent.last_activity),
@@ -3259,8 +4246,6 @@ class Arr:
3259
4246
  torrent.name,
3260
4247
  torrent.hash,
3261
4248
  )
3262
- if torrent.state_enum == TorrentStates.QUEUED_DOWNLOAD:
3263
- self.recently_queue[torrent.hash] = time.time()
3264
4249
 
3265
4250
  def _process_single_torrent_added_to_ignore_cache(
3266
4251
  self, torrent: qbittorrentapi.TorrentDictionary
@@ -3272,7 +4257,7 @@ class Arr:
3272
4257
  "[Last active: %s] "
3273
4258
  "| [%s] | %s (%s)",
3274
4259
  round(torrent.progress * 100, 2),
3275
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4260
+ datetime.fromtimestamp(torrent.added_on),
3276
4261
  round(torrent.availability * 100, 2),
3277
4262
  timedelta(seconds=torrent.eta),
3278
4263
  datetime.fromtimestamp(torrent.last_activity),
@@ -3292,7 +4277,7 @@ class Arr:
3292
4277
  "[Last active: %s] "
3293
4278
  "| [%s] | %s (%s)",
3294
4279
  round(torrent.progress * 100, 2),
3295
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4280
+ datetime.fromtimestamp(torrent.added_on),
3296
4281
  round(torrent.availability * 100, 2),
3297
4282
  timedelta(seconds=torrent.eta),
3298
4283
  datetime.fromtimestamp(torrent.last_activity),
@@ -3309,7 +4294,7 @@ class Arr:
3309
4294
  "[Last active: %s] "
3310
4295
  "| [%s] | %s (%s)",
3311
4296
  round(torrent.progress * 100, 2),
3312
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4297
+ datetime.fromtimestamp(torrent.added_on),
3313
4298
  round(torrent.availability * 100, 2),
3314
4299
  timedelta(seconds=torrent.eta),
3315
4300
  datetime.fromtimestamp(torrent.last_activity),
@@ -3325,8 +4310,8 @@ class Arr:
3325
4310
  # deletion if they have been added more than "IgnoreTorrentsYoungerThan"
3326
4311
  # seconds ago
3327
4312
  if (
3328
- self.recently_queue.get(torrent.hash, torrent.added_on)
3329
- < time.time() - self.ignore_torrents_younger_than
4313
+ torrent.added_on < time.time() - self.ignore_torrents_younger_than
4314
+ and torrent.last_activity < (time.time() - self.ignore_torrents_younger_than)
3330
4315
  ):
3331
4316
  self.logger.info(
3332
4317
  "Deleting Stale torrent: %s | "
@@ -3336,7 +4321,7 @@ class Arr:
3336
4321
  "| [%s] | %s (%s)",
3337
4322
  extra,
3338
4323
  round(torrent.progress * 100, 2),
3339
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4324
+ datetime.fromtimestamp(torrent.added_on),
3340
4325
  round(torrent.availability * 100, 2),
3341
4326
  timedelta(seconds=torrent.eta),
3342
4327
  datetime.fromtimestamp(torrent.last_activity),
@@ -3353,7 +4338,7 @@ class Arr:
3353
4338
  "[Last active: %s] "
3354
4339
  "| [%s] | %s (%s)",
3355
4340
  round(torrent.progress * 100, 2),
3356
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4341
+ datetime.fromtimestamp(torrent.added_on),
3357
4342
  round(torrent.availability * 100, 2),
3358
4343
  timedelta(seconds=torrent.eta),
3359
4344
  datetime.fromtimestamp(torrent.last_activity),
@@ -3383,7 +4368,7 @@ class Arr:
3383
4368
  "[Last active: %s] "
3384
4369
  "| [%s] | %s (%s)",
3385
4370
  round(torrent.progress * 100, 2),
3386
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4371
+ datetime.fromtimestamp(torrent.added_on),
3387
4372
  round(torrent.availability * 100, 2),
3388
4373
  timedelta(seconds=torrent.eta),
3389
4374
  datetime.fromtimestamp(torrent.last_activity),
@@ -3401,7 +4386,7 @@ class Arr:
3401
4386
  "[Last active: %s] "
3402
4387
  "| [%s] | %s (%s)",
3403
4388
  round(torrent.progress * 100, 2),
3404
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4389
+ datetime.fromtimestamp(torrent.added_on),
3405
4390
  round(torrent.availability * 100, 2),
3406
4391
  timedelta(seconds=torrent.eta),
3407
4392
  datetime.fromtimestamp(torrent.last_activity),
@@ -3420,7 +4405,7 @@ class Arr:
3420
4405
  "[Last active: %s] "
3421
4406
  "| [%s] | %s (%s)",
3422
4407
  round(torrent.progress * 100, 2),
3423
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4408
+ datetime.fromtimestamp(torrent.added_on),
3424
4409
  round(torrent.availability * 100, 2),
3425
4410
  timedelta(seconds=torrent.eta),
3426
4411
  datetime.fromtimestamp(torrent.last_activity),
@@ -3439,7 +4424,7 @@ class Arr:
3439
4424
  "[Last active: %s] "
3440
4425
  "| [%s] | %s (%s)",
3441
4426
  round(torrent.progress * 100, 2),
3442
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4427
+ datetime.fromtimestamp(torrent.added_on),
3443
4428
  round(torrent.availability * 100, 2),
3444
4429
  timedelta(seconds=torrent.eta),
3445
4430
  datetime.fromtimestamp(torrent.last_activity),
@@ -3456,7 +4441,7 @@ class Arr:
3456
4441
  "[Last active: %s] "
3457
4442
  "| [%s] | %s (%s)",
3458
4443
  round(torrent.progress * 100, 2),
3459
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4444
+ datetime.fromtimestamp(torrent.added_on),
3460
4445
  round(torrent.availability * 100, 2),
3461
4446
  timedelta(seconds=torrent.eta),
3462
4447
  datetime.fromtimestamp(torrent.last_activity),
@@ -3477,7 +4462,7 @@ class Arr:
3477
4462
  "[Last active: %s] "
3478
4463
  "| [%s] | %s (%s)",
3479
4464
  round(torrent.progress * 100, 2),
3480
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4465
+ datetime.fromtimestamp(torrent.added_on),
3481
4466
  round(torrent.availability * 100, 2),
3482
4467
  timedelta(seconds=torrent.eta),
3483
4468
  datetime.fromtimestamp(torrent.last_activity),
@@ -3493,7 +4478,7 @@ class Arr:
3493
4478
  "[Last active: %s] "
3494
4479
  "| [%s] | %s (%s)",
3495
4480
  round(torrent.progress * 100, 2),
3496
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4481
+ datetime.fromtimestamp(torrent.added_on),
3497
4482
  round(torrent.availability * 100, 2),
3498
4483
  timedelta(seconds=torrent.eta),
3499
4484
  datetime.fromtimestamp(torrent.last_activity),
@@ -3524,7 +4509,7 @@ class Arr:
3524
4509
  "[Last active: %s] "
3525
4510
  "| [%s] | %s (%s)",
3526
4511
  round(torrent.progress * 100, 2),
3527
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4512
+ datetime.fromtimestamp(torrent.added_on),
3528
4513
  round(torrent.availability * 100, 2),
3529
4514
  timedelta(seconds=torrent.eta),
3530
4515
  datetime.fromtimestamp(torrent.last_activity),
@@ -3546,7 +4531,7 @@ class Arr:
3546
4531
  "[Last active: %s] "
3547
4532
  "| [%s] | %s (%s)",
3548
4533
  round(torrent.progress * 100, 2),
3549
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4534
+ datetime.fromtimestamp(torrent.added_on),
3550
4535
  round(torrent.availability * 100, 2),
3551
4536
  timedelta(seconds=torrent.eta),
3552
4537
  datetime.fromtimestamp(torrent.last_activity),
@@ -3562,7 +4547,7 @@ class Arr:
3562
4547
  "[Last active: %s] "
3563
4548
  "| [%s] | %s (%s)",
3564
4549
  round(torrent.progress * 100, 2),
3565
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4550
+ datetime.fromtimestamp(torrent.added_on),
3566
4551
  round(torrent.availability * 100, 2),
3567
4552
  timedelta(seconds=torrent.eta),
3568
4553
  datetime.fromtimestamp(torrent.last_activity),
@@ -3582,7 +4567,7 @@ class Arr:
3582
4567
  "[Last active: %s] "
3583
4568
  "| [%s] | %s (%s)",
3584
4569
  round(torrent.progress * 100, 2),
3585
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4570
+ datetime.fromtimestamp(torrent.added_on),
3586
4571
  round(torrent.availability * 100, 2),
3587
4572
  timedelta(seconds=torrent.eta),
3588
4573
  datetime.fromtimestamp(torrent.last_activity),
@@ -3599,7 +4584,7 @@ class Arr:
3599
4584
  "[Last active: %s] "
3600
4585
  "| [%s] | %s (%s)",
3601
4586
  round(torrent.progress * 100, 2),
3602
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4587
+ datetime.fromtimestamp(torrent.added_on),
3603
4588
  round(torrent.availability * 100, 2),
3604
4589
  timedelta(seconds=torrent.eta),
3605
4590
  datetime.fromtimestamp(torrent.last_activity),
@@ -3617,7 +4602,7 @@ class Arr:
3617
4602
  "[Last active: %s] "
3618
4603
  "| [%s] | %s (%s)",
3619
4604
  round(torrent.progress * 100, 2),
3620
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4605
+ datetime.fromtimestamp(torrent.added_on),
3621
4606
  torrent.ratio,
3622
4607
  timedelta(seconds=torrent.seeding_time),
3623
4608
  datetime.fromtimestamp(torrent.last_activity),
@@ -3635,7 +4620,7 @@ class Arr:
3635
4620
  "[Last active: %s] "
3636
4621
  "| [%s] | %s (%s)",
3637
4622
  round(torrent.progress * 100, 2),
3638
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4623
+ datetime.fromtimestamp(torrent.added_on),
3639
4624
  torrent.ratio,
3640
4625
  timedelta(seconds=torrent.seeding_time),
3641
4626
  datetime.fromtimestamp(torrent.last_activity),
@@ -3716,9 +4701,7 @@ class Arr:
3716
4701
  "[Last active: %s] "
3717
4702
  "| [%s] | %s (%s)",
3718
4703
  round(torrent.progress * 100, 2),
3719
- datetime.fromtimestamp(
3720
- self.recently_queue.get(torrent.hash, torrent.added_on)
3721
- ),
4704
+ datetime.fromtimestamp(torrent.added_on),
3722
4705
  round(torrent.availability * 100, 2),
3723
4706
  timedelta(seconds=torrent.eta),
3724
4707
  datetime.fromtimestamp(torrent.last_activity),
@@ -3747,7 +4730,7 @@ class Arr:
3747
4730
  "[Last active: %s] "
3748
4731
  "| [%s] | %s (%s)",
3749
4732
  round(torrent.progress * 100, 2),
3750
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4733
+ datetime.fromtimestamp(torrent.added_on),
3751
4734
  round(torrent.availability * 100, 2),
3752
4735
  timedelta(seconds=torrent.eta),
3753
4736
  datetime.fromtimestamp(torrent.last_activity),
@@ -3763,7 +4746,7 @@ class Arr:
3763
4746
  "[Last active: %s] "
3764
4747
  "| [%s] | %s (%s)",
3765
4748
  round(torrent.progress * 100, 2),
3766
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4749
+ datetime.fromtimestamp(torrent.added_on),
3767
4750
  round(torrent.availability * 100, 2),
3768
4751
  timedelta(seconds=torrent.eta),
3769
4752
  datetime.fromtimestamp(torrent.last_activity),
@@ -3780,7 +4763,7 @@ class Arr:
3780
4763
  "[Last active: %s] "
3781
4764
  "| [%s] | %s (%s)",
3782
4765
  round(torrent.progress * 100, 2),
3783
- datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
4766
+ datetime.fromtimestamp(torrent.added_on),
3784
4767
  round(torrent.availability * 100, 2),
3785
4768
  timedelta(seconds=torrent.eta),
3786
4769
  datetime.fromtimestamp(torrent.last_activity),
@@ -3826,38 +4809,46 @@ class Arr:
3826
4809
  )
3827
4810
 
3828
4811
  data_settings = {
3829
- "ratio_limit": r
3830
- if (
3831
- r := most_important_tracker.get(
3832
- "MaxUploadRatio", self.seeding_mode_global_max_upload_ratio
4812
+ "ratio_limit": (
4813
+ r
4814
+ if (
4815
+ r := most_important_tracker.get(
4816
+ "MaxUploadRatio", self.seeding_mode_global_max_upload_ratio
4817
+ )
3833
4818
  )
3834
- )
3835
- > 0
3836
- else -5,
3837
- "seeding_time_limit": r
3838
- if (
3839
- r := most_important_tracker.get(
3840
- "MaxSeedingTime", self.seeding_mode_global_max_seeding_time
4819
+ > 0
4820
+ else -5
4821
+ ),
4822
+ "seeding_time_limit": (
4823
+ r
4824
+ if (
4825
+ r := most_important_tracker.get(
4826
+ "MaxSeedingTime", self.seeding_mode_global_max_seeding_time
4827
+ )
3841
4828
  )
3842
- )
3843
- > 0
3844
- else -5,
3845
- "dl_limit": r
3846
- if (
3847
- r := most_important_tracker.get(
3848
- "DownloadRateLimit", self.seeding_mode_global_download_limit
4829
+ > 0
4830
+ else -5
4831
+ ),
4832
+ "dl_limit": (
4833
+ r
4834
+ if (
4835
+ r := most_important_tracker.get(
4836
+ "DownloadRateLimit", self.seeding_mode_global_download_limit
4837
+ )
3849
4838
  )
3850
- )
3851
- > 0
3852
- else -5,
3853
- "up_limit": r
3854
- if (
3855
- r := most_important_tracker.get(
3856
- "UploadRateLimit", self.seeding_mode_global_upload_limit
4839
+ > 0
4840
+ else -5
4841
+ ),
4842
+ "up_limit": (
4843
+ r
4844
+ if (
4845
+ r := most_important_tracker.get(
4846
+ "UploadRateLimit", self.seeding_mode_global_upload_limit
4847
+ )
3857
4848
  )
3858
- )
3859
- > 0
3860
- else -5,
4849
+ > 0
4850
+ else -5
4851
+ ),
3861
4852
  "super_seeding": most_important_tracker.get("SuperSeedMode", torrent.super_seeding),
3862
4853
  "max_eta": most_important_tracker.get("MaximumETA", self.maximum_eta),
3863
4854
  }
@@ -3910,6 +4901,8 @@ class Arr:
3910
4901
  not return_value and self.in_tags(torrent, "qBitrr-allowed_seeding")
3911
4902
  ) or self.in_tags(torrent, "qBitrr-free_space_paused"):
3912
4903
  self.remove_tags(torrent, ["qBitrr-allowed_seeding"])
4904
+
4905
+ self.logger.trace("Config Settings returned [%s]: %r", torrent.name, data_settings)
3913
4906
  return (
3914
4907
  return_value,
3915
4908
  data_settings.get("max_eta", self.maximum_eta),
@@ -3926,13 +4919,19 @@ class Arr:
3926
4919
  torrent.add_trackers(need_to_be_added)
3927
4920
  with contextlib.suppress(BaseException):
3928
4921
  for tracker in torrent.trackers:
3929
- if (
4922
+ tracker_url = getattr(tracker, "url", None)
4923
+ message_text = (getattr(tracker, "msg", "") or "").lower()
4924
+ remove_for_message = (
3930
4925
  self.remove_dead_trackers
3931
- and (
3932
- any(tracker.msg == m for m in self.seeding_mode_global_bad_tracker_msg)
3933
- ) # TODO: Add more messages
3934
- ) or tracker.url in self._remove_trackers_if_exists:
3935
- _remove_urls.add(tracker.url)
4926
+ and self._normalized_bad_tracker_msgs
4927
+ and any(
4928
+ keyword in message_text for keyword in self._normalized_bad_tracker_msgs
4929
+ )
4930
+ )
4931
+ if not tracker_url:
4932
+ continue
4933
+ if remove_for_message or tracker_url in self._remove_trackers_if_exists:
4934
+ _remove_urls.add(tracker_url)
3936
4935
  if _remove_urls:
3937
4936
  self.logger.trace(
3938
4937
  "Removing trackers from torrent: %s (%s) - %s",
@@ -3949,22 +4948,26 @@ class Arr:
3949
4948
  # Only use globals if there is not a configured equivalent value on the
3950
4949
  # highest priority tracker
3951
4950
  data = {
3952
- "ratio_limit": r
3953
- if (
3954
- r := most_important_tracker.get(
3955
- "MaxUploadRatio", self.seeding_mode_global_max_upload_ratio
4951
+ "ratio_limit": (
4952
+ r
4953
+ if (
4954
+ r := most_important_tracker.get(
4955
+ "MaxUploadRatio", self.seeding_mode_global_max_upload_ratio
4956
+ )
3956
4957
  )
3957
- )
3958
- > 0
3959
- else None,
3960
- "seeding_time_limit": r
3961
- if (
3962
- r := most_important_tracker.get(
3963
- "MaxSeedingTime", self.seeding_mode_global_max_seeding_time
4958
+ > 0
4959
+ else None
4960
+ ),
4961
+ "seeding_time_limit": (
4962
+ r
4963
+ if (
4964
+ r := most_important_tracker.get(
4965
+ "MaxSeedingTime", self.seeding_mode_global_max_seeding_time
4966
+ )
3964
4967
  )
3965
- )
3966
- > 0
3967
- else None,
4968
+ > 0
4969
+ else None
4970
+ ),
3968
4971
  }
3969
4972
  if any(r is not None for r in data):
3970
4973
  if (
@@ -4021,9 +5024,9 @@ class Arr:
4021
5024
  else:
4022
5025
  data = {
4023
5026
  "ratio_limit": r if (r := self.seeding_mode_global_max_upload_ratio) > 0 else None,
4024
- "seeding_time_limit": r
4025
- if (r := self.seeding_mode_global_max_seeding_time) > 0
4026
- else None,
5027
+ "seeding_time_limit": (
5028
+ r if (r := self.seeding_mode_global_max_seeding_time) > 0 else None
5029
+ ),
4027
5030
  }
4028
5031
  if any(r is not None for r in data):
4029
5032
  if (
@@ -4068,27 +5071,34 @@ class Arr:
4068
5071
  def _stalled_check(self, torrent: qbittorrentapi.TorrentDictionary, time_now: float) -> bool:
4069
5072
  stalled_ignore = True
4070
5073
  if not self.allowed_stalled:
5074
+ self.logger.trace("Stalled check: Stalled delay disabled")
4071
5075
  return False
4072
- if (
4073
- self.recently_queue.get(torrent.hash, torrent.added_on)
4074
- < time_now - self.ignore_torrents_younger_than
4075
- ):
5076
+ if time_now < torrent.added_on + self.ignore_torrents_younger_than:
5077
+ self.logger.trace(
5078
+ "Stalled check: In recent queue %s [Current:%s][Added:%s][Starting:%s]",
5079
+ torrent.name,
5080
+ datetime.fromtimestamp(time_now),
5081
+ datetime.fromtimestamp(torrent.added_on),
5082
+ datetime.fromtimestamp(
5083
+ torrent.added_on + timedelta(minutes=self.stalled_delay).seconds
5084
+ ),
5085
+ )
4076
5086
  return True
4077
5087
  if self.stalled_delay == 0:
4078
5088
  self.logger.trace(
4079
- "Stalled check: %s [Current:%s][Added:%s][Limit:No Limit]",
5089
+ "Stalled check: %s [Current:%s][Last Activity:%s][Limit:No Limit]",
4080
5090
  torrent.name,
4081
5091
  datetime.fromtimestamp(time_now),
4082
- datetime.fromtimestamp(torrent.added_on),
5092
+ datetime.fromtimestamp(torrent.last_activity),
4083
5093
  )
4084
5094
  else:
4085
5095
  self.logger.trace(
4086
- "Stalled check: %s [Current:%s][Added:%s][Limit:%s]",
5096
+ "Stalled check: %s [Current:%s][Last Activity:%s][Limit:%s]",
4087
5097
  torrent.name,
4088
5098
  datetime.fromtimestamp(time_now),
4089
- datetime.fromtimestamp(torrent.added_on),
5099
+ datetime.fromtimestamp(torrent.last_activity),
4090
5100
  datetime.fromtimestamp(
4091
- torrent.added_on + timedelta(minutes=self.stalled_delay).seconds
5101
+ torrent.last_activity + timedelta(minutes=self.stalled_delay).seconds
4092
5102
  ),
4093
5103
  )
4094
5104
  if (
@@ -4099,11 +5109,7 @@ class Arr:
4099
5109
  and not self.in_tags(torrent, "qBitrr-free_space_paused")
4100
5110
  )
4101
5111
  or (
4102
- (
4103
- self.recently_queue.get(torrent.hash, torrent.added_on)
4104
- < time_now - self.ignore_torrents_younger_than
4105
- and torrent.availability < 1
4106
- )
5112
+ torrent.availability < 1
4107
5113
  and torrent.hash in self.cleaned_torrents
4108
5114
  and torrent.state_enum in (TorrentStates.DOWNLOADING)
4109
5115
  and not self.in_tags(torrent, "qBitrr-ignored")
@@ -4112,7 +5118,8 @@ class Arr:
4112
5118
  ) and self.allowed_stalled:
4113
5119
  if (
4114
5120
  self.stalled_delay > 0
4115
- and time_now >= torrent.added_on + timedelta(minutes=self.stalled_delay).seconds
5121
+ and time_now
5122
+ >= torrent.last_activity + timedelta(minutes=self.stalled_delay).seconds
4116
5123
  ):
4117
5124
  stalled_ignore = False
4118
5125
  self.logger.trace("Process stalled, delay expired: %s", torrent.name)
@@ -4135,7 +5142,15 @@ class Arr:
4135
5142
  else:
4136
5143
  self.logger.trace("Stalled, adding tag: %s", torrent.name)
4137
5144
  elif self.in_tags(torrent, "qBitrr-allowed_stalled"):
4138
- self.logger.trace("Stalled: %s", torrent.name)
5145
+ self.logger.trace(
5146
+ "Stalled: %s [Current:%s][Last Activity:%s][Limit:%s]",
5147
+ torrent.name,
5148
+ datetime.fromtimestamp(time_now),
5149
+ datetime.fromtimestamp(torrent.last_activity),
5150
+ datetime.fromtimestamp(
5151
+ torrent.last_activity + timedelta(minutes=self.stalled_delay).seconds
5152
+ ),
5153
+ )
4139
5154
 
4140
5155
  elif self.in_tags(torrent, "qBitrr-allowed_stalled"):
4141
5156
  self.remove_tags(torrent, ["qBitrr-allowed_stalled"])
@@ -4162,7 +5177,14 @@ class Arr:
4162
5177
  )
4163
5178
  maximum_eta = _tracker_max_eta
4164
5179
 
4165
- stalled_ignore = self._stalled_check(torrent, time_now)
5180
+ if torrent.state_enum in (
5181
+ TorrentStates.METADATA_DOWNLOAD,
5182
+ TorrentStates.STALLED_DOWNLOAD,
5183
+ TorrentStates.DOWNLOADING,
5184
+ ):
5185
+ stalled_ignore = self._stalled_check(torrent, time_now)
5186
+ else:
5187
+ stalled_ignore = False
4166
5188
 
4167
5189
  if self.in_tags(torrent, "qBitrr-ignored"):
4168
5190
  self.remove_tags(torrent, ["qBitrr-allowed_seeding", "qBitrr-free_space_paused"])
@@ -4204,21 +5226,22 @@ class Arr:
4204
5226
  self._process_single_torrent_added_to_ignore_cache(torrent)
4205
5227
  elif torrent.state_enum == TorrentStates.QUEUED_UPLOAD:
4206
5228
  self._process_single_torrent_queued_upload(torrent, leave_alone)
4207
- elif (
4208
- torrent.progress >= self.maximum_deletable_percentage
4209
- and not self.is_complete_state(torrent)
4210
- and not self.in_tags(torrent, "qBitrr-ignored")
4211
- and not self.in_tags(torrent, "qBitrr-free_space_paused")
4212
- and not stalled_ignore
4213
- ) and torrent.hash in self.cleaned_torrents:
4214
- self._process_single_torrent_percentage_threshold(torrent, maximum_eta)
4215
5229
  # Resume monitored downloads which have been paused.
4216
5230
  elif (
4217
5231
  torrent.state_enum == TorrentStates.PAUSED_DOWNLOAD
4218
5232
  and torrent.amount_left != 0
4219
5233
  and not self.in_tags(torrent, "qBitrr-free_space_paused")
5234
+ and not self.in_tags(torrent, "qBitrr-ignored")
4220
5235
  ):
4221
5236
  self._process_single_torrent_paused(torrent)
5237
+ elif (
5238
+ torrent.progress <= self.maximum_deletable_percentage
5239
+ and not self.is_complete_state(torrent)
5240
+ and not self.in_tags(torrent, "qBitrr-ignored")
5241
+ and not self.in_tags(torrent, "qBitrr-free_space_paused")
5242
+ and not stalled_ignore
5243
+ ) and torrent.hash in self.cleaned_torrents:
5244
+ self._process_single_torrent_percentage_threshold(torrent, maximum_eta)
4222
5245
  # Ignore torrents which have been submitted to their respective Arr
4223
5246
  # instance for import.
4224
5247
  elif (
@@ -4259,8 +5282,7 @@ class Arr:
4259
5282
  elif (
4260
5283
  torrent.state_enum != TorrentStates.PAUSED_DOWNLOAD
4261
5284
  and torrent.state_enum.is_downloading
4262
- and self.recently_queue.get(torrent.hash, torrent.added_on)
4263
- < time_now - self.ignore_torrents_younger_than
5285
+ and time_now > torrent.added_on + self.ignore_torrents_younger_than
4264
5286
  and 0 < maximum_eta < torrent.eta
4265
5287
  and not self.do_not_remove_slow
4266
5288
  and not self.in_tags(torrent, "qBitrr-ignored")
@@ -4274,8 +5296,7 @@ class Arr:
4274
5296
  # "IgnoreTorrentsYoungerThan" variable, mark it for deletion.
4275
5297
  if (
4276
5298
  (
4277
- self.recently_queue.get(torrent.hash, torrent.added_on)
4278
- < time_now - self.ignore_torrents_younger_than
5299
+ time_now > torrent.added_on + self.ignore_torrents_younger_than
4279
5300
  and torrent.availability < 1
4280
5301
  )
4281
5302
  and torrent.hash in self.cleaned_torrents
@@ -4335,6 +5356,9 @@ class Arr:
4335
5356
  elif self.type == "radarr":
4336
5357
  entry_id_field = "movieId"
4337
5358
  file_id_field = "MovieFileId"
5359
+ elif self.type == "lidarr":
5360
+ entry_id_field = "albumId"
5361
+ file_id_field = "AlbumFileId"
4338
5362
  else:
4339
5363
  return False # Unknown type
4340
5364
 
@@ -4388,14 +5412,16 @@ class Arr:
4388
5412
  elif self.seeding_mode_global_remove_torrent == 1 and torrent.ratio >= ratio_limit:
4389
5413
  return True
4390
5414
  elif self.seeding_mode_global_remove_torrent == -1 and (
4391
- torrent.ratio >= ratio_limit or torrent.seeding_time >= seeding_time_limit
5415
+ torrent.ratio >= ratio_limit and torrent.seeding_time >= seeding_time_limit
4392
5416
  ):
4393
5417
  return True
4394
5418
  else:
4395
5419
  return False
4396
5420
 
4397
5421
  def refresh_download_queue(self):
4398
- self.queue = self.get_queue()
5422
+ self.queue = self.get_queue() or []
5423
+ self.queue_active_count = len(self.queue)
5424
+ self.category_torrent_count = 0
4399
5425
  self.requeue_cache = defaultdict(set)
4400
5426
  if self.queue:
4401
5427
  self.cache = {
@@ -4435,23 +5461,36 @@ class Arr:
4435
5461
  self.model_queue.delete().where(
4436
5462
  self.model_queue.EntryId.not_in(list(self.queue_file_ids))
4437
5463
  ).execute()
5464
+ elif self.type == "lidarr":
5465
+ self.requeue_cache = {
5466
+ entry["id"]: entry["albumId"] for entry in self.queue if entry.get("albumId")
5467
+ }
5468
+ self.queue_file_ids = {
5469
+ entry["albumId"] for entry in self.queue if entry.get("albumId")
5470
+ }
5471
+ if self.model_queue:
5472
+ self.model_queue.delete().where(
5473
+ self.model_queue.EntryId.not_in(list(self.queue_file_ids))
5474
+ ).execute()
4438
5475
 
4439
5476
  self._update_bad_queue_items()
4440
5477
 
4441
5478
  def get_queue(self, page=1, page_size=1000, sort_direction="ascending", sort_key="timeLeft"):
4442
- while True:
4443
- try:
4444
- res = self.client.get_queue(
4445
- page=page, page_size=page_size, sort_key=sort_key, sort_dir=sort_direction
4446
- )
4447
- break
4448
- except (
5479
+ res = with_retry(
5480
+ lambda: self.client.get_queue(
5481
+ page=page, page_size=page_size, sort_key=sort_key, sort_dir=sort_direction
5482
+ ),
5483
+ retries=3,
5484
+ backoff=0.5,
5485
+ max_backoff=3,
5486
+ exceptions=(
4449
5487
  requests.exceptions.ChunkedEncodingError,
4450
5488
  requests.exceptions.ContentDecodingError,
4451
5489
  requests.exceptions.ConnectionError,
4452
5490
  JSONDecodeError,
4453
- ):
4454
- continue
5491
+ requests.exceptions.RequestException,
5492
+ ),
5493
+ )
4455
5494
  try:
4456
5495
  res = res.get("records", [])
4457
5496
  except AttributeError:
@@ -4488,7 +5527,7 @@ class Arr:
4488
5527
  self.files_to_explicitly_delete = iter(_path_filter.copy())
4489
5528
 
4490
5529
  def parse_quality_profiles(self) -> dict[int, int]:
4491
- temp_quality_profile_ids = {}
5530
+ temp_quality_profile_ids: dict[int, int] = {}
4492
5531
 
4493
5532
  while True:
4494
5533
  try:
@@ -4500,7 +5539,23 @@ class Arr:
4500
5539
  requests.exceptions.ConnectionError,
4501
5540
  JSONDecodeError,
4502
5541
  ):
5542
+ # transient network/encoding issues; retry
4503
5543
  continue
5544
+ except PyarrServerError as e:
5545
+ # Server-side error (e.g., Radarr DB disk I/O). Log and wait 5 minutes before retrying.
5546
+ self.logger.error(
5547
+ "Failed to get quality profiles (server error): %s -- retrying in 5 minutes", e
5548
+ )
5549
+ try:
5550
+ time.sleep(300)
5551
+ except Exception:
5552
+ pass
5553
+ continue
5554
+ except Exception as e:
5555
+ # Unexpected error; log and continue without profiles.
5556
+ self.logger.error("Unexpected error getting quality profiles: %s", e)
5557
+ profiles = []
5558
+ break
4504
5559
 
4505
5560
  for n in self.main_quality_profiles:
4506
5561
  pair = [n, self.temp_quality_profiles[self.main_quality_profiles.index(n)]]
@@ -4519,24 +5574,55 @@ class Arr:
4519
5574
  def register_search_mode(self):
4520
5575
  if self.search_setup_completed:
4521
5576
  return
4522
- if not self.search_missing:
5577
+
5578
+ db1, db2, db3, db4, db5 = self._get_models()
5579
+
5580
+ if not (
5581
+ self.search_missing
5582
+ or self.do_upgrade_search
5583
+ or self.quality_unmet_search
5584
+ or self.custom_format_unmet_search
5585
+ or self.ombi_search_requests
5586
+ or self.overseerr_requests
5587
+ ):
5588
+ if db5 and getattr(self, "torrents", None) is None:
5589
+ self.torrent_db = SqliteDatabase(None)
5590
+ self.torrent_db.init(
5591
+ str(self._app_data_folder.joinpath("Torrents.db")),
5592
+ pragmas={
5593
+ "journal_mode": "wal",
5594
+ "cache_size": -64_000,
5595
+ "foreign_keys": 1,
5596
+ "ignore_check_constraints": 0,
5597
+ "synchronous": 0,
5598
+ },
5599
+ timeout=15,
5600
+ )
5601
+
5602
+ class Torrents(db5):
5603
+ class Meta:
5604
+ database = self.torrent_db
5605
+
5606
+ self.torrent_db.connect()
5607
+ self.torrent_db.create_tables([Torrents])
5608
+ self.torrents = Torrents
4523
5609
  self.search_setup_completed = True
4524
5610
  return
4525
5611
 
5612
+ self.search_db_file.parent.mkdir(parents=True, exist_ok=True)
4526
5613
  self.db = SqliteDatabase(None)
4527
5614
  self.db.init(
4528
5615
  str(self.search_db_file),
4529
5616
  pragmas={
4530
5617
  "journal_mode": "wal",
4531
- "cache_size": -1 * 64000, # 64MB
5618
+ "cache_size": -64_000,
4532
5619
  "foreign_keys": 1,
4533
5620
  "ignore_check_constraints": 0,
4534
5621
  "synchronous": 0,
4535
5622
  },
5623
+ timeout=15,
4536
5624
  )
4537
5625
 
4538
- db1, db2, db3, db4 = self._get_models()
4539
-
4540
5626
  class Files(db1):
4541
5627
  class Meta:
4542
5628
  database = self.db
@@ -4550,7 +5636,18 @@ class Arr:
4550
5636
  database = self.db
4551
5637
 
4552
5638
  self.db.connect()
4553
- if db3:
5639
+
5640
+ if db4:
5641
+
5642
+ class Tracks(db4):
5643
+ class Meta:
5644
+ database = self.db
5645
+
5646
+ self.track_file_model = Tracks
5647
+ else:
5648
+ self.track_file_model = None
5649
+
5650
+ if db3 and self.type == "sonarr":
4554
5651
 
4555
5652
  class Series(db3):
4556
5653
  class Meta:
@@ -4558,35 +5655,86 @@ class Arr:
4558
5655
 
4559
5656
  self.db.create_tables([Files, Queue, PersistingQueue, Series])
4560
5657
  self.series_file_model = Series
5658
+ self.artists_file_model = None
5659
+ elif db3 and self.type == "lidarr":
5660
+
5661
+ class Artists(db3):
5662
+ class Meta:
5663
+ database = self.db
5664
+
5665
+ self.db.create_tables([Files, Queue, PersistingQueue, Artists, Tracks])
5666
+ self.artists_file_model = Artists
5667
+ self.series_file_model = None # Lidarr uses artists, not series
4561
5668
  else:
5669
+ # Radarr or any type without db3/db4 (series/artists/tracks models)
4562
5670
  self.db.create_tables([Files, Queue, PersistingQueue])
5671
+ self.artists_file_model = None
5672
+ self.series_file_model = None
4563
5673
 
4564
- if db4:
5674
+ if db5:
4565
5675
  self.torrent_db = SqliteDatabase(None)
4566
5676
  self.torrent_db.init(
4567
5677
  str(self._app_data_folder.joinpath("Torrents.db")),
4568
5678
  pragmas={
4569
5679
  "journal_mode": "wal",
4570
- "cache_size": -1 * 64000, # 64MB
5680
+ "cache_size": -64_000,
4571
5681
  "foreign_keys": 1,
4572
5682
  "ignore_check_constraints": 0,
4573
5683
  "synchronous": 0,
4574
5684
  },
5685
+ timeout=15,
4575
5686
  )
4576
5687
 
4577
- class Torrents(db4):
5688
+ class Torrents(db5):
4578
5689
  class Meta:
4579
5690
  database = self.torrent_db
4580
5691
 
4581
5692
  self.torrent_db.connect()
4582
5693
  self.torrent_db.create_tables([Torrents])
4583
5694
  self.torrents = Torrents
5695
+ else:
5696
+ self.torrents = None
4584
5697
 
4585
5698
  self.model_file = Files
4586
5699
  self.model_queue = Queue
4587
5700
  self.persistent_queue = PersistingQueue
4588
5701
  self.search_setup_completed = True
4589
5702
 
5703
+ def _get_models(
5704
+ self,
5705
+ ) -> tuple[
5706
+ type[EpisodeFilesModel] | type[MoviesFilesModel] | type[AlbumFilesModel],
5707
+ type[EpisodeQueueModel] | type[MovieQueueModel] | type[AlbumQueueModel],
5708
+ type[SeriesFilesModel] | type[ArtistFilesModel] | None,
5709
+ type[TrackFilesModel] | None,
5710
+ type[TorrentLibrary] | None,
5711
+ ]:
5712
+ if self.type == "sonarr":
5713
+ return (
5714
+ EpisodeFilesModel,
5715
+ EpisodeQueueModel,
5716
+ SeriesFilesModel,
5717
+ None,
5718
+ TorrentLibrary if TAGLESS else None,
5719
+ )
5720
+ if self.type == "radarr":
5721
+ return (
5722
+ MoviesFilesModel,
5723
+ MovieQueueModel,
5724
+ None,
5725
+ None,
5726
+ TorrentLibrary if TAGLESS else None,
5727
+ )
5728
+ if self.type == "lidarr":
5729
+ return (
5730
+ AlbumFilesModel,
5731
+ AlbumQueueModel,
5732
+ ArtistFilesModel,
5733
+ TrackFilesModel,
5734
+ TorrentLibrary if TAGLESS else None,
5735
+ )
5736
+ raise UnhandledError(f"Well you shouldn't have reached here, Arr.type={self.type}")
5737
+
4590
5738
  def run_request_search(self):
4591
5739
  if (
4592
5740
  (
@@ -4597,13 +5745,13 @@ class Arr:
4597
5745
  or (self.request_search_timer > time.time() - self.search_requests_every_x_seconds)
4598
5746
  ):
4599
5747
  return None
4600
- self.register_search_mode()
4601
5748
  totcommands = -1
4602
5749
  if SEARCH_LOOP_DELAY == -1:
4603
5750
  loop_delay = 30
4604
5751
  else:
4605
5752
  loop_delay = SEARCH_LOOP_DELAY
4606
5753
  try:
5754
+ event = self.manager.qbit_manager.shutdown_event
4607
5755
  self.db_request_update()
4608
5756
  try:
4609
5757
  for entry, commands in self.db_get_request_files():
@@ -4616,26 +5764,23 @@ class Arr:
4616
5764
  loop_delay = 30
4617
5765
  else:
4618
5766
  loop_delay = SEARCH_LOOP_DELAY
4619
- while not self.maybe_do_search(
4620
- entry,
4621
- request=True,
4622
- commands=totcommands,
5767
+ while (not event.is_set()) and (
5768
+ not self.maybe_do_search(
5769
+ entry,
5770
+ request=True,
5771
+ commands=totcommands,
5772
+ )
4623
5773
  ):
4624
5774
  self.logger.debug("Waiting for active request search commands")
4625
- time.sleep(loop_delay)
5775
+ event.wait(loop_delay)
4626
5776
  self.logger.info("Delaying request search loop by %s seconds", loop_delay)
4627
- time.sleep(loop_delay)
5777
+ event.wait(loop_delay)
4628
5778
  if totcommands == 0:
4629
5779
  self.logger.info("All request searches completed")
4630
5780
  else:
4631
5781
  self.logger.info(
4632
5782
  "Request searches not completed, %s remaining", totcommands
4633
5783
  )
4634
- self.logger.debug(
4635
- "%s api calls in %s seconds",
4636
- self.api_call_count,
4637
- (datetime.now() - self.api_call_timer).seconds,
4638
- )
4639
5784
  self.request_search_timer = time.time()
4640
5785
  except NoConnectionrException as e:
4641
5786
  self.logger.error(e.message)
@@ -4669,23 +5814,26 @@ class Arr:
4669
5814
  self.logger.debug(
4670
5815
  "No downloads in category, sleeping for %s", timedelta(seconds=e.length)
4671
5816
  )
4672
- time.sleep(e.length)
5817
+ # Respect shutdown signal
5818
+ self.manager.qbit_manager.shutdown_event.wait(e.length)
4673
5819
 
4674
5820
  def get_year_search(self) -> tuple[list[int], int]:
4675
5821
  years_list = set()
4676
5822
  years = []
4677
5823
  if self.type == "radarr":
4678
- while True:
4679
- try:
4680
- movies = self.client.get_movie()
4681
- break
4682
- except (
5824
+ movies = with_retry(
5825
+ lambda: self.client.get_movie(),
5826
+ retries=3,
5827
+ backoff=0.5,
5828
+ max_backoff=3,
5829
+ exceptions=(
4683
5830
  requests.exceptions.ChunkedEncodingError,
4684
5831
  requests.exceptions.ContentDecodingError,
4685
5832
  requests.exceptions.ConnectionError,
4686
5833
  JSONDecodeError,
4687
- ):
4688
- continue
5834
+ requests.exceptions.RequestException,
5835
+ ),
5836
+ )
4689
5837
 
4690
5838
  for m in movies:
4691
5839
  if not m["monitored"]:
@@ -4694,21 +5842,34 @@ class Arr:
4694
5842
  years_list.add(m["year"])
4695
5843
 
4696
5844
  elif self.type == "sonarr":
4697
- while True:
4698
- try:
4699
- series = self.client.get_series()
4700
- break
4701
- except (
5845
+ series = with_retry(
5846
+ lambda: self.client.get_series(),
5847
+ retries=3,
5848
+ backoff=0.5,
5849
+ max_backoff=3,
5850
+ exceptions=(
4702
5851
  requests.exceptions.ChunkedEncodingError,
4703
5852
  requests.exceptions.ContentDecodingError,
4704
5853
  requests.exceptions.ConnectionError,
4705
5854
  JSONDecodeError,
4706
- ):
4707
- continue
5855
+ requests.exceptions.RequestException,
5856
+ ),
5857
+ )
4708
5858
 
4709
5859
  for s in series:
4710
- self.api_call_count += 1
4711
- episodes = self.client.get_episode(s["id"], True)
5860
+ episodes = with_retry(
5861
+ lambda: self.client.get_episode(s["id"], True),
5862
+ retries=3,
5863
+ backoff=0.5,
5864
+ max_backoff=3,
5865
+ exceptions=(
5866
+ requests.exceptions.ChunkedEncodingError,
5867
+ requests.exceptions.ContentDecodingError,
5868
+ requests.exceptions.ConnectionError,
5869
+ JSONDecodeError,
5870
+ requests.exceptions.RequestException,
5871
+ ),
5872
+ )
4712
5873
  for e in episodes:
4713
5874
  if "airDateUtc" in e:
4714
5875
  if not self.search_specials and e["seasonNumber"] == 0:
@@ -4737,15 +5898,22 @@ class Arr:
4737
5898
  def run_search_loop(self) -> NoReturn:
4738
5899
  run_logs(self.logger)
4739
5900
  try:
4740
- self.register_search_mode()
4741
- if not self.search_missing:
5901
+ if not (
5902
+ self.search_missing
5903
+ or self.do_upgrade_search
5904
+ or self.quality_unmet_search
5905
+ or self.custom_format_unmet_search
5906
+ or self.ombi_search_requests
5907
+ or self.overseerr_requests
5908
+ ):
4742
5909
  return None
4743
5910
  loop_timer = timedelta(minutes=15)
4744
5911
  timer = datetime.now()
4745
5912
  years_index = 0
4746
5913
  totcommands = -1
4747
5914
  self.db_update_processed = False
4748
- while True:
5915
+ event = self.manager.qbit_manager.shutdown_event
5916
+ while not event.is_set():
4749
5917
  if self.loop_completed:
4750
5918
  years_index = 0
4751
5919
  totcommands = -1
@@ -4771,14 +5939,22 @@ class Arr:
4771
5939
  self.search_current_year = years[years_index]
4772
5940
  elif datetime.now() >= (timer + loop_timer):
4773
5941
  self.refresh_download_queue()
4774
- time.sleep(((timer + loop_timer) - datetime.now()).total_seconds())
5942
+ event.wait(((timer + loop_timer) - datetime.now()).total_seconds())
4775
5943
  self.logger.trace("Restarting loop testing")
5944
+ try:
5945
+ self._record_search_activity(None, detail="loop-complete")
5946
+ except Exception:
5947
+ pass
4776
5948
  raise RestartLoopException
4777
5949
  elif datetime.now() >= (timer + loop_timer):
4778
5950
  self.refresh_download_queue()
4779
5951
  self.logger.trace("Restarting loop testing")
5952
+ try:
5953
+ self._record_search_activity(None, detail="loop-complete")
5954
+ except Exception:
5955
+ pass
4780
5956
  raise RestartLoopException
4781
- self.logger.trace("Getting general files")
5957
+ any_commands = False
4782
5958
  for (
4783
5959
  entry,
4784
5960
  todays,
@@ -4786,6 +5962,7 @@ class Arr:
4786
5962
  series_search,
4787
5963
  commands,
4788
5964
  ) in self.db_get_files():
5965
+ any_commands = True
4789
5966
  if totcommands == -1:
4790
5967
  totcommands = commands
4791
5968
  self.logger.info("Starting search for %s items", totcommands)
@@ -4793,25 +5970,39 @@ class Arr:
4793
5970
  loop_delay = 30
4794
5971
  else:
4795
5972
  loop_delay = SEARCH_LOOP_DELAY
4796
- while not self.maybe_do_search(
4797
- entry,
4798
- todays=todays,
4799
- bypass_limit=limit_bypass,
4800
- series_search=series_search,
4801
- commands=totcommands,
5973
+ while (not event.is_set()) and (
5974
+ not self.maybe_do_search(
5975
+ entry,
5976
+ todays=todays,
5977
+ bypass_limit=limit_bypass,
5978
+ series_search=series_search,
5979
+ commands=totcommands,
5980
+ )
4802
5981
  ):
4803
5982
  self.logger.debug("Waiting for active search commands")
4804
- time.sleep(loop_delay)
5983
+ event.wait(loop_delay)
4805
5984
  totcommands -= 1
4806
5985
  self.logger.info("Delaying search loop by %s seconds", loop_delay)
4807
- time.sleep(loop_delay)
5986
+ event.wait(loop_delay)
4808
5987
  if totcommands == 0:
4809
5988
  self.logger.info("All searches completed")
5989
+ try:
5990
+ self._record_search_activity(
5991
+ None, detail="no-pending-searches"
5992
+ )
5993
+ except Exception:
5994
+ pass
4810
5995
  elif datetime.now() >= (timer + loop_timer):
4811
5996
  timer = datetime.now()
4812
5997
  self.logger.info(
4813
5998
  "Searches not completed, %s remaining", totcommands
4814
5999
  )
6000
+ if not any_commands:
6001
+ self.logger.debug("No pending searches for %s", self._name)
6002
+ try:
6003
+ self._record_search_activity(None, detail="no-pending-searches")
6004
+ except Exception:
6005
+ pass
4815
6006
  except RestartLoopException:
4816
6007
  self.loop_completed = True
4817
6008
  self.db_update_processed = False
@@ -4830,7 +6021,7 @@ class Arr:
4830
6021
  raise DelayLoopException(length=300, type="qbit")
4831
6022
  except Exception as e:
4832
6023
  self.logger.exception(e, exc_info=sys.exc_info())
4833
- time.sleep(LOOP_SLEEP_TIMER)
6024
+ event.wait(LOOP_SLEEP_TIMER)
4834
6025
  except DelayLoopException as e:
4835
6026
  if e.type == "qbit":
4836
6027
  self.logger.critical(
@@ -4853,22 +6044,22 @@ class Arr:
4853
6044
  "sleeping for %s",
4854
6045
  timedelta(seconds=e.length),
4855
6046
  )
4856
- time.sleep(e.length)
6047
+ event.wait(e.length)
4857
6048
  self.manager.qbit_manager.should_delay_torrent_scan = False
4858
6049
  except KeyboardInterrupt:
4859
6050
  self.logger.hnotice("Detected Ctrl+C - Terminating process")
4860
6051
  sys.exit(0)
4861
6052
  else:
4862
- time.sleep(5)
6053
+ event.wait(5)
4863
6054
  except KeyboardInterrupt:
4864
6055
  self.logger.hnotice("Detected Ctrl+C - Terminating process")
4865
6056
  sys.exit(0)
4866
6057
 
4867
6058
  def run_torrent_loop(self) -> NoReturn:
4868
6059
  run_logs(self.logger)
4869
- self.register_search_mode()
4870
6060
  self.logger.hnotice("Starting torrent monitoring for %s", self._name)
4871
- while True:
6061
+ event = self.manager.qbit_manager.shutdown_event
6062
+ while not event.is_set():
4872
6063
  try:
4873
6064
  try:
4874
6065
  try:
@@ -4898,7 +6089,7 @@ class Arr:
4898
6089
  sys.exit(0)
4899
6090
  except Exception as e:
4900
6091
  self.logger.error(e, exc_info=sys.exc_info())
4901
- time.sleep(LOOP_SLEEP_TIMER)
6092
+ event.wait(LOOP_SLEEP_TIMER)
4902
6093
  except DelayLoopException as e:
4903
6094
  if e.type == "qbit":
4904
6095
  self.logger.critical(
@@ -4926,7 +6117,7 @@ class Arr:
4926
6117
  "No downloads in category, sleeping for %s",
4927
6118
  timedelta(seconds=e.length),
4928
6119
  )
4929
- time.sleep(e.length)
6120
+ event.wait(e.length)
4930
6121
  self.manager.qbit_manager.should_delay_torrent_scan = False
4931
6122
  except KeyboardInterrupt:
4932
6123
  self.logger.hnotice("Detected Ctrl+C - Terminating process")
@@ -4939,13 +6130,13 @@ class Arr:
4939
6130
  _temp = []
4940
6131
  if self.search_missing:
4941
6132
  self.process_search_loop = pathos.helpers.mp.Process(
4942
- target=self.run_search_loop, daemon=True
6133
+ target=self.run_search_loop, daemon=False
4943
6134
  )
4944
6135
  self.manager.qbit_manager.child_processes.append(self.process_search_loop)
4945
6136
  _temp.append(self.process_search_loop)
4946
- if not any([QBIT_DISABLED, SEARCH_ONLY]):
6137
+ if not (QBIT_DISABLED or SEARCH_ONLY):
4947
6138
  self.process_torrent_loop = pathos.helpers.mp.Process(
4948
- target=self.run_torrent_loop, daemon=True
6139
+ target=self.run_torrent_loop, daemon=False
4949
6140
  )
4950
6141
  self.manager.qbit_manager.child_processes.append(self.process_torrent_loop)
4951
6142
  _temp.append(self.process_torrent_loop)
@@ -4963,7 +6154,6 @@ class PlaceHolderArr(Arr):
4963
6154
  self.queue = []
4964
6155
  self.cache = {}
4965
6156
  self.requeue_cache = {}
4966
- self.recently_queue = {}
4967
6157
  self.sent_to_scan = set()
4968
6158
  self.sent_to_scan_hashes = set()
4969
6159
  self.files_probed = set()
@@ -4984,22 +6174,15 @@ class PlaceHolderArr(Arr):
4984
6174
  self.tracker_delay = ExpiringSet(max_age_seconds=600)
4985
6175
  self._LOG_LEVEL = self.manager.qbit_manager.logger.level
4986
6176
  self.logger = logging.getLogger(f"qBitrr.{self._name}")
4987
- if ENABLE_LOGS:
4988
- logs_folder = HOME_PATH.joinpath("logs")
4989
- logs_folder.mkdir(parents=True, exist_ok=True)
4990
- logs_folder.chmod(mode=0o777)
4991
- logfile = logs_folder.joinpath(self._name + ".log")
4992
- if pathlib.Path(logfile).is_file():
4993
- logold = logs_folder.joinpath(self._name + ".log.old")
4994
- if pathlib.Path(logold).exists():
4995
- logold.unlink()
4996
- logfile.rename(logold)
4997
- fh = logging.FileHandler(logfile)
4998
- self.logger.addHandler(fh)
4999
- run_logs(self.logger)
6177
+ run_logs(self.logger, self._name)
5000
6178
  self.search_missing = False
5001
6179
  self.session = None
5002
6180
  self.search_setup_completed = False
6181
+ self.last_search_description: str | None = None
6182
+ self.last_search_timestamp: str | None = None
6183
+ self.queue_active_count: int = 0
6184
+ self.category_torrent_count: int = 0
6185
+ self.free_space_tagged_count: int = 0
5003
6186
  self.logger.hnotice("Starting %s monitor", self._name)
5004
6187
 
5005
6188
  def _process_errored(self):
@@ -5012,9 +6195,31 @@ class PlaceHolderArr(Arr):
5012
6195
  updated_recheck.append(h)
5013
6196
  if c := self.manager.qbit_manager.cache.get(h):
5014
6197
  temp[c].append(h)
5015
- self.manager.qbit.torrents_recheck(torrent_hashes=updated_recheck)
6198
+ with contextlib.suppress(Exception):
6199
+ with_retry(
6200
+ lambda: self.manager.qbit.torrents_recheck(torrent_hashes=updated_recheck),
6201
+ retries=3,
6202
+ backoff=0.5,
6203
+ max_backoff=3,
6204
+ exceptions=(
6205
+ qbittorrentapi.exceptions.APIError,
6206
+ qbittorrentapi.exceptions.APIConnectionError,
6207
+ requests.exceptions.RequestException,
6208
+ ),
6209
+ )
5016
6210
  for k, v in temp.items():
5017
- self.manager.qbit.torrents_set_category(torrent_hashes=v, category=k)
6211
+ with contextlib.suppress(Exception):
6212
+ with_retry(
6213
+ lambda: self.manager.qbit.torrents_set_category(torrent_hashes=v, category=k),
6214
+ retries=3,
6215
+ backoff=0.5,
6216
+ max_backoff=3,
6217
+ exceptions=(
6218
+ qbittorrentapi.exceptions.APIError,
6219
+ qbittorrentapi.exceptions.APIConnectionError,
6220
+ requests.exceptions.RequestException,
6221
+ ),
6222
+ )
5018
6223
 
5019
6224
  for k in updated_recheck:
5020
6225
  self.timed_ignore_cache.add(k)
@@ -5037,10 +6242,36 @@ class PlaceHolderArr(Arr):
5037
6242
  # Remove all bad torrents from the Client.
5038
6243
  temp_to_delete = set()
5039
6244
  if to_delete_all:
5040
- self.manager.qbit.torrents_delete(hashes=to_delete_all, delete_files=True)
6245
+ with contextlib.suppress(Exception):
6246
+ with_retry(
6247
+ lambda: self.manager.qbit.torrents_delete(
6248
+ hashes=to_delete_all, delete_files=True
6249
+ ),
6250
+ retries=3,
6251
+ backoff=0.5,
6252
+ max_backoff=3,
6253
+ exceptions=(
6254
+ qbittorrentapi.exceptions.APIError,
6255
+ qbittorrentapi.exceptions.APIConnectionError,
6256
+ requests.exceptions.RequestException,
6257
+ ),
6258
+ )
5041
6259
  if self.remove_from_qbit or self.skip_blacklist:
5042
6260
  temp_to_delete = self.remove_from_qbit.union(self.skip_blacklist)
5043
- self.manager.qbit.torrents_delete(hashes=temp_to_delete, delete_files=True)
6261
+ with contextlib.suppress(Exception):
6262
+ with_retry(
6263
+ lambda: self.manager.qbit.torrents_delete(
6264
+ hashes=temp_to_delete, delete_files=True
6265
+ ),
6266
+ retries=3,
6267
+ backoff=0.5,
6268
+ max_backoff=3,
6269
+ exceptions=(
6270
+ qbittorrentapi.exceptions.APIError,
6271
+ qbittorrentapi.exceptions.APIConnectionError,
6272
+ requests.exceptions.RequestException,
6273
+ ),
6274
+ )
5044
6275
  to_delete_all = to_delete_all.union(temp_to_delete)
5045
6276
  for h in to_delete_all:
5046
6277
  if h in self.manager.qbit_manager.name_cache:
@@ -5060,16 +6291,27 @@ class PlaceHolderArr(Arr):
5060
6291
  try:
5061
6292
  while True:
5062
6293
  try:
5063
- torrents = self.manager.qbit_manager.client.torrents.info(
5064
- status_filter="all",
5065
- category=self.category,
5066
- sort="added_on",
5067
- reverse=False,
6294
+ torrents = with_retry(
6295
+ lambda: self.manager.qbit_manager.client.torrents.info(
6296
+ status_filter="all",
6297
+ category=self.category,
6298
+ sort="added_on",
6299
+ reverse=False,
6300
+ ),
6301
+ retries=3,
6302
+ backoff=0.5,
6303
+ max_backoff=3,
6304
+ exceptions=(
6305
+ qbittorrentapi.exceptions.APIError,
6306
+ qbittorrentapi.exceptions.APIConnectionError,
6307
+ requests.exceptions.RequestException,
6308
+ ),
5068
6309
  )
5069
6310
  break
5070
6311
  except qbittorrentapi.exceptions.APIError:
5071
6312
  continue
5072
6313
  torrents = [t for t in torrents if hasattr(t, "category")]
6314
+ self.category_torrent_count = len(torrents)
5073
6315
  if not len(torrents):
5074
6316
  raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads")
5075
6317
  if not has_internet(self.manager.qbit_manager):
@@ -5110,29 +6352,15 @@ class PlaceHolderArr(Arr):
5110
6352
  except DelayLoopException:
5111
6353
  raise
5112
6354
 
5113
- def run_search_loop(self):
5114
- return
5115
-
5116
6355
 
5117
6356
  class FreeSpaceManager(Arr):
5118
6357
  def __init__(self, categories: set[str], manager: ArrManager):
5119
6358
  self._name = "FreeSpaceManager"
6359
+ self.type = "FreeSpaceManager"
5120
6360
  self.manager = manager
5121
6361
  self.logger = logging.getLogger(f"qBitrr.{self._name}")
5122
6362
  self._LOG_LEVEL = self.manager.qbit_manager.logger.level
5123
- if ENABLE_LOGS:
5124
- logs_folder = HOME_PATH.joinpath("logs")
5125
- logs_folder.mkdir(parents=True, exist_ok=True)
5126
- logs_folder.chmod(mode=0o777)
5127
- logfile = logs_folder.joinpath(self._name + ".log")
5128
- if pathlib.Path(logfile).is_file():
5129
- logold = logs_folder.joinpath(self._name + ".log.old")
5130
- if pathlib.Path(logold).exists():
5131
- logold.unlink()
5132
- logfile.rename(logold)
5133
- fh = logging.FileHandler(logfile)
5134
- self.logger.addHandler(fh)
5135
- run_logs(self.logger)
6363
+ run_logs(self.logger, self._name)
5136
6364
  self.categories = categories
5137
6365
  self.logger.trace("Categories: %s", self.categories)
5138
6366
  self.pause = set()
@@ -5143,6 +6371,9 @@ class FreeSpaceManager(Arr):
5143
6371
  )
5144
6372
  self.timed_ignore_cache = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than)
5145
6373
  self.needs_cleanup = False
6374
+ self._app_data_folder = APPDATA_FOLDER
6375
+ # Track search setup state to cooperate with Arr.register_search_mode
6376
+ self.search_setup_completed = False
5146
6377
  if FREE_SPACE_FOLDER == "CHANGE_ME":
5147
6378
  self.completed_folder = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).joinpath(
5148
6379
  next(iter(self.categories))
@@ -5150,52 +6381,68 @@ class FreeSpaceManager(Arr):
5150
6381
  else:
5151
6382
  self.completed_folder = pathlib.Path(FREE_SPACE_FOLDER)
5152
6383
  self.min_free_space = FREE_SPACE
5153
- self.current_free_space = shutil.disk_usage(self.completed_folder).free - parse_size(
5154
- self.min_free_space
6384
+ # Parse once to avoid repeated conversions
6385
+ self._min_free_space_bytes = (
6386
+ parse_size(self.min_free_space) if self.min_free_space != "-1" else 0
6387
+ )
6388
+ self.current_free_space = (
6389
+ shutil.disk_usage(self.completed_folder).free - self._min_free_space_bytes
6390
+ )
6391
+ self.logger.trace(
6392
+ "Free space monitor initialized | Available: %s | Threshold: %s",
6393
+ format_bytes(self.current_free_space + self._min_free_space_bytes),
6394
+ format_bytes(self._min_free_space_bytes),
5155
6395
  )
5156
- self.logger.trace("Current free space: %s", self.current_free_space)
5157
6396
  self.manager.qbit_manager.client.torrents_create_tags(["qBitrr-free_space_paused"])
5158
6397
  self.search_missing = False
6398
+ self.do_upgrade_search = False
6399
+ self.quality_unmet_search = False
6400
+ self.custom_format_unmet_search = False
6401
+ self.ombi_search_requests = False
6402
+ self.overseerr_requests = False
5159
6403
  self.session = None
5160
- self.register_torrent_database()
6404
+ # Ensure torrent tag-emulation tables exist when needed.
6405
+ self.torrents = None
6406
+ self.torrent_db: SqliteDatabase | None = None
6407
+ self.last_search_description: str | None = None
6408
+ self.last_search_timestamp: str | None = None
6409
+ self.queue_active_count: int = 0
6410
+ self.category_torrent_count: int = 0
6411
+ self.free_space_tagged_count: int = 0
6412
+ self.register_search_mode()
5161
6413
  self.logger.hnotice("Starting %s monitor", self._name)
5162
-
5163
- def register_torrent_database(self):
5164
- self.torrent_db = SqliteDatabase(None)
5165
- self.torrent_db.init(
5166
- str(APPDATA_FOLDER.joinpath("Torrents.db")),
5167
- pragmas={
5168
- "journal_mode": "wal",
5169
- "cache_size": -1 * 64000, # 64MB
5170
- "foreign_keys": 1,
5171
- "ignore_check_constraints": 0,
5172
- "synchronous": 0,
5173
- },
6414
+ atexit.register(
6415
+ lambda: (
6416
+ hasattr(self, "torrent_db")
6417
+ and self.torrent_db
6418
+ and not self.torrent_db.is_closed()
6419
+ and self.torrent_db.close()
6420
+ )
5174
6421
  )
5175
6422
 
5176
- class Torrents(TorrentLibrary):
5177
- class Meta:
5178
- database = self.torrent_db
5179
-
5180
- self.torrent_db.connect()
5181
- self.torrent_db.create_tables([Torrents])
5182
- self.torrents = Torrents
6423
+ def _get_models(
6424
+ self,
6425
+ ) -> tuple[
6426
+ None,
6427
+ None,
6428
+ None,
6429
+ None,
6430
+ type[TorrentLibrary] | None,
6431
+ ]:
6432
+ return None, None, None, None, (TorrentLibrary if TAGLESS else None)
5183
6433
 
5184
6434
  def _process_single_torrent_pause_disk_space(self, torrent: qbittorrentapi.TorrentDictionary):
5185
6435
  self.logger.info(
5186
- "Pausing torrent for disk space: "
5187
- "[Progress: %s%%][Added On: %s]"
5188
- "[Availability: %s%%][Time Left: %s]"
5189
- "[Last active: %s] "
5190
- "| [%s] | %s (%s)",
6436
+ "Pausing torrent due to insufficient disk space | "
6437
+ "Name: %s | Progress: %s%% | Size remaining: %s | "
6438
+ "Availability: %s%% | ETA: %s | State: %s | Hash: %s",
6439
+ torrent.name,
5191
6440
  round(torrent.progress * 100, 2),
5192
- datetime.fromtimestamp(torrent.added_on),
6441
+ format_bytes(torrent.amount_left),
5193
6442
  round(torrent.availability * 100, 2),
5194
6443
  timedelta(seconds=torrent.eta),
5195
- datetime.fromtimestamp(torrent.last_activity),
5196
6444
  torrent.state_enum,
5197
- torrent.name,
5198
- torrent.hash,
6445
+ torrent.hash[:8], # Shortened hash for readability
5199
6446
  )
5200
6447
  self.pause.add(torrent.hash)
5201
6448
 
@@ -5204,45 +6451,48 @@ class FreeSpaceManager(Arr):
5204
6451
  free_space_test = self.current_free_space
5205
6452
  free_space_test -= torrent["amount_left"]
5206
6453
  self.logger.trace(
5207
- "Result [%s]: Free space %s -> %s",
6454
+ "Evaluating torrent: %s | Current space: %s | Space after download: %s | Remaining: %s",
5208
6455
  torrent.name,
5209
- self.current_free_space,
5210
- free_space_test,
6456
+ format_bytes(self.current_free_space + self._min_free_space_bytes),
6457
+ format_bytes(free_space_test + self._min_free_space_bytes),
6458
+ format_bytes(torrent.amount_left),
5211
6459
  )
5212
6460
  if torrent.state_enum != TorrentStates.PAUSED_DOWNLOAD and free_space_test < 0:
5213
6461
  self.logger.info(
5214
- "Pause download [%s]: Free space %s -> %s",
6462
+ "Pausing download (insufficient space) | Torrent: %s | Available: %s | Needed: %s | Deficit: %s",
5215
6463
  torrent.name,
5216
- self.current_free_space,
5217
- free_space_test,
6464
+ format_bytes(self.current_free_space + self._min_free_space_bytes),
6465
+ format_bytes(torrent.amount_left),
6466
+ format_bytes(-free_space_test),
5218
6467
  )
5219
6468
  self.add_tags(torrent, ["qBitrr-free_space_paused"])
5220
6469
  self.remove_tags(torrent, ["qBitrr-allowed_seeding"])
5221
6470
  self._process_single_torrent_pause_disk_space(torrent)
5222
6471
  elif torrent.state_enum == TorrentStates.PAUSED_DOWNLOAD and free_space_test < 0:
5223
6472
  self.logger.info(
5224
- "Leave paused [%s]: Free space %s -> %s",
6473
+ "Keeping paused (insufficient space) | Torrent: %s | Available: %s | Needed: %s | Deficit: %s",
5225
6474
  torrent.name,
5226
- self.current_free_space,
5227
- free_space_test,
6475
+ format_bytes(self.current_free_space + self._min_free_space_bytes),
6476
+ format_bytes(torrent.amount_left),
6477
+ format_bytes(-free_space_test),
5228
6478
  )
5229
6479
  self.add_tags(torrent, ["qBitrr-free_space_paused"])
5230
6480
  self.remove_tags(torrent, ["qBitrr-allowed_seeding"])
5231
6481
  elif torrent.state_enum != TorrentStates.PAUSED_DOWNLOAD and free_space_test > 0:
5232
6482
  self.logger.info(
5233
- "Continue downloading [%s]: Free space %s -> %s",
6483
+ "Continuing download (sufficient space) | Torrent: %s | Available: %s | Space after: %s",
5234
6484
  torrent.name,
5235
- self.current_free_space,
5236
- free_space_test,
6485
+ format_bytes(self.current_free_space + self._min_free_space_bytes),
6486
+ format_bytes(free_space_test + self._min_free_space_bytes),
5237
6487
  )
5238
6488
  self.current_free_space = free_space_test
5239
6489
  self.remove_tags(torrent, ["qBitrr-free_space_paused"])
5240
6490
  elif torrent.state_enum == TorrentStates.PAUSED_DOWNLOAD and free_space_test > 0:
5241
6491
  self.logger.info(
5242
- "Unpause download [%s]: Free space %s -> %s",
6492
+ "Resuming download (space available) | Torrent: %s | Available: %s | Space after: %s",
5243
6493
  torrent.name,
5244
- self.current_free_space,
5245
- free_space_test,
6494
+ format_bytes(self.current_free_space + self._min_free_space_bytes),
6495
+ format_bytes(free_space_test + self._min_free_space_bytes),
5246
6496
  )
5247
6497
  self.current_free_space = free_space_test
5248
6498
  self.remove_tags(torrent, ["qBitrr-free_space_paused"])
@@ -5250,10 +6500,9 @@ class FreeSpaceManager(Arr):
5250
6500
  torrent, "qBitrr-free_space_paused"
5251
6501
  ):
5252
6502
  self.logger.info(
5253
- "Removing tag [%s] for completed torrent[%s]: Free space %s",
5254
- "qBitrr-free_space_paused",
6503
+ "Torrent completed, removing free space tag | Torrent: %s | Available: %s",
5255
6504
  torrent.name,
5256
- self.current_free_space,
6505
+ format_bytes(self.current_free_space + self._min_free_space_bytes),
5257
6506
  )
5258
6507
  self.remove_tags(torrent, ["qBitrr-free_space_paused"])
5259
6508
 
@@ -5265,15 +6514,28 @@ class FreeSpaceManager(Arr):
5265
6514
  try:
5266
6515
  while True:
5267
6516
  try:
5268
- torrents = self.manager.qbit_manager.client.torrents.info(
5269
- status_filter="all", sort="added_on", reverse=False
5270
- )
6517
+ # Fetch per category to reduce client-side filtering
6518
+ torrents = []
6519
+ for cat in self.categories:
6520
+ with contextlib.suppress(qbittorrentapi.exceptions.APIError):
6521
+ torrents.extend(
6522
+ self.manager.qbit_manager.client.torrents.info(
6523
+ status_filter="all",
6524
+ category=cat,
6525
+ sort="added_on",
6526
+ reverse=False,
6527
+ )
6528
+ )
5271
6529
  break
5272
6530
  except qbittorrentapi.exceptions.APIError:
5273
6531
  continue
5274
6532
  torrents = [t for t in torrents if hasattr(t, "category")]
5275
6533
  torrents = [t for t in torrents if t.category in self.categories]
5276
6534
  torrents = [t for t in torrents if "qBitrr-ignored" not in t.tags]
6535
+ self.category_torrent_count = len(torrents)
6536
+ self.free_space_tagged_count = sum(
6537
+ 1 for t in torrents if self.in_tags(t, "qBitrr-free_space_paused")
6538
+ )
5277
6539
  if not len(torrents):
5278
6540
  raise DelayLoopException(length=LOOP_SLEEP_TIMER, type="no_downloads")
5279
6541
  if not has_internet(self.manager.qbit_manager):
@@ -5281,10 +6543,17 @@ class FreeSpaceManager(Arr):
5281
6543
  raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet")
5282
6544
  if self.manager.qbit_manager.should_delay_torrent_scan:
5283
6545
  raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay")
5284
- self.current_free_space = shutil.disk_usage(
5285
- self.completed_folder
5286
- ).free - parse_size(self.min_free_space)
5287
- self.logger.trace("Current free space: %s", self.current_free_space)
6546
+ self.current_free_space = (
6547
+ shutil.disk_usage(self.completed_folder).free - self._min_free_space_bytes
6548
+ )
6549
+ self.logger.trace(
6550
+ "Processing torrents | Available: %s | Threshold: %s | Usable: %s | Torrents: %d | Paused for space: %d",
6551
+ format_bytes(self.current_free_space + self._min_free_space_bytes),
6552
+ format_bytes(self._min_free_space_bytes),
6553
+ format_bytes(self.current_free_space),
6554
+ self.category_torrent_count,
6555
+ self.free_space_tagged_count,
6556
+ )
5288
6557
  sorted_torrents = sorted(torrents, key=lambda t: t["priority"])
5289
6558
  for torrent in sorted_torrents:
5290
6559
  with contextlib.suppress(qbittorrentapi.NotFound404Error):
@@ -5332,7 +6601,7 @@ class ArrManager:
5332
6601
  self.ffprobe_available: bool = self.qbit_manager.ffprobe_downloader.probe_path.exists()
5333
6602
  self.logger = logging.getLogger("qBitrr.ArrManager")
5334
6603
  run_logs(self.logger)
5335
- if not self.ffprobe_available and not any([QBIT_DISABLED, SEARCH_ONLY]):
6604
+ if not self.ffprobe_available and not (QBIT_DISABLED or SEARCH_ONLY):
5336
6605
  self.logger.error(
5337
6606
  "'%s' was not found, disabling all functionality dependant on it",
5338
6607
  self.qbit_manager.ffprobe_downloader.probe_path,
@@ -5340,7 +6609,7 @@ class ArrManager:
5340
6609
 
5341
6610
  def build_arr_instances(self):
5342
6611
  for key in CONFIG.sections():
5343
- if search := re.match("(rad|son|anim)arr.*", key, re.IGNORECASE):
6612
+ if search := re.match("(rad|son|anim|lid)arr.*", key, re.IGNORECASE):
5344
6613
  name = search.group(0)
5345
6614
  match = search.group(1)
5346
6615
  if match.lower() == "son":
@@ -5349,6 +6618,8 @@ class ArrManager:
5349
6618
  call_cls = SonarrAPI
5350
6619
  elif match.lower() == "rad":
5351
6620
  call_cls = RadarrAPI
6621
+ elif match.lower() == "lid":
6622
+ call_cls = LidarrAPI
5352
6623
  else:
5353
6624
  call_cls = None
5354
6625
  try:
@@ -5363,7 +6634,12 @@ class ArrManager:
5363
6634
  continue
5364
6635
  except (OSError, TypeError) as e:
5365
6636
  self.logger.exception(e)
5366
- if FREE_SPACE != "-1" and AUTO_PAUSE_RESUME:
6637
+ if (
6638
+ FREE_SPACE != "-1"
6639
+ and AUTO_PAUSE_RESUME
6640
+ and not QBIT_DISABLED
6641
+ and len(self.arr_categories) > 0
6642
+ ):
5367
6643
  managed_object = FreeSpaceManager(self.arr_categories, self)
5368
6644
  self.managed_objects["FreeSpaceManager"] = managed_object
5369
6645
  for cat in self.special_categories: