qBitrr2 3.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qBitrr/arss.py ADDED
@@ -0,0 +1,3918 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import itertools
5
+ import logging
6
+ import pathlib
7
+ import re
8
+ import shutil
9
+ import sys
10
+ import time
11
+ from collections import defaultdict
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from copy import copy
14
+ from datetime import datetime, timedelta, timezone
15
+ from typing import TYPE_CHECKING, Callable, Iterable, Iterator, NoReturn
16
+
17
+ import ffmpeg
18
+ import pathos
19
+ import qbittorrentapi
20
+ import qbittorrentapi.exceptions
21
+ import requests
22
+ from peewee import *
23
+ from peewee import JOIN, SqliteDatabase
24
+ from pyarr import RadarrAPI, SonarrAPI
25
+ from qbittorrentapi import TorrentDictionary, TorrentStates
26
+
27
+ from qBitrr.arr_tables import (
28
+ CommandsModel,
29
+ EpisodesModel,
30
+ MoviesMetadataModel,
31
+ MoviesModel,
32
+ MoviesModelv5,
33
+ SeriesModel,
34
+ )
35
+ from qBitrr.config import (
36
+ APPDATA_FOLDER,
37
+ COMPLETED_DOWNLOAD_FOLDER,
38
+ CONFIG,
39
+ FAILED_CATEGORY,
40
+ LOOP_SLEEP_TIMER,
41
+ NO_INTERNET_SLEEP_TIMER,
42
+ PROCESS_ONLY,
43
+ QBIT_DISABLED,
44
+ RECHECK_CATEGORY,
45
+ SEARCH_ONLY,
46
+ )
47
+ from qBitrr.errors import (
48
+ DelayLoopException,
49
+ NoConnectionrException,
50
+ RestartLoopException,
51
+ SkipException,
52
+ UnhandledError,
53
+ )
54
+ from qBitrr.logger import run_logs
55
+ from qBitrr.tables import (
56
+ EpisodeFilesModel,
57
+ EpisodeQueueModel,
58
+ FilesQueued,
59
+ MovieQueueModel,
60
+ MoviesFilesModel,
61
+ SeriesFilesModel,
62
+ )
63
+ from qBitrr.utils import (
64
+ ExpiringSet,
65
+ absolute_file_paths,
66
+ has_internet,
67
+ validate_and_return_torrent_file,
68
+ )
69
+
70
+ if TYPE_CHECKING:
71
+ from qBitrr.main import qBitManager
72
+
73
+
74
+ class Arr:
75
+ def __init__(
76
+ self,
77
+ name: str,
78
+ manager: ArrManager,
79
+ client_cls: type[Callable | RadarrAPI | SonarrAPI],
80
+ ):
81
+ if name in manager.groups:
82
+ raise OSError("Group '{name}' has already been registered.")
83
+ self._name = name
84
+ self.managed = CONFIG.get(f"{name}.Managed", fallback=False)
85
+ if not self.managed:
86
+ raise SkipException
87
+ self.uri = CONFIG.get_or_raise(f"{name}.URI")
88
+ if self.uri in manager.uris:
89
+ raise OSError(
90
+ f"Group '{self._name}' is trying to manage Arr instance: "
91
+ f"'{self.uri}' which has already been registered."
92
+ )
93
+ self.category = CONFIG.get(f"{name}.Category", fallback=self._name)
94
+ self.manager = manager
95
+ self._LOG_LEVEL = self.manager.qbit_manager.logger.level
96
+ self.logger = logging.getLogger(f"qBitrr.{self._name}")
97
+ run_logs(self.logger)
98
+ self.completed_folder = pathlib.Path(COMPLETED_DOWNLOAD_FOLDER).joinpath(self.category)
99
+ if not self.completed_folder.exists() and not SEARCH_ONLY:
100
+ try:
101
+ self.completed_folder.mkdir(parents=True, exist_ok=True)
102
+ except BaseException:
103
+ self.logger.warning(
104
+ "%s completed folder is a soft requirement. The specified folder does not exist %s and cannot be created. This will disable all file monitoring.",
105
+ self._name,
106
+ self.completed_folder,
107
+ )
108
+ self.apikey = CONFIG.get_or_raise(f"{name}.APIKey")
109
+ self.re_search = CONFIG.get(f"{name}.ReSearch", fallback=False)
110
+ self.import_mode = CONFIG.get(f"{name}.importMode", fallback="Move")
111
+ self.refresh_downloads_timer = CONFIG.get(f"{name}.RefreshDownloadsTimer", fallback=1)
112
+ self.arr_error_codes_to_blocklist = CONFIG.get(
113
+ f"{name}.ArrErrorCodesToBlocklist", fallback=[]
114
+ )
115
+ self.rss_sync_timer = CONFIG.get(f"{name}.RssSyncTimer", fallback=15)
116
+
117
+ self.case_sensitive_matches = CONFIG.get(
118
+ f"{name}.Torrent.CaseSensitiveMatches", fallback=[]
119
+ )
120
+ self.folder_exclusion_regex = CONFIG.get(
121
+ f"{name}.Torrent.FolderExclusionRegex", fallback=[]
122
+ )
123
+ self.file_name_exclusion_regex = CONFIG.get(
124
+ f"{name}.Torrent.FileNameExclusionRegex", fallback=[]
125
+ )
126
+ self.file_extension_allowlist = CONFIG.get(
127
+ f"{name}.Torrent.FileExtensionAllowlist", fallback=[]
128
+ )
129
+ self.auto_delete = CONFIG.get(f"{name}.Torrent.AutoDelete", fallback=False)
130
+
131
+ self.remove_dead_trackers = CONFIG.get(
132
+ f"{name}.Torrent.SeedingMode.RemoveDeadTrackers", fallback=False
133
+ )
134
+ self.seeding_mode_global_download_limit = CONFIG.get(
135
+ f"{name}.Torrent.SeedingMode.DownloadRateLimitPerTorrent", fallback=-1
136
+ )
137
+ self.seeding_mode_global_upload_limit = CONFIG.get(
138
+ f"{name}.Torrent.SeedingMode.UploadRateLimitPerTorrent", fallback=-1
139
+ )
140
+ self.seeding_mode_global_max_upload_ratio = CONFIG.get(
141
+ f"{name}.Torrent.SeedingMode.MaxUploadRatio", fallback=-1
142
+ )
143
+ self.seeding_mode_global_max_seeding_time = CONFIG.get(
144
+ f"{name}.Torrent.SeedingMode.MaxSeedingTime", fallback=-1
145
+ )
146
+ self.seeding_mode_global_remove_torrent = CONFIG.get(
147
+ f"{name}.Torrent.SeedingMode.RemoveTorrent", fallback=-1
148
+ )
149
+ self.seeding_mode_global_bad_tracker_msg = CONFIG.get(
150
+ f"{name}.Torrent.SeedingMode.RemoveTrackerWithMessage", fallback=[]
151
+ )
152
+
153
+ self.monitored_trackers = CONFIG.get(f"{name}.Torrent.Trackers", fallback=[])
154
+ self._remove_trackers_if_exists: set[str] = {
155
+ i.get("URI") for i in self.monitored_trackers if i.get("RemoveIfExists") is True
156
+ }
157
+ self._monitored_tracker_urls: set[str] = {
158
+ r
159
+ for i in self.monitored_trackers
160
+ if not (r := i.get("URI")) not in self._remove_trackers_if_exists
161
+ }
162
+ self._add_trackers_if_missing: set[str] = {
163
+ i.get("URI") for i in self.monitored_trackers if i.get("AddTrackerIfMissing") is True
164
+ }
165
+ if (
166
+ self.auto_delete is True
167
+ and not self.completed_folder.parent.exists()
168
+ and not SEARCH_ONLY
169
+ ):
170
+ self.auto_delete = False
171
+ self.logger.critical(
172
+ "AutoDelete disabled due to missing folder: '%s'",
173
+ self.completed_folder.parent,
174
+ )
175
+
176
+ self.reset_on_completion = CONFIG.get(
177
+ f"{name}.EntrySearch.SearchAgainOnSearchCompletion", fallback=False
178
+ )
179
+ self.do_upgrade_search = CONFIG.get(f"{name}.EntrySearch.DoUpgradeSearch", fallback=False)
180
+ self.quality_unmet_search = CONFIG.get(
181
+ f"{name}.EntrySearch.QualityUnmetSearch", fallback=False
182
+ )
183
+
184
+ self.ignore_torrents_younger_than = CONFIG.get(
185
+ f"{name}.Torrent.IgnoreTorrentsYoungerThan", fallback=600
186
+ )
187
+ self.maximum_eta = CONFIG.get(f"{name}.Torrent.MaximumETA", fallback=86400)
188
+ self.maximum_deletable_percentage = CONFIG.get(
189
+ f"{name}.Torrent.MaximumDeletablePercentage", fallback=0.95
190
+ )
191
+ self.search_missing = CONFIG.get(f"{name}.EntrySearch.SearchMissing", fallback=False)
192
+ if PROCESS_ONLY:
193
+ self.search_missing = False
194
+ self.search_specials = CONFIG.get(f"{name}.EntrySearch.AlsoSearchSpecials", fallback=False)
195
+ self.search_by_year = CONFIG.get(f"{name}.EntrySearch.SearchByYear", fallback=True)
196
+ self.search_in_reverse = CONFIG.get(f"{name}.EntrySearch.SearchInReverse", fallback=False)
197
+
198
+ self.search_command_limit = CONFIG.get(f"{name}.EntrySearch.SearchLimit", fallback=5)
199
+ self.prioritize_todays_release = CONFIG.get(
200
+ f"{name}.EntrySearch.PrioritizeTodaysReleases", fallback=True
201
+ )
202
+
203
+ self.do_not_remove_slow = CONFIG.get(f"{name}.Torrent.DoNotRemoveSlow", fallback=False)
204
+ self.search_current_year = None
205
+ if self.search_in_reverse:
206
+ self._delta = 1
207
+ else:
208
+ self._delta = -1
209
+ arr_db_file = CONFIG.get(f"{name}.EntrySearch.DatabaseFile", fallback=None)
210
+ self.arr_db_file = pathlib.Path("/.Invalid Place Holder")
211
+ if self.search_missing and arr_db_file is None:
212
+ self.logger.critical("Arr DB file not specified setting SearchMissing to False")
213
+ self.search_missing = False
214
+ if arr_db_file is not None:
215
+ self.arr_db_file = pathlib.Path(arr_db_file)
216
+ self._app_data_folder = APPDATA_FOLDER
217
+ self.search_db_file = self._app_data_folder.joinpath(f"{self._name}.db")
218
+ if self.search_missing and not self.arr_db_file.exists():
219
+ self.logger.critical(
220
+ "Arr DB file cannot be located setting SearchMissing to False: %s",
221
+ self.arr_db_file,
222
+ )
223
+ self.search_missing = False
224
+
225
+ self.ombi_search_requests = CONFIG.get(
226
+ f"{name}.EntrySearch.Ombi.SearchOmbiRequests", fallback=False
227
+ )
228
+ self.overseerr_requests = CONFIG.get(
229
+ f"{name}.EntrySearch.Overseerr.SearchOverseerrRequests", fallback=False
230
+ )
231
+ self.series_search = CONFIG.get(f"{name}.EntrySearch.SearchBySeries", fallback=False)
232
+ if self.ombi_search_requests:
233
+ self.ombi_uri = CONFIG.get_or_raise(f"{name}.EntrySearch.Ombi.OmbiURI")
234
+ self.ombi_api_key = CONFIG.get_or_raise(f"{name}.EntrySearch.Ombi.OmbiAPIKey")
235
+ else:
236
+ self.ombi_uri = CONFIG.get(f"{name}.EntrySearch.Ombi.OmbiURI", fallback=None)
237
+ self.ombi_api_key = CONFIG.get(f"{name}.EntrySearch.Ombi.OmbiAPIKey", fallback=None)
238
+ if self.overseerr_requests:
239
+ self.overseerr_uri = CONFIG.get_or_raise(f"{name}.EntrySearch.Overseerr.OverseerrURI")
240
+ self.overseerr_api_key = CONFIG.get_or_raise(
241
+ f"{name}.EntrySearch.Overseerr.OverseerrAPIKey"
242
+ )
243
+ else:
244
+ self.overseerr_uri = CONFIG.get(
245
+ f"{name}.EntrySearch.Overseerr.OverseerrURI", fallback=None
246
+ )
247
+ self.overseerr_api_key = CONFIG.get(
248
+ f"{name}.EntrySearch.Overseerr.OverseerrAPIKey", fallback=None
249
+ )
250
+ self.overseerr_is_4k = CONFIG.get(f"{name}.EntrySearch.Overseerr.Is4K", fallback=False)
251
+ self.ombi_approved_only = CONFIG.get(
252
+ f"{name}.EntrySearch.Ombi.ApprovedOnly", fallback=True
253
+ )
254
+ self.overseerr_approved_only = CONFIG.get(
255
+ f"{name}.EntrySearch.Overseerr.ApprovedOnly", fallback=True
256
+ )
257
+ self.search_requests_every_x_seconds = CONFIG.get(
258
+ f"{name}.EntrySearch.SearchRequestsEvery", fallback=1800
259
+ )
260
+ self._temp_overseer_request_cache: dict[str, set[int | str]] = defaultdict(set)
261
+ if self.ombi_search_requests or self.overseerr_requests:
262
+ self.request_search_timer = 0
263
+ else:
264
+ self.request_search_timer = None
265
+
266
+ if self.case_sensitive_matches:
267
+ self.folder_exclusion_regex_re = re.compile(
268
+ "|".join(self.folder_exclusion_regex), re.DOTALL
269
+ )
270
+ self.file_name_exclusion_regex_re = re.compile(
271
+ "|".join(self.file_name_exclusion_regex), re.DOTALL
272
+ )
273
+ else:
274
+ self.folder_exclusion_regex_re = re.compile(
275
+ "|".join(self.folder_exclusion_regex), re.IGNORECASE | re.DOTALL
276
+ )
277
+ self.file_name_exclusion_regex_re = re.compile(
278
+ "|".join(self.file_name_exclusion_regex), re.IGNORECASE | re.DOTALL
279
+ )
280
+ self.client = client_cls(host_url=self.uri, api_key=self.apikey)
281
+ if isinstance(self.client, SonarrAPI):
282
+ self.type = "sonarr"
283
+ version_info = self.client.get_update()
284
+ self.version = version_info[0].get("version")[:1]
285
+ self.logger.debug("%s version: %s", self._name, self.version)
286
+ elif isinstance(self.client, RadarrAPI):
287
+ self.type = "radarr"
288
+ version_info = self.client.get_update()
289
+ self.version = version_info[0].get("version")[:1]
290
+ self.logger.debug("%s version: %s", self._name, self.version)
291
+
292
+ if self.rss_sync_timer > 0:
293
+ self.rss_sync_timer_last_checked = datetime(1970, 1, 1)
294
+ else:
295
+ self.rss_sync_timer_last_checked = None
296
+ if self.refresh_downloads_timer > 0:
297
+ self.refresh_downloads_timer_last_checked = datetime(1970, 1, 1)
298
+ else:
299
+ self.refresh_downloads_timer_last_checked = None
300
+
301
+ self.loop_completed = False
302
+ self.queue = []
303
+ self.cache = {}
304
+ self.requeue_cache = {}
305
+ self.queue_file_ids = set()
306
+ self.sent_to_scan = set()
307
+ self.sent_to_scan_hashes = set()
308
+ self.files_probed = set()
309
+ self.import_torrents = []
310
+ self.change_priority = dict()
311
+ self.recheck = set()
312
+ self.pause = set()
313
+ self.skip_blacklist = set()
314
+ self.delete = set()
315
+ self.resume = set()
316
+ self.remove_from_qbit = set()
317
+ self.overseerr_requests_release_cache = dict()
318
+ self.files_to_explicitly_delete: Iterator = iter([])
319
+ self.files_to_cleanup = set()
320
+ self.missing_files_post_delete = set()
321
+ self.downloads_with_bad_error_message_blocklist = set()
322
+ self.needs_cleanup = False
323
+ self.recently_queue = dict()
324
+
325
+ self.timed_ignore_cache = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than)
326
+ self.timed_skip = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than)
327
+ self.tracker_delay = ExpiringSet(max_age_seconds=600)
328
+ self.special_casing_file_check = ExpiringSet(max_age_seconds=10)
329
+ self.expiring_bool = ExpiringSet(max_age_seconds=10)
330
+ self.session = requests.Session()
331
+ self.cleaned_torrents = set()
332
+ self.search_api_command = None
333
+
334
+ self.manager.completed_folders.add(self.completed_folder)
335
+ self.manager.category_allowlist.add(self.category)
336
+
337
+ self.logger.debug(
338
+ "%s Config: "
339
+ "Managed: %s, "
340
+ "Re-search: %s, "
341
+ "ImportMode: %s, "
342
+ "Category: %s, "
343
+ "URI: %s, "
344
+ "API Key: %s, "
345
+ "RefreshDownloadsTimer=%s, "
346
+ "RssSyncTimer=%s",
347
+ self._name,
348
+ self.import_mode,
349
+ self.managed,
350
+ self.re_search,
351
+ self.category,
352
+ self.uri,
353
+ self.apikey,
354
+ self.refresh_downloads_timer,
355
+ self.rss_sync_timer,
356
+ )
357
+ self.logger.debug(
358
+ "Script Config: CaseSensitiveMatches=%s",
359
+ self.case_sensitive_matches,
360
+ )
361
+ self.logger.debug(
362
+ "Script Config: FolderExclusionRegex=%s",
363
+ self.folder_exclusion_regex,
364
+ )
365
+ self.logger.debug(
366
+ "Script Config: FileNameExclusionRegex=%s",
367
+ self.file_name_exclusion_regex,
368
+ )
369
+ self.logger.debug(
370
+ "Script Config: FileExtensionAllowlist=%s",
371
+ self.file_extension_allowlist,
372
+ )
373
+ self.logger.debug("Script Config: AutoDelete=%s", self.auto_delete)
374
+
375
+ self.logger.debug(
376
+ "Script Config: IgnoreTorrentsYoungerThan=%s",
377
+ self.ignore_torrents_younger_than,
378
+ )
379
+ self.logger.debug("Script Config: MaximumETA=%s", self.maximum_eta)
380
+
381
+ if self.search_missing:
382
+ self.logger.debug(
383
+ "Script Config: SearchMissing=%s",
384
+ self.search_missing,
385
+ )
386
+ self.logger.debug(
387
+ "Script Config: AlsoSearchSpecials=%s",
388
+ self.search_specials,
389
+ )
390
+ self.logger.debug(
391
+ "Script Config: SearchByYear=%s",
392
+ self.search_by_year,
393
+ )
394
+ self.logger.debug(
395
+ "Script Config: SearchInReverse=%s",
396
+ self.search_in_reverse,
397
+ )
398
+ self.logger.debug(
399
+ "Script Config: CommandLimit=%s",
400
+ self.search_command_limit,
401
+ )
402
+ self.logger.debug(
403
+ "Script Config: DatabaseFile=%s",
404
+ self.arr_db_file,
405
+ )
406
+ self.logger.debug(
407
+ "Script Config: MaximumDeletablePercentage=%s",
408
+ self.maximum_deletable_percentage,
409
+ )
410
+ self.logger.debug(
411
+ "Script Config: DoUpgradeSearch=%s",
412
+ self.do_upgrade_search,
413
+ )
414
+ self.logger.debug(
415
+ "Script Config: PrioritizeTodaysReleases=%s",
416
+ self.prioritize_todays_release,
417
+ )
418
+ self.logger.debug(
419
+ "Script Config: SearchBySeries=%s",
420
+ self.series_search,
421
+ )
422
+ self.logger.debug(
423
+ "Script Config: SearchOmbiRequests=%s",
424
+ self.ombi_search_requests,
425
+ )
426
+ if self.ombi_search_requests:
427
+ self.logger.debug(
428
+ "Script Config: OmbiURI=%s",
429
+ self.ombi_uri,
430
+ )
431
+ self.logger.debug(
432
+ "Script Config: OmbiAPIKey=%s",
433
+ self.ombi_api_key,
434
+ )
435
+ self.logger.debug(
436
+ "Script Config: ApprovedOnly=%s",
437
+ self.ombi_approved_only,
438
+ )
439
+ self.logger.debug(
440
+ "Script Config: SearchOverseerrRequests=%s",
441
+ self.overseerr_requests,
442
+ )
443
+ if self.overseerr_requests:
444
+ self.logger.debug(
445
+ "Script Config: OverseerrURI=%s",
446
+ self.overseerr_uri,
447
+ )
448
+ self.logger.debug(
449
+ "Script Config: OverseerrAPIKey=%s",
450
+ self.overseerr_api_key,
451
+ )
452
+ if self.ombi_search_requests or self.overseerr_requests:
453
+ self.logger.debug(
454
+ "Script Config: SearchRequestsEvery=%s",
455
+ self.search_requests_every_x_seconds,
456
+ )
457
+
458
+ if self.type == "sonarr":
459
+ if self.quality_unmet_search:
460
+ self.search_api_command = "SeriesSearch"
461
+ else:
462
+ self.search_api_command = "MissingEpisodeSearch"
463
+
464
+ self.search_setup_completed = False
465
+ self.model_arr_file: EpisodesModel | MoviesModel | MoviesModelv5 = None
466
+ self.model_arr_series_file: SeriesModel = None
467
+ self.model_arr_movies_file: MoviesMetadataModel = None
468
+
469
+ self.model_arr_command: CommandsModel = None
470
+ self.model_file: EpisodeFilesModel | MoviesFilesModel = None
471
+ self.series_file_model: SeriesFilesModel = None
472
+ self.model_queue: EpisodeQueueModel | MovieQueueModel = None
473
+ self.persistent_queue: FilesQueued = None
474
+ self.logger.hnotice("Starting %s monitor", self._name)
475
+
476
+ @property
477
+ def is_alive(self) -> bool:
478
+ try:
479
+ if 1 in self.expiring_bool:
480
+ return True
481
+ if self.session is None:
482
+ self.expiring_bool.add(1)
483
+ return True
484
+ req = self.session.get(
485
+ f"{self.uri}/api/v3/system/status", timeout=10, params={"apikey": self.apikey}
486
+ )
487
+ req.raise_for_status()
488
+ self.logger.trace("Successfully connected to %s", self.uri)
489
+ self.expiring_bool.add(1)
490
+ return True
491
+ except requests.HTTPError:
492
+ self.expiring_bool.add(1)
493
+ return True
494
+ except requests.RequestException:
495
+ self.logger.warning("Could not connect to %s", self.uri)
496
+ return False
497
+
498
+ @staticmethod
499
+ def is_ignored_state(torrent: TorrentDictionary) -> bool:
500
+ return torrent.state_enum in (
501
+ TorrentStates.FORCED_DOWNLOAD,
502
+ TorrentStates.FORCED_UPLOAD,
503
+ TorrentStates.CHECKING_UPLOAD,
504
+ TorrentStates.CHECKING_DOWNLOAD,
505
+ TorrentStates.CHECKING_RESUME_DATA,
506
+ TorrentStates.ALLOCATING,
507
+ TorrentStates.MOVING,
508
+ TorrentStates.QUEUED_DOWNLOAD,
509
+ )
510
+
511
+ @staticmethod
512
+ def is_uploading_state(torrent: TorrentDictionary) -> bool:
513
+ return torrent.state_enum in (
514
+ TorrentStates.UPLOADING,
515
+ TorrentStates.STALLED_UPLOAD,
516
+ TorrentStates.QUEUED_UPLOAD,
517
+ )
518
+
519
+ @staticmethod
520
+ def is_complete_state(torrent: TorrentDictionary) -> bool:
521
+ """Returns True if the State is categorized as Complete."""
522
+ return torrent.state_enum in (
523
+ TorrentStates.UPLOADING,
524
+ TorrentStates.STALLED_UPLOAD,
525
+ TorrentStates.PAUSED_UPLOAD,
526
+ TorrentStates.QUEUED_UPLOAD,
527
+ )
528
+
529
+ @staticmethod
530
+ def is_downloading_state(torrent: TorrentDictionary) -> bool:
531
+ """Returns True if the State is categorized as Downloading."""
532
+ return torrent.state_enum in (
533
+ TorrentStates.DOWNLOADING,
534
+ TorrentStates.PAUSED_DOWNLOAD,
535
+ )
536
+
537
+ def _get_arr_modes(
538
+ self,
539
+ ) -> tuple[
540
+ type[EpisodesModel] | type[MoviesModel] | type[MoviesModelv5],
541
+ type[CommandsModel],
542
+ type[SeriesModel] | type[MoviesMetadataModel],
543
+ ]:
544
+ if self.type == "sonarr":
545
+ return EpisodesModel, CommandsModel, SeriesModel
546
+ elif self.type == "radarr":
547
+ if self.version == "4":
548
+ return MoviesModel, CommandsModel, MoviesMetadataModel
549
+ elif self.version == "5":
550
+ return MoviesModelv5, CommandsModel, MoviesMetadataModel
551
+ else:
552
+ raise UnhandledError("Well you shouldn't have reached here, Arr.type=%s" % self.type)
553
+
554
+ def _get_models(
555
+ self,
556
+ ) -> tuple[
557
+ type[EpisodeFilesModel] | type[MoviesFilesModel],
558
+ type[EpisodeQueueModel] | type[MovieQueueModel],
559
+ type[SeriesFilesModel] | None,
560
+ ]:
561
+ if self.type == "sonarr":
562
+ if self.series_search:
563
+ return EpisodeFilesModel, EpisodeQueueModel, SeriesFilesModel
564
+ return EpisodeFilesModel, EpisodeQueueModel, None
565
+ elif self.type == "radarr":
566
+ return MoviesFilesModel, MovieQueueModel, None
567
+ else:
568
+ raise UnhandledError("Well you shouldn't have reached here, Arr.type=%s" % self.type)
569
+
570
+ def _get_oversee_requests_all(self) -> dict[str, set]:
571
+ try:
572
+ if self.overseerr_approved_only:
573
+ key = "approved"
574
+ else:
575
+ key = "unavailable"
576
+ data = defaultdict(set)
577
+ response = self.session.get(
578
+ url=f"{self.overseerr_uri}/api/v1/request",
579
+ headers={"X-Api-Key": self.overseerr_api_key},
580
+ params={"take": 100, "skip": 0, "sort": "added", "filter": key},
581
+ timeout=2,
582
+ )
583
+ response = response.json().get("results", [])
584
+ type_ = None
585
+ if self.type == "sonarr":
586
+ type_ = "tv"
587
+ elif self.type == "radarr":
588
+ type_ = "movie"
589
+ _now = datetime.now()
590
+ for entry in response:
591
+ type__ = entry.get("type")
592
+ if type__ == "movie":
593
+ id__ = entry.get("media", {}).get("tmdbId")
594
+ elif type__ == "tv":
595
+ id__ = entry.get("media", {}).get("tvdbId")
596
+ if type_ != type__:
597
+ continue
598
+ if self.overseerr_is_4k and entry.get("is4k"):
599
+ if self.overseerr_approved_only:
600
+ if entry.get("media", {}).get("status4k") != 3:
601
+ continue
602
+ else:
603
+ if entry.get("media", {}).get("status4k") == 5:
604
+ continue
605
+ elif not self.overseerr_is_4k and not entry.get("is4k"):
606
+ if self.overseerr_approved_only:
607
+ if entry.get("media", {}).get("status") != 3:
608
+ continue
609
+ else:
610
+ if entry.get("media", {}).get("status") == 5:
611
+ continue
612
+ else:
613
+ continue
614
+ if id__ in self.overseerr_requests_release_cache:
615
+ date = self.overseerr_requests_release_cache[id__]
616
+ else:
617
+ date = datetime(day=1, month=1, year=1970)
618
+ date_string_backup = f"{_now.year}-{_now.month:02}-{_now.day:02}"
619
+ date_string = None
620
+ try:
621
+ if type_ == "movie":
622
+ _entry_data = self.session.get(
623
+ url=f"{self.overseerr_uri}/api/v1/movies/{id__}",
624
+ headers={"X-Api-Key": self.overseerr_api_key},
625
+ timeout=2,
626
+ )
627
+ date_string = _entry_data.json().get("releaseDate")
628
+ elif type__ == "tv":
629
+ _entry_data = self.session.get(
630
+ url=f"{self.overseerr_uri}/api/v1/tv/{id__}",
631
+ headers={"X-Api-Key": self.overseerr_api_key},
632
+ timeout=2,
633
+ )
634
+ # We don't do granular (episode/season) searched here so no need to
635
+ # suppose them
636
+ date_string = _entry_data.json().get("firstAirDate")
637
+ if not date_string:
638
+ date_string = date_string_backup
639
+ date = datetime.strptime(date_string, "%Y-%m-%d")
640
+ if date > _now:
641
+ continue
642
+ self.overseerr_requests_release_cache[id__] = date
643
+ except Exception as e:
644
+ self.logger.warning("Failed to query release date from Overseerr: %s", e)
645
+ if media := entry.get("media"):
646
+ if imdbId := media.get("imdbId"):
647
+ data["ImdbId"].add(imdbId)
648
+ if self.type == "sonarr" and (tvdbId := media.get("tvdbId")):
649
+ data["TvdbId"].add(tvdbId)
650
+ elif self.type == "radarr" and (tmdbId := media.get("tmdbId")):
651
+ data["TmdbId"].add(tmdbId)
652
+ self._temp_overseer_request_cache = data
653
+ except requests.exceptions.ConnectionError:
654
+ self.logger.warning("Couldn't connect to Overseerr")
655
+ self._temp_overseer_request_cache = defaultdict(set)
656
+ return self._temp_overseer_request_cache
657
+ except requests.exceptions.ReadTimeout:
658
+ self.logger.warning("Connection to Overseerr timed out")
659
+ self._temp_overseer_request_cache = defaultdict(set)
660
+ return self._temp_overseer_request_cache
661
+ except Exception as e:
662
+ self.logger.exception(e, exc_info=sys.exc_info())
663
+ self._temp_overseer_request_cache = defaultdict(set)
664
+ return self._temp_overseer_request_cache
665
+ else:
666
+ return self._temp_overseer_request_cache
667
+
668
+ def _get_overseerr_requests_count(self) -> int:
669
+ self._get_oversee_requests_all()
670
+ if self.type == "sonarr":
671
+ return len(
672
+ self._temp_overseer_request_cache.get("TvdbId", [])
673
+ or self._temp_overseer_request_cache.get("ImdbId", [])
674
+ )
675
+ elif self.type == "radarr":
676
+ return len(
677
+ self._temp_overseer_request_cache.get("ImdbId", [])
678
+ or self._temp_overseer_request_cache.get("TmdbId", [])
679
+ )
680
+ return 0
681
+
682
+ def _get_ombi_request_count(self) -> int:
683
+ if self.type == "sonarr":
684
+ extras = "/api/v1/Request/tv/total"
685
+ elif self.type == "radarr":
686
+ extras = "/api/v1/Request/movie/total"
687
+ else:
688
+ raise UnhandledError("Well you shouldn't have reached here, Arr.type=%s" % self.type)
689
+ try:
690
+ response = self.session.get(
691
+ url=f"{self.ombi_uri}{extras}", headers={"ApiKey": self.ombi_api_key}
692
+ )
693
+ except Exception as e:
694
+ self.logger.exception(e, exc_info=sys.exc_info())
695
+ return 0
696
+ else:
697
+ return response.json()
698
+
699
+ def _get_ombi_requests(self) -> list[dict]:
700
+ if self.type == "sonarr":
701
+ extras = "/api/v1/Request/tvlite"
702
+ elif self.type == "radarr":
703
+ extras = "/api/v1/Request/movie"
704
+ else:
705
+ raise UnhandledError("Well you shouldn't have reached here, Arr.type=%s" % self.type)
706
+ try:
707
+ response = self.session.get(
708
+ url=f"{self.ombi_uri}{extras}", headers={"ApiKey": self.ombi_api_key}
709
+ )
710
+ return response.json()
711
+ except Exception as e:
712
+ self.logger.exception(e, exc_info=sys.exc_info())
713
+ return []
714
+
715
+ def _process_ombi_requests(self) -> dict[str, set[str, int]]:
716
+ requests = self._get_ombi_requests()
717
+ data = defaultdict(set)
718
+ for request in requests:
719
+ if self.type == "radarr" and self.ombi_approved_only and request.get("denied") is True:
720
+ continue
721
+ elif self.type == "sonarr" and self.ombi_approved_only:
722
+ # This is me being lazy and not wanting to deal with partially approved requests.
723
+ if any(child.get("denied") is True for child in request.get("childRequests", [])):
724
+ continue
725
+ if imdbId := request.get("imdbId"):
726
+ data["ImdbId"].add(imdbId)
727
+ if self.type == "radarr" and (theMovieDbId := request.get("theMovieDbId")):
728
+ data["TmdbId"].add(theMovieDbId)
729
+ if self.type == "sonarr" and (tvDbId := request.get("tvDbId")):
730
+ data["TvdbId"].add(tvDbId)
731
+ return data
732
+
733
+ def _process_paused(self) -> None:
734
+ # Bulks pause all torrents flagged for pausing.
735
+ if self.pause:
736
+ self.needs_cleanup = True
737
+ self.logger.debug("Pausing %s completed torrents", len(self.pause))
738
+ for i in self.pause:
739
+ self.logger.debug(
740
+ "Pausing %s (%s)",
741
+ i,
742
+ self.manager.qbit_manager.name_cache.get(i),
743
+ )
744
+ self.manager.qbit.torrents_pause(torrent_hashes=self.pause)
745
+ self.pause.clear()
746
+
747
+ def _process_imports(self) -> None:
748
+ if self.import_torrents:
749
+ self.needs_cleanup = True
750
+ for torrent in self.import_torrents:
751
+ if torrent.hash in self.sent_to_scan:
752
+ continue
753
+ path = validate_and_return_torrent_file(torrent.content_path)
754
+ if not path.exists():
755
+ self.timed_ignore_cache.add(torrent.hash)
756
+ self.logger.warning(
757
+ "Missing Torrent: [%s] %s (%s) - File does not seem to exist: %s",
758
+ torrent.state_enum,
759
+ torrent.name,
760
+ torrent.hash,
761
+ path,
762
+ )
763
+ continue
764
+ if path in self.sent_to_scan:
765
+ continue
766
+ self.sent_to_scan_hashes.add(torrent.hash)
767
+ if self.type == "sonarr":
768
+ self.logger.success(
769
+ "DownloadedEpisodesScan: %s",
770
+ path,
771
+ )
772
+ self.post_command(
773
+ "DownloadedEpisodesScan",
774
+ path=str(path),
775
+ downloadClientId=torrent.hash.upper(),
776
+ importMode=self.import_mode,
777
+ )
778
+ elif self.type == "radarr":
779
+ self.logger.success("DownloadedMoviesScan: %s", path)
780
+ self.post_command(
781
+ "DownloadedMoviesScan",
782
+ path=str(path),
783
+ downloadClientId=torrent.hash.upper(),
784
+ importMode=self.import_mode,
785
+ )
786
+ self.sent_to_scan.add(path)
787
+ self.import_torrents.clear()
788
+
789
+ def _process_failed_individual(self, hash_: str, entry: int, skip_blacklist: set[str]) -> None:
790
+ with contextlib.suppress(Exception):
791
+ if hash_ not in skip_blacklist:
792
+ self.logger.debug(
793
+ "Blocklisting: %s (%s)",
794
+ hash_,
795
+ self.manager.qbit_manager.name_cache.get(hash_, "Deleted"),
796
+ )
797
+ self.delete_from_queue(id_=entry, blacklist=True)
798
+ else:
799
+ self.delete_from_queue(id_=entry, blacklist=False)
800
+ if hash_ in self.recently_queue:
801
+ del self.recently_queue[hash_]
802
+ object_id = self.requeue_cache.get(entry)
803
+ if self.re_search and object_id:
804
+ if self.type == "sonarr":
805
+ object_ids = object_id
806
+ for object_id in object_ids:
807
+ data = self.client.get_episode_by_episode_id(object_id)
808
+ name = data.get("title")
809
+ series_id = data.get("series", {}).get("id")
810
+ if name:
811
+ episodeNumber = data.get("episodeNumber", 0)
812
+ absoluteEpisodeNumber = data.get("absoluteEpisodeNumber", 0)
813
+ seasonNumber = data.get("seasonNumber", 0)
814
+ seriesTitle = data.get("series", {}).get("title")
815
+ year = data.get("series", {}).get("year", 0)
816
+ tvdbId = data.get("series", {}).get("tvdbId", 0)
817
+ self.logger.notice(
818
+ "Re-Searching episode: %s (%s) | "
819
+ "S%02dE%03d "
820
+ "(E%04d) | "
821
+ "%s | "
822
+ "[tvdbId=%s|id=%s]",
823
+ seriesTitle,
824
+ year,
825
+ seasonNumber,
826
+ episodeNumber,
827
+ absoluteEpisodeNumber,
828
+ name,
829
+ tvdbId,
830
+ object_id,
831
+ )
832
+ else:
833
+ self.logger.notice(
834
+ "Re-Searching episode: %s",
835
+ object_id,
836
+ )
837
+ if object_id not in self.queue_file_ids:
838
+ self.post_command("EpisodeSearch", episodeIds=[object_id])
839
+ if self.persistent_queue and series_id:
840
+ self.persistent_queue.insert(EntryId=series_id).on_conflict_ignore()
841
+ elif self.type == "radarr":
842
+ data = self.client.get_movie_by_movie_id(object_id)
843
+ name = data.get("title")
844
+ if name:
845
+ year = data.get("year", 0)
846
+ tmdbId = data.get("tmdbId", 0)
847
+ self.logger.notice(
848
+ "Re-Searching movie: %s (%s) | [tmdbId=%s|id=%s]",
849
+ name,
850
+ year,
851
+ tmdbId,
852
+ object_id,
853
+ )
854
+ else:
855
+ self.logger.notice(
856
+ "Re-Searching movie: %s",
857
+ object_id,
858
+ )
859
+ if object_id not in self.queue_file_ids:
860
+ self.post_command("MoviesSearch", movieIds=[object_id])
861
+ if self.persistent_queue:
862
+ self.persistent_queue.insert(EntryId=object_id).on_conflict_ignore()
863
+
864
+ def _process_errored(self) -> None:
865
+ # Recheck all torrents marked for rechecking.
866
+ if self.recheck:
867
+ self.needs_cleanup = True
868
+ updated_recheck = [r for r in self.recheck]
869
+ self.manager.qbit.torrents_recheck(torrent_hashes=updated_recheck)
870
+ for k in updated_recheck:
871
+ self.timed_ignore_cache.add(k)
872
+ self.recheck.clear()
873
+
874
+ def _process_failed(self) -> None:
875
+ to_delete_all = self.delete.union(
876
+ self.missing_files_post_delete, self.downloads_with_bad_error_message_blocklist
877
+ )
878
+ if self.missing_files_post_delete or self.downloads_with_bad_error_message_blocklist:
879
+ delete_ = True
880
+ else:
881
+ delete_ = False
882
+ skip_blacklist = {
883
+ i.upper() for i in self.skip_blacklist.union(self.missing_files_post_delete)
884
+ }
885
+ if to_delete_all:
886
+ self.needs_cleanup = True
887
+ payload, hashes = self.process_entries(to_delete_all)
888
+ if payload:
889
+ for entry, hash_ in payload:
890
+ self._process_failed_individual(
891
+ hash_=hash_, entry=entry, skip_blacklist=skip_blacklist
892
+ )
893
+ if self.remove_from_qbit or self.skip_blacklist or to_delete_all:
894
+ # Remove all bad torrents from the Client.
895
+ temp_to_delete = set()
896
+ if to_delete_all:
897
+ self.manager.qbit.torrents_delete(hashes=to_delete_all, delete_files=True)
898
+ if self.remove_from_qbit or self.skip_blacklist:
899
+ temp_to_delete = self.remove_from_qbit.union(self.skip_blacklist)
900
+ self.manager.qbit.torrents_delete(hashes=temp_to_delete, delete_files=True)
901
+
902
+ to_delete_all = to_delete_all.union(temp_to_delete)
903
+ for h in to_delete_all:
904
+ self.cleaned_torrents.discard(h)
905
+ self.sent_to_scan_hashes.discard(h)
906
+ if h in self.manager.qbit_manager.name_cache:
907
+ del self.manager.qbit_manager.name_cache[h]
908
+ if h in self.manager.qbit_manager.cache:
909
+ del self.manager.qbit_manager.cache[h]
910
+ if delete_:
911
+ self.missing_files_post_delete.clear()
912
+ self.downloads_with_bad_error_message_blocklist.clear()
913
+ self.skip_blacklist.clear()
914
+ self.remove_from_qbit.clear()
915
+ self.delete.clear()
916
+
917
+ def _process_file_priority(self) -> None:
918
+ # Set all files marked as "Do not download" to not download.
919
+ for hash_, files in self.change_priority.copy().items():
920
+ self.needs_cleanup = True
921
+ name = self.manager.qbit_manager.name_cache.get(hash_)
922
+ if name:
923
+ self.logger.debug(
924
+ "Updating file priority on torrent: %s (%s)",
925
+ name,
926
+ hash_,
927
+ )
928
+ self.manager.qbit.torrents_file_priority(
929
+ torrent_hash=hash_, file_ids=files, priority=0
930
+ )
931
+ else:
932
+ self.logger.error("Torrent does not exist? %s", hash_)
933
+ del self.change_priority[hash_]
934
+
935
+ def _process_resume(self) -> None:
936
+ if self.resume:
937
+ self.needs_cleanup = True
938
+ self.manager.qbit.torrents_resume(torrent_hashes=self.resume)
939
+ for k in self.resume:
940
+ self.timed_ignore_cache.add(k)
941
+ self.resume.clear()
942
+
943
+ def _remove_empty_folders(self) -> None:
944
+ new_sent_to_scan = set()
945
+ if not self.completed_folder.exists():
946
+ return
947
+ for path in absolute_file_paths(self.completed_folder):
948
+ if path.is_dir() and not len(list(absolute_file_paths(path))):
949
+ path.rmdir()
950
+ self.logger.trace("Removing empty folder: %s", path)
951
+ if path in self.sent_to_scan:
952
+ self.sent_to_scan.discard(path)
953
+ else:
954
+ new_sent_to_scan.add(path)
955
+ self.sent_to_scan = new_sent_to_scan
956
+ if not len(list(absolute_file_paths(self.completed_folder))):
957
+ self.sent_to_scan = set()
958
+ self.sent_to_scan_hashes = set()
959
+
960
+ def api_calls(self) -> None:
961
+ if not self.is_alive:
962
+ raise NoConnectionrException(
963
+ f"Service: {self._name} did not respond on {self.uri}", type="arr"
964
+ )
965
+ now = datetime.now()
966
+ if (
967
+ self.rss_sync_timer_last_checked is not None
968
+ and self.rss_sync_timer_last_checked < now - timedelta(minutes=self.rss_sync_timer)
969
+ ):
970
+ self.post_command("RssSync")
971
+ self.rss_sync_timer_last_checked = now
972
+
973
+ if (
974
+ self.refresh_downloads_timer_last_checked is not None
975
+ and self.refresh_downloads_timer_last_checked
976
+ < now - timedelta(minutes=self.refresh_downloads_timer)
977
+ ):
978
+ self.post_command("RefreshMonitoredDownloads")
979
+ self.refresh_downloads_timer_last_checked = now
980
+
981
+ def arr_db_query_commands_count(self) -> int:
982
+ if not self.search_missing:
983
+ return 0
984
+
985
+ try:
986
+ search_commands = ( # ilovemywife
987
+ self.model_arr_command.select()
988
+ .where(
989
+ (self.model_arr_command.EndedAt.is_null(True))
990
+ & (self.model_arr_command.Name.endswith("Search"))
991
+ )
992
+ .count()
993
+ .execute()
994
+ )
995
+ except BaseException:
996
+ self.logger.trace("No unended commands found")
997
+ search_commands = 0
998
+
999
+ return search_commands
1000
+
1001
+ def _search_todays(self, condition):
1002
+ if self.prioritize_todays_release:
1003
+ condition_today = copy(condition)
1004
+ condition_today &= self.model_file.AirDateUtc >= datetime.now(timezone.utc).date()
1005
+ for entry in (
1006
+ self.model_file.select()
1007
+ .where(condition_today)
1008
+ .order_by(
1009
+ self.model_file.SeriesTitle,
1010
+ self.model_file.SeasonNumber.desc(),
1011
+ self.model_file.AirDateUtc.desc(),
1012
+ )
1013
+ .execute()
1014
+ ):
1015
+ yield entry, True, True
1016
+ else:
1017
+ yield None, None, None
1018
+
1019
+ def db_get_files(
1020
+ self,
1021
+ ) -> Iterable[
1022
+ tuple[MoviesFilesModel | EpisodeFilesModel | SeriesFilesModel, bool, bool, bool]
1023
+ ]:
1024
+ if self.type == "sonarr" and self.series_search:
1025
+ for i1, i2, i3 in self.db_get_files_series():
1026
+ yield i1, i2, i3, i3 is not True
1027
+ elif self.type == "sonarr" and not self.series_search:
1028
+ for i1, i2, i3 in self.db_get_files_episodes():
1029
+ yield i1, i2, i3, False
1030
+ elif self.type == "radarr":
1031
+ for i1, i2, i3 in self.db_get_files_movies():
1032
+ yield i1, i2, i3, False
1033
+
1034
+ def db_maybe_reset_entry_searched_state(self):
1035
+ if self.type == "sonarr":
1036
+ self.db_reset__series_searched_state()
1037
+ self.db_reset__episode_searched_state()
1038
+ elif self.type == "radarr":
1039
+ self.db_reset__movie_searched_state()
1040
+
1041
+ def db_reset__series_searched_state(self):
1042
+ self.series_file_model: SeriesFilesModel
1043
+ self.model_file: EpisodeFilesModel
1044
+ if (
1045
+ self.loop_completed is True and self.reset_on_completion
1046
+ ): # Only wipe if a loop completed was tagged
1047
+ series_query = (
1048
+ self.series_file_model.select(self.series_file_model.EntryId)
1049
+ .join(
1050
+ self.model_file,
1051
+ on=(self.series_file_model.EntryId == self.model_file.SeriesId),
1052
+ )
1053
+ .where(
1054
+ self.series_file_model.Searched == True & self.model_file.EpisodeFileId == 0
1055
+ )
1056
+ .execute()
1057
+ )
1058
+ series_ids = [s.EntryId for s in series_query]
1059
+ self.series_file_model.update(Searched=False).where(
1060
+ self.series_file_model.EntryId << series_ids
1061
+ ).execute()
1062
+
1063
+ def db_reset__episode_searched_state(self):
1064
+ self.model_file: EpisodeFilesModel
1065
+ if (
1066
+ self.loop_completed is True and self.reset_on_completion
1067
+ ): # Only wipe if a loop completed was tagged
1068
+ self.model_file.update(Searched=False).where(
1069
+ self.model_file.Searched == True & self.model_file.EpisodeFileId == 0
1070
+ ).execute()
1071
+
1072
+ def db_reset__movie_searched_state(self):
1073
+ self.model_file: MoviesFilesModel
1074
+ if (
1075
+ self.loop_completed is True and self.reset_on_completion
1076
+ ): # Only wipe if a loop completed was tagged
1077
+ self.model_file.update(Searched=False).where(
1078
+ self.model_file.Searched == True & self.model_file.MovieFileId == 0
1079
+ ).execute()
1080
+
1081
+ def db_get_files_series(
1082
+ self,
1083
+ ) -> Iterable[tuple[SeriesFilesModel, bool, bool]]:
1084
+ if not self.search_missing:
1085
+ yield None, False, False
1086
+ elif not self.series_search:
1087
+ yield None, False, False
1088
+ elif self.type == "sonarr":
1089
+ condition = self.model_file.AirDateUtc.is_null(False)
1090
+ if not self.search_specials:
1091
+ condition &= self.model_file.SeasonNumber != 0
1092
+ if not self.do_upgrade_search:
1093
+ condition &= self.model_file.Searched == False
1094
+ condition &= self.model_file.EpisodeFileId == 0
1095
+ condition &= self.model_file.AirDateUtc < (
1096
+ datetime.now(timezone.utc) - timedelta(hours=2)
1097
+ )
1098
+ condition &= self.model_file.AbsoluteEpisodeNumber.is_null(
1099
+ False
1100
+ ) | self.model_file.SceneAbsoluteEpisodeNumber.is_null(False)
1101
+ for i1, i2, i3 in self._search_todays(condition):
1102
+ if i1 is not None:
1103
+ yield i1, i2, i3
1104
+ if not self.do_upgrade_search:
1105
+ condition = self.series_file_model.Searched == False
1106
+ else:
1107
+ condition = self.series_file_model.Searched.is_null(False)
1108
+ for entry_ in (
1109
+ self.series_file_model.select()
1110
+ .where(condition)
1111
+ .order_by(self.series_file_model.EntryId.asc())
1112
+ .execute()
1113
+ ):
1114
+ yield entry_, False, False
1115
+
1116
+ def db_get_files_episodes(
1117
+ self,
1118
+ ) -> Iterable[tuple[EpisodeFilesModel, bool, bool]]:
1119
+ if not self.search_missing:
1120
+ yield None, False, False
1121
+ elif self.type == "sonarr":
1122
+ condition = self.model_file.AirDateUtc.is_null(False)
1123
+
1124
+ if not self.search_specials:
1125
+ condition &= self.model_file.SeasonNumber != 0
1126
+ condition &= self.model_file.AirDateUtc.is_null(False)
1127
+ if not self.do_upgrade_search:
1128
+ if self.quality_unmet_search:
1129
+ condition &= self.model_file.QualityMet == False
1130
+ else:
1131
+ condition &= self.model_file.Searched == False
1132
+ condition &= self.model_file.EpisodeFileId == 0
1133
+ condition &= self.model_file.AirDateUtc < (
1134
+ datetime.now(timezone.utc) - timedelta(hours=2)
1135
+ )
1136
+ condition &= self.model_file.AbsoluteEpisodeNumber.is_null(
1137
+ False
1138
+ ) | self.model_file.SceneAbsoluteEpisodeNumber.is_null(False)
1139
+ today_condition = copy(condition)
1140
+ for entry_ in (
1141
+ self.model_file.select()
1142
+ .where(condition)
1143
+ .order_by(
1144
+ self.model_file.SeriesTitle,
1145
+ self.model_file.SeasonNumber.desc(),
1146
+ self.model_file.AirDateUtc.desc(),
1147
+ )
1148
+ .group_by(self.model_file.SeriesId)
1149
+ .execute()
1150
+ ):
1151
+ condition_series = copy(condition)
1152
+ condition_series &= self.model_file.SeriesId == entry_.SeriesId
1153
+ has_been_queried = (
1154
+ self.persistent_queue.get_or_none(
1155
+ self.persistent_queue.EntryId == entry_.SeriesId
1156
+ )
1157
+ is not None
1158
+ )
1159
+ for entry in (
1160
+ self.model_file.select()
1161
+ .where(condition_series)
1162
+ .order_by(
1163
+ self.model_file.SeasonNumber.desc(),
1164
+ self.model_file.AirDateUtc.desc(),
1165
+ )
1166
+ .execute()
1167
+ ):
1168
+ yield entry, False, has_been_queried
1169
+ has_been_queried = True
1170
+ for i1, i2, i3 in self._search_todays(today_condition):
1171
+ if i1 is not None:
1172
+ yield i1, i2, i3
1173
+
1174
+ def db_get_files_movies(
1175
+ self,
1176
+ ) -> Iterable[tuple[MoviesFilesModel, bool, bool]]:
1177
+ if not self.search_missing:
1178
+ yield None, False, False
1179
+ if self.type == "radarr":
1180
+ if self.search_by_year:
1181
+ condition = self.model_file.Year == self.search_current_year
1182
+ if not self.do_upgrade_search:
1183
+ if self.quality_unmet_search:
1184
+ condition &= self.model_file.QualityMet == False
1185
+ else:
1186
+ condition &= self.model_file.Searched == False
1187
+ condition &= self.model_file.MovieFileId == 0
1188
+ else:
1189
+ if not self.do_upgrade_search:
1190
+ if self.quality_unmet_search:
1191
+ condition = self.model_file.QualityMet == False
1192
+ else:
1193
+ condition = self.model_file.Searched == False
1194
+ condition &= self.model_file.MovieFileId == 0
1195
+ for entry in (
1196
+ self.model_file.select()
1197
+ .where(condition)
1198
+ .order_by(self.model_file.Title.asc())
1199
+ .execute()
1200
+ ):
1201
+ yield entry, False, False
1202
+
1203
+ def db_get_request_files(self) -> Iterable[MoviesFilesModel | EpisodeFilesModel]:
1204
+ if (not self.ombi_search_requests) or (not self.overseerr_requests):
1205
+ yield None
1206
+ if not self.search_missing:
1207
+ yield None
1208
+ elif self.type == "sonarr":
1209
+ condition = self.model_file.IsRequest == True
1210
+ if not self.do_upgrade_search:
1211
+ if self.quality_unmet_search:
1212
+ condition &= self.model_file.QualityMet == False
1213
+ else:
1214
+ condition &= self.model_file.EpisodeFileId == 0
1215
+ if not self.search_specials:
1216
+ condition &= self.model_file.SeasonNumber != 0
1217
+ condition &= self.model_file.AbsoluteEpisodeNumber.is_null(
1218
+ False
1219
+ ) | self.model_file.SceneAbsoluteEpisodeNumber.is_null(False)
1220
+ condition &= self.model_file.AirDateUtc.is_null(False)
1221
+ condition &= self.model_file.AirDateUtc < (
1222
+ datetime.now(timezone.utc) - timedelta(hours=2)
1223
+ )
1224
+ yield from (
1225
+ self.model_file.select()
1226
+ .where(condition)
1227
+ .order_by(
1228
+ self.model_file.SeriesTitle,
1229
+ self.model_file.SeasonNumber.desc(),
1230
+ self.model_file.AirDateUtc.desc(),
1231
+ )
1232
+ .execute()
1233
+ )
1234
+ elif self.type == "radarr":
1235
+ condition = self.model_file.Year <= datetime.now().year
1236
+ condition &= self.model_file.Year > 0
1237
+ if not self.do_upgrade_search:
1238
+ if self.quality_unmet_search:
1239
+ condition &= self.model_file.QualityMet == False
1240
+ else:
1241
+ condition &= self.model_file.MovieFileId == 0
1242
+ condition &= self.model_file.IsRequest == True
1243
+ yield from (
1244
+ self.model_file.select()
1245
+ .where(condition)
1246
+ .order_by(self.model_file.Title.asc())
1247
+ .execute()
1248
+ )
1249
+
1250
+ def db_request_update(self):
1251
+ if self.overseerr_requests:
1252
+ self.db_overseerr_update()
1253
+ else:
1254
+ self.db_ombi_update()
1255
+
1256
+ def _db_request_update(self, request_ids: dict[str, set[int | str]]):
1257
+ with self.db.atomic():
1258
+ try:
1259
+ if self.type == "sonarr" and any(i in request_ids for i in ["ImdbId", "TvdbId"]):
1260
+ self.model_arr_file: EpisodesModel
1261
+ self.model_arr_series_file: SeriesModel
1262
+ condition = self.model_arr_file.AirDateUtc.is_null(False)
1263
+ if not self.search_specials:
1264
+ condition &= self.model_arr_file.SeasonNumber != 0
1265
+ condition &= self.model_arr_file.AbsoluteEpisodeNumber.is_null(
1266
+ False
1267
+ ) | self.model_arr_file.SceneAbsoluteEpisodeNumber.is_null(False)
1268
+ condition &= self.model_arr_file.AirDateUtc < datetime.now(timezone.utc)
1269
+ imdb_con = None
1270
+ tvdb_con = None
1271
+ if ImdbIds := request_ids.get("ImdbId"):
1272
+ imdb_con = self.model_arr_series_file.ImdbId.in_(ImdbIds)
1273
+ if tvDbIds := request_ids.get("TvdbId"):
1274
+ tvdb_con = self.model_arr_series_file.TvdbId.in_(tvDbIds)
1275
+ if imdb_con and tvdb_con:
1276
+ condition &= imdb_con | tvdb_con
1277
+ elif imdb_con:
1278
+ condition &= imdb_con
1279
+ elif tvdb_con:
1280
+ condition &= tvdb_con
1281
+ for db_entry in (
1282
+ self.model_arr_file.select()
1283
+ .join(
1284
+ self.model_arr_series_file,
1285
+ on=(self.model_arr_file.SeriesId == self.model_arr_series_file.Id),
1286
+ join_type=JOIN.LEFT_OUTER,
1287
+ )
1288
+ .switch(self.model_arr_file)
1289
+ .where(condition)
1290
+ .execute()
1291
+ ):
1292
+ self.db_update_single_series(db_entry=db_entry, request=True)
1293
+ elif self.type == "radarr" and any(i in request_ids for i in ["ImdbId", "TmdbId"]):
1294
+ if self.version == "4":
1295
+ self.model_arr_file: MoviesModel
1296
+ elif self.version == "5":
1297
+ self.model_arr_file: MoviesModelv5
1298
+ self.model_arr_movies_file: MoviesMetadataModel
1299
+ condition = self.model_arr_movies_file.Year <= datetime.now().year
1300
+
1301
+ tmdb_con = None
1302
+ imdb_con = None
1303
+ if ImdbIds := request_ids.get("ImdbId"):
1304
+ imdb_con = self.model_arr_movies_file.ImdbId.in_(ImdbIds)
1305
+ if TmdbIds := request_ids.get("TmdbId"):
1306
+ tmdb_con = self.model_arr_movies_file.TmdbId.in_(TmdbIds)
1307
+ if tmdb_con and imdb_con:
1308
+ condition &= tmdb_con | imdb_con
1309
+ elif tmdb_con:
1310
+ condition &= tmdb_con
1311
+ elif imdb_con:
1312
+ condition &= imdb_con
1313
+ for db_entry in (
1314
+ self.model_arr_file.select()
1315
+ .join(
1316
+ self.model_arr_movies_file,
1317
+ on=(
1318
+ self.model_arr_file.MovieMetadataId
1319
+ == self.model_arr_movies_file.Id
1320
+ ),
1321
+ join_type=JOIN.LEFT_OUTER,
1322
+ )
1323
+ .switch(self.model_arr_file)
1324
+ .where(condition)
1325
+ .order_by(self.model_arr_file.Added.desc())
1326
+ .execute()
1327
+ ):
1328
+ self.db_update_single_series(db_entry=db_entry, request=True)
1329
+ except requests.exceptions.ConnectionError:
1330
+ self.logger.error("Connection Error")
1331
+ raise DelayLoopException(length=300, type=self._name)
1332
+
1333
+ def db_overseerr_update(self):
1334
+ if (not self.search_missing) or (not self.overseerr_requests):
1335
+ return
1336
+ if self._get_overseerr_requests_count() == 0:
1337
+ return
1338
+ request_ids = self._temp_overseer_request_cache
1339
+ if not any(i in request_ids for i in ["ImdbId", "TmdbId", "TvdbId"]):
1340
+ return
1341
+ self.logger.notice(f"Started updating database with Overseerr request entries.")
1342
+ self._db_request_update(request_ids)
1343
+ self.logger.notice(f"Finished updating database with Overseerr request entries")
1344
+
1345
+ def db_ombi_update(self):
1346
+ if (not self.search_missing) or (not self.ombi_search_requests):
1347
+ return
1348
+ if self._get_ombi_request_count() == 0:
1349
+ return
1350
+ request_ids = self._process_ombi_requests()
1351
+ if not any(i in request_ids for i in ["ImdbId", "TmdbId", "TvdbId"]):
1352
+ return
1353
+ self.logger.notice(f"Started updating database with Ombi request entries.")
1354
+ self._db_request_update(request_ids)
1355
+ self.logger.notice(f"Finished updating database with Ombi request entries")
1356
+
1357
+ def db_update_todays_releases(self):
1358
+ if not self.prioritize_todays_release:
1359
+ return
1360
+ with self.db.atomic():
1361
+ if self.type == "sonarr":
1362
+ try:
1363
+ for series in self.model_arr_file.select().where(
1364
+ (self.model_arr_file.AirDateUtc.is_null(False))
1365
+ & (self.model_arr_file.AirDateUtc < datetime.now(timezone.utc))
1366
+ & (self.model_arr_file.AirDateUtc >= datetime.now(timezone.utc).date())
1367
+ & (
1368
+ self.model_arr_file.AbsoluteEpisodeNumber.is_null(False)
1369
+ | self.model_arr_file.SceneAbsoluteEpisodeNumber.is_null(False)
1370
+ ).execute()
1371
+ ):
1372
+ self.db_update_single_series(db_entry=series)
1373
+ except BaseException:
1374
+ self.logger.debug("No episode releases found for today")
1375
+
1376
+ def db_update(self):
1377
+ if not self.search_missing:
1378
+ return
1379
+ self.logger.trace(f"Started updating database")
1380
+ self.db_update_todays_releases()
1381
+ with self.db.atomic():
1382
+ if self.type == "sonarr":
1383
+ if not self.series_search:
1384
+ _series = set()
1385
+ if self.search_by_year:
1386
+ for series in self.model_arr_file.select().where(
1387
+ (self.model_arr_file.AirDateUtc.is_null(False))
1388
+ & (self.model_arr_file.AirDateUtc < datetime.now(timezone.utc))
1389
+ & (
1390
+ self.model_arr_file.AbsoluteEpisodeNumber.is_null(False)
1391
+ | self.model_arr_file.SceneAbsoluteEpisodeNumber.is_null(False)
1392
+ )
1393
+ & (
1394
+ self.model_arr_file.AirDateUtc
1395
+ >= datetime(month=1, day=1, year=self.search_current_year)
1396
+ )
1397
+ & (
1398
+ self.model_arr_file.AirDateUtc
1399
+ <= datetime(month=12, day=31, year=self.search_current_year)
1400
+ ).execute()
1401
+ ):
1402
+ series: EpisodesModel
1403
+ _series.add(series.SeriesId)
1404
+ self.db_update_single_series(db_entry=series)
1405
+ for series in (
1406
+ self.model_arr_file.select()
1407
+ .where(self.model_arr_file.SeriesId.in_(_series))
1408
+ .execute()
1409
+ ):
1410
+ self.db_update_single_series(db_entry=series)
1411
+ else:
1412
+ for series in self.model_arr_file.select().where(
1413
+ (self.model_arr_file.AirDateUtc.is_null(False))
1414
+ & (self.model_arr_file.AirDateUtc < datetime.now(timezone.utc))
1415
+ & (
1416
+ self.model_arr_file.AbsoluteEpisodeNumber.is_null(False)
1417
+ | self.model_arr_file.SceneAbsoluteEpisodeNumber.is_null(False)
1418
+ ).execute()
1419
+ ):
1420
+ series: EpisodesModel
1421
+ _series.add(series.SeriesId)
1422
+ self.db_update_single_series(db_entry=series)
1423
+ for series in (
1424
+ self.model_arr_file.select()
1425
+ .where(self.model_arr_file.SeriesId.in_(_series))
1426
+ .execute()
1427
+ ):
1428
+ self.db_update_single_series(db_entry=series)
1429
+ else:
1430
+ for series in (
1431
+ self.model_arr_series_file.select()
1432
+ .order_by(self.model_arr_series_file.Added.desc())
1433
+ .execute()
1434
+ ):
1435
+ self.db_update_single_series(db_entry=series, series=True)
1436
+ elif self.type == "radarr":
1437
+ if self.search_by_year:
1438
+ for movies in (
1439
+ self.model_arr_file.select(self.model_arr_file)
1440
+ .join(
1441
+ self.model_arr_movies_file,
1442
+ on=(
1443
+ self.model_arr_file.MovieMetadataId
1444
+ == self.model_arr_movies_file.Id
1445
+ ),
1446
+ )
1447
+ .switch(self.model_arr_file)
1448
+ .where(self.model_arr_movies_file.Year == self.search_current_year)
1449
+ .order_by(self.model_arr_file.Added.desc())
1450
+ .execute()
1451
+ ):
1452
+ self.db_update_single_series(db_entry=movies)
1453
+
1454
+ else:
1455
+ for movies in (
1456
+ self.model_arr_file.select(self.model_arr_file)
1457
+ .join(
1458
+ self.model_arr_movies_file,
1459
+ on=(
1460
+ self.model_arr_file.MovieMetadataId
1461
+ == self.model_arr_movies_file.Id
1462
+ ),
1463
+ )
1464
+ .switch(self.model_arr_file)
1465
+ .order_by(self.model_arr_file.Added.desc())
1466
+ .execute()
1467
+ ):
1468
+ self.db_update_single_series(db_entry=movies)
1469
+ self.logger.trace(f"Finished updating database")
1470
+
1471
+ def minimum_availability_check(
1472
+ self,
1473
+ db_entry: MoviesModel | MoviesModelv5 = None,
1474
+ metadata: MoviesMetadataModel = None,
1475
+ ) -> bool:
1476
+ if metadata.Year < datetime.now().year and metadata.Year != 0:
1477
+ self.logger.trace(
1478
+ "Grabbing %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1479
+ metadata.Title,
1480
+ db_entry.MinimumAvailability,
1481
+ metadata.InCinemas,
1482
+ metadata.DigitalRelease,
1483
+ metadata.PhysicalRelease,
1484
+ )
1485
+ return True
1486
+ elif (
1487
+ metadata.InCinemas is None
1488
+ and metadata.DigitalRelease is None
1489
+ and metadata.PhysicalRelease is None
1490
+ and db_entry.MinimumAvailability == 3
1491
+ ):
1492
+ self.logger.trace(
1493
+ "Grabbing %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1494
+ metadata.Title,
1495
+ db_entry.MinimumAvailability,
1496
+ metadata.InCinemas,
1497
+ metadata.DigitalRelease,
1498
+ metadata.PhysicalRelease,
1499
+ )
1500
+ return True
1501
+ elif (
1502
+ metadata.DigitalRelease is not None
1503
+ and metadata.PhysicalRelease is not None
1504
+ and db_entry.MinimumAvailability == 3
1505
+ ):
1506
+ if (
1507
+ datetime.strptime(metadata.DigitalRelease[:19], "%Y-%m-%d %H:%M:%S")
1508
+ <= datetime.now()
1509
+ or datetime.strptime(metadata.PhysicalRelease[:19], "%Y-%m-%d %H:%M:%S")
1510
+ <= datetime.now()
1511
+ ):
1512
+ self.logger.trace(
1513
+ "Grabbing %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1514
+ metadata.Title,
1515
+ db_entry.MinimumAvailability,
1516
+ metadata.InCinemas,
1517
+ metadata.DigitalRelease,
1518
+ metadata.PhysicalRelease,
1519
+ )
1520
+ return True
1521
+ else:
1522
+ self.logger.trace(
1523
+ "Skipping %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1524
+ metadata.Title,
1525
+ db_entry.MinimumAvailability,
1526
+ metadata.InCinemas,
1527
+ metadata.DigitalRelease,
1528
+ metadata.PhysicalRelease,
1529
+ )
1530
+ return False
1531
+ elif (
1532
+ metadata.DigitalRelease is not None or metadata.PhysicalRelease is not None
1533
+ ) and db_entry.MinimumAvailability == 3:
1534
+ if metadata.DigitalRelease is not None:
1535
+ if (
1536
+ datetime.strptime(metadata.DigitalRelease[:19], "%Y-%m-%d %H:%M:%S")
1537
+ <= datetime.now()
1538
+ ):
1539
+ self.logger.trace(
1540
+ "Grabbing %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1541
+ metadata.Title,
1542
+ db_entry.MinimumAvailability,
1543
+ metadata.InCinemas,
1544
+ metadata.DigitalRelease,
1545
+ metadata.PhysicalRelease,
1546
+ )
1547
+ return True
1548
+ else:
1549
+ self.logger.trace(
1550
+ "Skipping %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1551
+ metadata.Title,
1552
+ db_entry.MinimumAvailability,
1553
+ metadata.InCinemas,
1554
+ metadata.DigitalRelease,
1555
+ metadata.PhysicalRelease,
1556
+ )
1557
+ return False
1558
+ else:
1559
+ if (
1560
+ datetime.strptime(metadata.PhysicalRelease[:19], "%Y-%m-%d %H:%M:%S")
1561
+ <= datetime.now()
1562
+ ):
1563
+ self.logger.trace(
1564
+ "Grabbing %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1565
+ metadata.Title,
1566
+ db_entry.MinimumAvailability,
1567
+ metadata.InCinemas,
1568
+ metadata.DigitalRelease,
1569
+ metadata.PhysicalRelease,
1570
+ )
1571
+ return True
1572
+ else:
1573
+ self.logger.trace(
1574
+ "Skipping %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1575
+ metadata.Title,
1576
+ db_entry.MinimumAvailability,
1577
+ metadata.InCinemas,
1578
+ metadata.DigitalRelease,
1579
+ metadata.PhysicalRelease,
1580
+ )
1581
+ return False
1582
+ elif (
1583
+ metadata.InCinemas is None
1584
+ and metadata.DigitalRelease is None
1585
+ and metadata.PhysicalRelease is None
1586
+ and db_entry.MinimumAvailability == 2
1587
+ ):
1588
+ self.logger.trace(
1589
+ "Grabbing %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1590
+ metadata.Title,
1591
+ db_entry.MinimumAvailability,
1592
+ metadata.InCinemas,
1593
+ metadata.DigitalRelease,
1594
+ metadata.PhysicalRelease,
1595
+ )
1596
+ return True
1597
+ elif metadata.InCinemas is not None and db_entry.MinimumAvailability == 2:
1598
+ if datetime.strptime(metadata.InCinemas[:19], "%Y-%m-%d %H:%M:%S") <= datetime.now():
1599
+ self.logger.trace(
1600
+ "Grabbing %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1601
+ metadata.Title,
1602
+ db_entry.MinimumAvailability,
1603
+ metadata.InCinemas,
1604
+ metadata.DigitalRelease,
1605
+ metadata.PhysicalRelease,
1606
+ )
1607
+ return True
1608
+ else:
1609
+ self.logger.trace(
1610
+ "Skipping %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1611
+ metadata.Title,
1612
+ db_entry.MinimumAvailability,
1613
+ metadata.InCinemas,
1614
+ metadata.DigitalRelease,
1615
+ metadata.PhysicalRelease,
1616
+ )
1617
+ return False
1618
+ elif metadata.InCinemas is None and db_entry.MinimumAvailability == 2:
1619
+ if metadata.DigitalRelease is not None:
1620
+ if (
1621
+ datetime.strptime(metadata.DigitalRelease[:19], "%Y-%m-%d %H:%M:%S")
1622
+ <= datetime.now()
1623
+ ):
1624
+ self.logger.trace(
1625
+ "Grabbing %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1626
+ metadata.Title,
1627
+ db_entry.MinimumAvailability,
1628
+ metadata.InCinemas,
1629
+ metadata.DigitalRelease,
1630
+ metadata.PhysicalRelease,
1631
+ )
1632
+ return True
1633
+ else:
1634
+ self.logger.trace(
1635
+ "Skipping %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1636
+ metadata.Title,
1637
+ db_entry.MinimumAvailability,
1638
+ metadata.InCinemas,
1639
+ metadata.DigitalRelease,
1640
+ metadata.PhysicalRelease,
1641
+ )
1642
+ return False
1643
+ elif metadata.PhysicalRelease is not None:
1644
+ if (
1645
+ datetime.strptime(metadata.DigitalRelease[:19], "%Y-%m-%d %H:%M:%S")
1646
+ <= datetime.now()
1647
+ ):
1648
+ self.logger.trace(
1649
+ "Grabbing %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1650
+ metadata.Title,
1651
+ db_entry.MinimumAvailability,
1652
+ metadata.InCinemas,
1653
+ metadata.DigitalRelease,
1654
+ metadata.PhysicalRelease,
1655
+ )
1656
+ return True
1657
+ else:
1658
+ self.logger.trace(
1659
+ "Skipping %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1660
+ metadata.Title,
1661
+ db_entry.MinimumAvailability,
1662
+ metadata.InCinemas,
1663
+ metadata.DigitalRelease,
1664
+ metadata.PhysicalRelease,
1665
+ )
1666
+ return False
1667
+ else:
1668
+ self.logger.trace(
1669
+ "Skipping %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1670
+ metadata.Title,
1671
+ db_entry.MinimumAvailability,
1672
+ metadata.InCinemas,
1673
+ metadata.DigitalRelease,
1674
+ metadata.PhysicalRelease,
1675
+ )
1676
+ return False
1677
+ elif db_entry.MinimumAvailability == 1:
1678
+ self.logger.trace(
1679
+ "Grabbing %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1680
+ metadata.Title,
1681
+ db_entry.MinimumAvailability,
1682
+ metadata.InCinemas,
1683
+ metadata.DigitalRelease,
1684
+ metadata.PhysicalRelease,
1685
+ )
1686
+ return True
1687
+ else:
1688
+ self.logger.trace(
1689
+ "Skipping %s - Minimum Availability: %s, Dates Cinema:%s, Digital:%s, Physical:%s",
1690
+ metadata.Title,
1691
+ db_entry.MinimumAvailability,
1692
+ metadata.InCinemas,
1693
+ metadata.DigitalRelease,
1694
+ metadata.PhysicalRelease,
1695
+ )
1696
+ return False
1697
+
1698
+ def db_update_single_series(
1699
+ self,
1700
+ db_entry: EpisodesModel | SeriesModel | MoviesModel | MoviesModelv5 = None,
1701
+ request: bool = False,
1702
+ series: bool = False,
1703
+ ):
1704
+ if self.search_missing is False:
1705
+ return
1706
+ try:
1707
+ searched = False
1708
+ if self.type == "sonarr":
1709
+ if not series:
1710
+ db_entry: EpisodesModel
1711
+ QualityUnmet = self.quality_unmet_search
1712
+ if db_entry.EpisodeFileId != 0 and not QualityUnmet:
1713
+ searched = True
1714
+ self.model_queue.update(Completed=True).where(
1715
+ self.model_queue.EntryId == db_entry.Id
1716
+ ).execute()
1717
+ EntryId = db_entry.Id
1718
+ metadata = self.client.get_episode_by_episode_id(EntryId)
1719
+ SeriesTitle = metadata.get("series", {}).get("title")
1720
+ SeasonNumber = db_entry.SeasonNumber
1721
+ Title = db_entry.Title
1722
+ SeriesId = db_entry.SeriesId
1723
+ EpisodeFileId = db_entry.EpisodeFileId
1724
+ EpisodeNumber = db_entry.EpisodeNumber
1725
+ AbsoluteEpisodeNumber = db_entry.AbsoluteEpisodeNumber
1726
+ SceneAbsoluteEpisodeNumber = db_entry.SceneAbsoluteEpisodeNumber
1727
+ LastSearchTime = db_entry.LastSearchTime
1728
+ AirDateUtc = db_entry.AirDateUtc
1729
+ Monitored = db_entry.Monitored
1730
+ searched = searched
1731
+ QualityMet = db_entry.EpisodeFileId != 0 and not QualityUnmet
1732
+
1733
+ if self.quality_unmet_search and QualityMet:
1734
+ self.logger.trace(
1735
+ "Quality Met | %s | S%02dE%03d",
1736
+ SeriesTitle,
1737
+ SeasonNumber,
1738
+ EpisodeNumber,
1739
+ )
1740
+
1741
+ self.logger.trace(
1742
+ "Updating database entry | %s | S%02dE%03d",
1743
+ SeriesTitle,
1744
+ SeasonNumber,
1745
+ EpisodeNumber,
1746
+ )
1747
+ to_update = {
1748
+ self.model_file.Monitored: Monitored,
1749
+ self.model_file.Title: Title,
1750
+ self.model_file.AirDateUtc: AirDateUtc,
1751
+ self.model_file.LastSearchTime: LastSearchTime,
1752
+ self.model_file.SceneAbsoluteEpisodeNumber: SceneAbsoluteEpisodeNumber,
1753
+ self.model_file.AbsoluteEpisodeNumber: AbsoluteEpisodeNumber,
1754
+ self.model_file.EpisodeNumber: EpisodeNumber,
1755
+ self.model_file.EpisodeFileId: EpisodeFileId,
1756
+ self.model_file.SeriesId: SeriesId,
1757
+ self.model_file.SeriesTitle: SeriesTitle,
1758
+ self.model_file.SeasonNumber: SeasonNumber,
1759
+ self.model_file.QualityMet: QualityMet,
1760
+ }
1761
+ if searched:
1762
+ to_update[self.model_file.Searched] = searched
1763
+
1764
+ if request:
1765
+ to_update[self.model_file.IsRequest] = request
1766
+
1767
+ db_commands = self.model_file.insert(
1768
+ EntryId=EntryId,
1769
+ Title=Title,
1770
+ SeriesId=SeriesId,
1771
+ EpisodeFileId=EpisodeFileId,
1772
+ EpisodeNumber=EpisodeNumber,
1773
+ AbsoluteEpisodeNumber=AbsoluteEpisodeNumber,
1774
+ SceneAbsoluteEpisodeNumber=SceneAbsoluteEpisodeNumber,
1775
+ LastSearchTime=LastSearchTime,
1776
+ AirDateUtc=AirDateUtc,
1777
+ Monitored=Monitored,
1778
+ SeriesTitle=SeriesTitle,
1779
+ SeasonNumber=SeasonNumber,
1780
+ Searched=searched,
1781
+ IsRequest=request,
1782
+ QualityMet=QualityMet,
1783
+ ).on_conflict(
1784
+ conflict_target=[self.model_file.EntryId],
1785
+ update=to_update,
1786
+ )
1787
+ else:
1788
+ db_entry: SeriesModel
1789
+ EntryId = db_entry.Id
1790
+
1791
+ metadata = self.client.get_series(id_=EntryId)
1792
+ episode_count = metadata.get("episodeCount", -2)
1793
+ searched = episode_count == metadata.get("episodeFileCount", -1)
1794
+ if episode_count == 0:
1795
+ searched = True
1796
+ Title = metadata.get("title")
1797
+ Monitored = db_entry.Monitored
1798
+ self.logger.trace(
1799
+ "Updating database entry | %s",
1800
+ Title,
1801
+ )
1802
+ to_update = {
1803
+ self.series_file_model.Monitored: Monitored,
1804
+ self.series_file_model.Title: Title,
1805
+ }
1806
+ if searched:
1807
+ to_update[self.series_file_model.Searched] = searched
1808
+
1809
+ db_commands = self.series_file_model.insert(
1810
+ EntryId=EntryId, Title=Title, Searched=searched, Monitored=Monitored
1811
+ ).on_conflict(
1812
+ conflict_target=[self.series_file_model.EntryId],
1813
+ update=to_update,
1814
+ )
1815
+ db_commands.execute()
1816
+
1817
+ elif self.type == "radarr":
1818
+ if self.version == "4":
1819
+ db_entry: MoviesModel
1820
+ elif self.version == "5":
1821
+ db_entry: MoviesModelv5
1822
+ searched = False
1823
+ QualityUnmet = self.quality_unmet_search
1824
+ if db_entry.MovieFileId != 0 and not QualityUnmet:
1825
+ searched = True
1826
+ self.model_queue.update(Completed=True).where(
1827
+ self.model_queue.EntryId == db_entry.Id
1828
+ ).execute()
1829
+
1830
+ metadata = self.model_arr_movies_file.get(
1831
+ self.model_arr_movies_file.Id == db_entry.MovieMetadataId
1832
+ )
1833
+ metadata: MoviesMetadataModel
1834
+
1835
+ if self.minimum_availability_check(db_entry, metadata):
1836
+ title = metadata.Title
1837
+ monitored = db_entry.Monitored
1838
+ tmdbId = metadata.TmdbId
1839
+ year = metadata.Year
1840
+ EntryId = db_entry.Id
1841
+ MovieFileId = db_entry.MovieFileId
1842
+ QualityMet = db_entry.MovieFileId != 0 and not QualityUnmet
1843
+
1844
+ self.logger.trace("Updating database entry | %s (%s)", title, tmdbId)
1845
+ to_update = {
1846
+ self.model_file.MovieFileId: MovieFileId,
1847
+ self.model_file.Monitored: monitored,
1848
+ self.model_file.QualityMet: QualityMet,
1849
+ }
1850
+
1851
+ if searched:
1852
+ to_update[self.model_file.Searched] = searched
1853
+ if request:
1854
+ to_update[self.model_file.IsRequest] = request
1855
+ db_commands = self.model_file.insert(
1856
+ Title=title,
1857
+ Monitored=monitored,
1858
+ TmdbId=tmdbId,
1859
+ Year=year,
1860
+ EntryId=EntryId,
1861
+ Searched=searched,
1862
+ MovieFileId=MovieFileId,
1863
+ IsRequest=request,
1864
+ QualityMet=QualityMet,
1865
+ ).on_conflict(
1866
+ conflict_target=[self.model_file.EntryId],
1867
+ update=to_update,
1868
+ )
1869
+ db_commands.execute()
1870
+ else:
1871
+ return
1872
+
1873
+ except requests.exceptions.ConnectionError as e:
1874
+ self.logger.debug(
1875
+ "Max retries exceeded for %s ID:%s", self._name, db_entry.Id, exc_info=e
1876
+ )
1877
+ raise DelayLoopException(length=300, type=self._name)
1878
+ except Exception as e:
1879
+ self.logger.error(e, exc_info=sys.exc_info())
1880
+
1881
+ def delete_from_queue(self, id_, remove_from_client=True, blacklist=True):
1882
+ params = {
1883
+ "removeFromClient": remove_from_client,
1884
+ "blocklist": blacklist,
1885
+ "blacklist": blacklist,
1886
+ }
1887
+ path = f"/api/v3/queue/{id_}"
1888
+ res = self.client.request_del(path, params=params)
1889
+ return res
1890
+
1891
+ def file_is_probeable(self, file: pathlib.Path) -> bool:
1892
+ if not self.manager.ffprobe_available:
1893
+ return True # ffprobe is not found, so we say every file is acceptable.
1894
+ try:
1895
+ if file in self.files_probed:
1896
+ self.logger.trace("Probeable: File has already been probed: %s", file)
1897
+ return True
1898
+ if file.is_dir():
1899
+ self.logger.trace("Not probeable: File is a directory: %s", file)
1900
+ return False
1901
+ output = ffmpeg.probe(
1902
+ str(file.absolute()), cmd=self.manager.qbit_manager.ffprobe_downloader.probe_path
1903
+ )
1904
+ if not output:
1905
+ self.logger.trace("Not probeable: Probe returned no output: %s", file)
1906
+ return False
1907
+ self.files_probed.add(file)
1908
+ return True
1909
+ except ffmpeg.Error as e:
1910
+ error = e.stderr.decode()
1911
+ self.logger.trace(
1912
+ "Not probeable: Probe returned an error: %s:\n%s",
1913
+ file,
1914
+ e.stderr,
1915
+ exc_info=sys.exc_info(),
1916
+ )
1917
+ if "Invalid data found when processing input" in error:
1918
+ return False
1919
+ return False
1920
+
1921
+ def folder_cleanup(self, downloads_id: str | None, folder: pathlib.Path):
1922
+ if self.auto_delete is False:
1923
+ return
1924
+ self.logger.debug("Folder Cleanup: %s", folder)
1925
+ all_files_in_folder = list(absolute_file_paths(folder))
1926
+ invalid_files = set()
1927
+ probeable = 0
1928
+ for file in all_files_in_folder:
1929
+ if file.name in {"desktop.ini", ".DS_Store"}:
1930
+ continue
1931
+ elif file.suffix.lower() == ".parts":
1932
+ continue
1933
+ if not file.exists():
1934
+ continue
1935
+ if file.is_dir():
1936
+ self.logger.trace("Folder Cleanup: File is a folder: %s", file)
1937
+ continue
1938
+ if (
1939
+ file.suffix.lower() in self.file_extension_allowlist
1940
+ or not self.file_extension_allowlist
1941
+ ):
1942
+ self.logger.trace("Folder Cleanup: File has an allowed extension: %s", file)
1943
+ if self.file_is_probeable(file):
1944
+ self.logger.trace("Folder Cleanup: File is a valid media type: %s", file)
1945
+ probeable += 1
1946
+ continue
1947
+ else:
1948
+ invalid_files.add(file)
1949
+
1950
+ if not probeable:
1951
+ self.downloads_with_bad_error_message_blocklist.discard(downloads_id)
1952
+ self.delete.discard(downloads_id)
1953
+ self.remove_and_maybe_blocklist(downloads_id, folder)
1954
+ elif invalid_files:
1955
+ for file in invalid_files:
1956
+ self.remove_and_maybe_blocklist(None, file)
1957
+
1958
+ def post_file_cleanup(self):
1959
+ for downloads_id, file in self.files_to_cleanup:
1960
+ self.folder_cleanup(downloads_id, file)
1961
+ self.files_to_cleanup = set()
1962
+
1963
+ def post_download_error_cleanup(self):
1964
+ for downloads_id, file in self.files_to_explicitly_delete:
1965
+ self.remove_and_maybe_blocklist(downloads_id, file)
1966
+
1967
+ def remove_and_maybe_blocklist(self, downloads_id: str | None, file_or_folder: pathlib.Path):
1968
+ if downloads_id is not None:
1969
+ self.delete_from_queue(id_=downloads_id, blacklist=True)
1970
+ self.logger.debug(
1971
+ "Torrent removed and blocklisted: File was marked as failed by Arr " "| %s",
1972
+ file_or_folder,
1973
+ )
1974
+
1975
+ if file_or_folder.is_dir():
1976
+ try:
1977
+ shutil.rmtree(file_or_folder, ignore_errors=True)
1978
+ self.logger.debug(
1979
+ "Folder removed: Folder was marked as failed by Arr, "
1980
+ "manually removing it | %s",
1981
+ file_or_folder,
1982
+ )
1983
+ except PermissionError:
1984
+ self.logger.debug(
1985
+ "Folder in use: Failed to remove Folder: Folder was marked as failed by Ar "
1986
+ "| %s",
1987
+ file_or_folder,
1988
+ )
1989
+ else:
1990
+ try:
1991
+ file_or_folder.unlink(missing_ok=True)
1992
+ self.logger.debug(
1993
+ "File removed: File was marked as failed by Arr, " "manually removing it | %s",
1994
+ file_or_folder,
1995
+ )
1996
+ except PermissionError:
1997
+ self.logger.debug(
1998
+ "File in use: Failed to remove file: File was marked as failed by Ar | %s",
1999
+ file_or_folder,
2000
+ )
2001
+
2002
+ def all_folder_cleanup(self) -> None:
2003
+ if self.auto_delete is False:
2004
+ return
2005
+ self._update_bad_queue_items()
2006
+ self.post_file_cleanup()
2007
+ if self.needs_cleanup is False:
2008
+ return
2009
+ folder = self.completed_folder
2010
+ self.folder_cleanup(None, folder)
2011
+ self.files_to_explicitly_delete = iter([])
2012
+ self.post_download_error_cleanup()
2013
+ self._remove_empty_folders()
2014
+ self.needs_cleanup = False
2015
+
2016
+ def maybe_do_search(
2017
+ self,
2018
+ file_model: EpisodeFilesModel | MoviesFilesModel | SeriesFilesModel,
2019
+ request: bool = False,
2020
+ todays: bool = False,
2021
+ bypass_limit: bool = False,
2022
+ series_search: bool = False,
2023
+ ):
2024
+ request_tag = (
2025
+ "[OVERSEERR REQUEST]: "
2026
+ if request and self.overseerr_requests
2027
+ else "[OMBI REQUEST]: "
2028
+ if request and self.ombi_search_requests
2029
+ else "[PRIORITY SEARCH - TODAY]: "
2030
+ if todays
2031
+ else ""
2032
+ )
2033
+ if request or todays:
2034
+ bypass_limit = True
2035
+ if (not self.search_missing) or (file_model is None):
2036
+ return None
2037
+ elif not self.is_alive:
2038
+ raise NoConnectionrException(f"Could not connect to {self.uri}", type="arr")
2039
+ elif self.type == "sonarr":
2040
+ if not series_search:
2041
+ file_model: EpisodeFilesModel
2042
+ if not (request or todays):
2043
+ queue = (
2044
+ self.model_queue.select()
2045
+ .where(self.model_queue.EntryId == file_model.EntryId)
2046
+ .execute()
2047
+ )
2048
+ else:
2049
+ queue = False
2050
+ if queue:
2051
+ self.logger.debug(
2052
+ "%sSkipping: Already Searched: %s | "
2053
+ "S%02dE%03d | "
2054
+ "%s | [id=%s|AirDateUTC=%s]",
2055
+ request_tag,
2056
+ file_model.SeriesTitle,
2057
+ file_model.SeasonNumber,
2058
+ file_model.EpisodeNumber,
2059
+ file_model.Title,
2060
+ file_model.EntryId,
2061
+ file_model.AirDateUtc,
2062
+ )
2063
+ file_model.Searched = True
2064
+ file_model.save()
2065
+ return True
2066
+ active_commands = self.arr_db_query_commands_count()
2067
+ self.logger.debug(
2068
+ "%s%s active search commands",
2069
+ request_tag,
2070
+ active_commands,
2071
+ )
2072
+ if not bypass_limit and active_commands >= self.search_command_limit:
2073
+ self.logger.trace(
2074
+ "%sIdle: Too many commands in queue: %s | "
2075
+ "S%02dE%03d | "
2076
+ "%s | [id=%s|AirDateUTC=%s]",
2077
+ request_tag,
2078
+ file_model.SeriesTitle,
2079
+ file_model.SeasonNumber,
2080
+ file_model.EpisodeNumber,
2081
+ file_model.Title,
2082
+ file_model.EntryId,
2083
+ file_model.AirDateUtc,
2084
+ )
2085
+ return False
2086
+ self.persistent_queue.insert(
2087
+ EntryId=file_model.SeriesId
2088
+ ).on_conflict_ignore().execute()
2089
+ self.model_queue.insert(
2090
+ Completed=False,
2091
+ EntryId=file_model.EntryId,
2092
+ ).on_conflict_replace().execute()
2093
+ if file_model.EntryId not in self.queue_file_ids:
2094
+ self.client.post_command("EpisodeSearch", episodeIds=[file_model.EntryId])
2095
+ file_model.Searched = True
2096
+ file_model.save()
2097
+ self.logger.hnotice(
2098
+ "%sSearching for: %s | S%02dE%03d | %s | [id=%s|AirDateUTC=%s]",
2099
+ request_tag,
2100
+ file_model.SeriesTitle,
2101
+ file_model.SeasonNumber,
2102
+ file_model.EpisodeNumber,
2103
+ file_model.Title,
2104
+ file_model.EntryId,
2105
+ file_model.AirDateUtc,
2106
+ )
2107
+ return True
2108
+ else:
2109
+ file_model: SeriesFilesModel
2110
+ active_commands = self.arr_db_query_commands_count()
2111
+ self.logger.debug(
2112
+ "%s%s active search commands",
2113
+ request_tag,
2114
+ active_commands,
2115
+ )
2116
+ if not bypass_limit and active_commands >= self.search_command_limit:
2117
+ self.logger.trace(
2118
+ "%sIdle: Too many commands in queue: %s | %[id=%s]",
2119
+ request_tag,
2120
+ file_model.Title,
2121
+ file_model.EntryId,
2122
+ )
2123
+ return False
2124
+ self.persistent_queue.insert(
2125
+ EntryId=file_model.EntryId
2126
+ ).on_conflict_ignore().execute()
2127
+ self.model_queue.insert(
2128
+ Completed=False,
2129
+ EntryId=file_model.EntryId,
2130
+ ).on_conflict_replace().execute()
2131
+ if file_model.EntryId not in self.queue_file_ids:
2132
+ self.client.post_command(self.search_api_command, seriesId=file_model.EntryId)
2133
+ file_model.Searched = True
2134
+ file_model.save()
2135
+ self.logger.hnotice(
2136
+ "%sSearching for: %s | %s | [id=%s]",
2137
+ request_tag,
2138
+ "Missing episodes in"
2139
+ if "Missing" in self.search_api_command
2140
+ else "All episodes in",
2141
+ file_model.Title,
2142
+ file_model.EntryId,
2143
+ )
2144
+
2145
+ return True
2146
+ elif self.type == "radarr":
2147
+ file_model: MoviesFilesModel
2148
+ if not (request or todays):
2149
+ queue = (
2150
+ self.model_queue.select()
2151
+ .where(self.model_queue.EntryId == file_model.EntryId)
2152
+ .execute()
2153
+ )
2154
+ else:
2155
+ queue = False
2156
+ if queue:
2157
+ self.logger.debug(
2158
+ "%sSkipping: Already Searched: %s (%s) [tmdbId=%s|id=%s]",
2159
+ request_tag,
2160
+ file_model.Title,
2161
+ file_model.Year,
2162
+ file_model.TmdbId,
2163
+ file_model.EntryId,
2164
+ )
2165
+ file_model.Searched = True
2166
+ file_model.save()
2167
+ return True
2168
+ active_commands = self.arr_db_query_commands_count()
2169
+ self.logger.debug(
2170
+ "%s%s active search commands",
2171
+ request_tag,
2172
+ active_commands,
2173
+ )
2174
+ if not bypass_limit and active_commands >= self.search_command_limit:
2175
+ self.logger.trace(
2176
+ "%sSkipping: Too many in queue: %s (%s) [tmdbId=%s|id=%s]",
2177
+ request_tag,
2178
+ file_model.Title,
2179
+ file_model.Year,
2180
+ file_model.TmdbId,
2181
+ file_model.EntryId,
2182
+ )
2183
+ return False
2184
+ self.persistent_queue.insert(EntryId=file_model.EntryId).on_conflict_ignore().execute()
2185
+
2186
+ self.model_queue.insert(
2187
+ Completed=False,
2188
+ EntryId=file_model.EntryId,
2189
+ ).on_conflict_replace().execute()
2190
+ if file_model.EntryId not in self.queue_file_ids:
2191
+ self.client.post_command("MoviesSearch", movieIds=[file_model.EntryId])
2192
+ file_model.Searched = True
2193
+ file_model.save()
2194
+ self.logger.hnotice(
2195
+ "%sSearching for: %s (%s) [tmdbId=%s|id=%s]",
2196
+ request_tag,
2197
+ file_model.Title,
2198
+ file_model.Year,
2199
+ file_model.TmdbId,
2200
+ file_model.EntryId,
2201
+ )
2202
+ return True
2203
+
2204
+ def post_command(self, name, **kwargs):
2205
+ data = {
2206
+ "name": name,
2207
+ **kwargs,
2208
+ }
2209
+ res = self.client.post_command(name, **kwargs)
2210
+ return res
2211
+
2212
+ def process(self):
2213
+ self._process_resume()
2214
+ self._process_paused()
2215
+ self._process_errored()
2216
+ self._process_file_priority()
2217
+ self._process_imports()
2218
+ self._process_failed()
2219
+ self.all_folder_cleanup()
2220
+
2221
+ def process_entries(self, hashes: set[str]) -> tuple[list[tuple[int, str]], set[str]]:
2222
+ payload = [
2223
+ (_id, h.upper()) for h in hashes if (_id := self.cache.get(h.upper())) is not None
2224
+ ]
2225
+ hashes = {h for h in hashes if (_id := self.cache.get(h.upper())) is not None}
2226
+
2227
+ return payload, hashes
2228
+
2229
+ def process_torrents(self):
2230
+ try:
2231
+ try:
2232
+ torrents = self.manager.qbit_manager.client.torrents.info(
2233
+ status_filter="all", category=self.category, sort="added_on", reverse=False
2234
+ )
2235
+ torrents = [t for t in torrents if hasattr(t, "category")]
2236
+ if not len(torrents):
2237
+ raise DelayLoopException(length=5, type="no_downloads")
2238
+ if has_internet() is False:
2239
+ self.manager.qbit_manager.should_delay_torrent_scan = True
2240
+ raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet")
2241
+ if self.manager.qbit_manager.should_delay_torrent_scan:
2242
+ raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay")
2243
+ self.api_calls()
2244
+ self.refresh_download_queue()
2245
+ for torrent in torrents:
2246
+ with contextlib.suppress(qbittorrentapi.NotFound404Error):
2247
+ self._process_single_torrent(torrent)
2248
+ self.process()
2249
+ except NoConnectionrException as e:
2250
+ self.logger.error(e.message)
2251
+ except requests.exceptions.ConnectionError:
2252
+ self.logger.warning("Couldn't connect to %s", self.type)
2253
+ self._temp_overseer_request_cache = defaultdict(set)
2254
+ return self._temp_overseer_request_cache
2255
+ except qbittorrentapi.exceptions.APIError as e:
2256
+ self.logger.error("The qBittorrent API returned an unexpected error")
2257
+ self.logger.debug("Unexpected APIError from qBitTorrent", exc_info=e)
2258
+ raise DelayLoopException(length=300, type="qbit")
2259
+ except AttributeError as e:
2260
+ self.logger.info("Torrent still connecting to trackers")
2261
+ self.logger.debug("No trackers found yet", exc_info=e)
2262
+ raise DelayLoopException(length=300, type="qbit")
2263
+ except DelayLoopException:
2264
+ raise
2265
+ except KeyboardInterrupt:
2266
+ self.logger.hnotice("Detected Ctrl+C - Terminating process")
2267
+ sys.exit(0)
2268
+ except Exception as e:
2269
+ self.logger.error(e, exc_info=sys.exc_info())
2270
+ except KeyboardInterrupt:
2271
+ self.logger.hnotice("Detected Ctrl+C - Terminating process")
2272
+ sys.exit(0)
2273
+ except DelayLoopException:
2274
+ raise
2275
+
2276
+ def _process_single_torrent_failed_cat(self, torrent: qbittorrentapi.TorrentDictionary):
2277
+ self.logger.notice(
2278
+ "Deleting manually failed torrent: "
2279
+ "[Progress: %s%%][Added On: %s]"
2280
+ "[Availability: %s%%][Time Left: %s]"
2281
+ "[Last active: %s] "
2282
+ "| [%s] | %s (%s)",
2283
+ round(torrent.progress * 100, 2),
2284
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2285
+ round(torrent.availability * 100, 2),
2286
+ timedelta(seconds=torrent.eta),
2287
+ datetime.fromtimestamp(torrent.last_activity),
2288
+ torrent.state_enum,
2289
+ torrent.name,
2290
+ torrent.hash,
2291
+ )
2292
+ self.delete.add(torrent.hash)
2293
+
2294
+ def _process_single_torrent_recheck_cat(self, torrent: qbittorrentapi.TorrentDictionary):
2295
+ self.logger.notice(
2296
+ "Re-checking manually set torrent: "
2297
+ "[Progress: %s%%][Added On: %s]"
2298
+ "[Availability: %s%%][Time Left: %s]"
2299
+ "[Last active: %s] "
2300
+ "| [%s] | %s (%s)",
2301
+ round(torrent.progress * 100, 2),
2302
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2303
+ round(torrent.availability * 100, 2),
2304
+ timedelta(seconds=torrent.eta),
2305
+ datetime.fromtimestamp(torrent.last_activity),
2306
+ torrent.state_enum,
2307
+ torrent.name,
2308
+ torrent.hash,
2309
+ )
2310
+ self.recheck.add(torrent.hash)
2311
+
2312
+ def _process_single_torrent_ignored(self, torrent: qbittorrentapi.TorrentDictionary):
2313
+ # Do not touch torrents that are currently being ignored.
2314
+ self.logger.trace(
2315
+ "Skipping torrent: Ignored state | "
2316
+ "[Progress: %s%%][Added On: %s]"
2317
+ "[Availability: %s%%][Time Left: %s]"
2318
+ "[Last active: %s] "
2319
+ "| [%s] | %s (%s)",
2320
+ round(torrent.progress * 100, 2),
2321
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2322
+ round(torrent.availability * 100, 2),
2323
+ timedelta(seconds=torrent.eta),
2324
+ datetime.fromtimestamp(torrent.last_activity),
2325
+ torrent.state_enum,
2326
+ torrent.name,
2327
+ torrent.hash,
2328
+ )
2329
+ if torrent.state_enum == TorrentStates.QUEUED_DOWNLOAD:
2330
+ self.recently_queue[torrent.hash] = time.time()
2331
+
2332
+ def _process_single_torrent_added_to_ignore_cache(
2333
+ self, torrent: qbittorrentapi.TorrentDictionary
2334
+ ):
2335
+ self.logger.trace(
2336
+ "Skipping torrent: Marked for skipping | "
2337
+ "[Progress: %s%%][Added On: %s]"
2338
+ "[Availability: %s%%][Time Left: %s]"
2339
+ "[Last active: %s] "
2340
+ "| [%s] | %s (%s)",
2341
+ round(torrent.progress * 100, 2),
2342
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2343
+ round(torrent.availability * 100, 2),
2344
+ timedelta(seconds=torrent.eta),
2345
+ datetime.fromtimestamp(torrent.last_activity),
2346
+ torrent.state_enum,
2347
+ torrent.name,
2348
+ torrent.hash,
2349
+ )
2350
+
2351
+ def _process_single_torrent_queued_upload(
2352
+ self, torrent: qbittorrentapi.TorrentDictionary, leave_alone: bool
2353
+ ):
2354
+ if leave_alone or torrent.state_enum == TorrentStates.FORCED_UPLOAD:
2355
+ self.logger.trace(
2356
+ "Torrent State: Queued Upload | Allowing Seeding | "
2357
+ "[Progress: %s%%][Added On: %s]"
2358
+ "[Availability: %s%%][Time Left: %s]"
2359
+ "[Last active: %s] "
2360
+ "| [%s] | %s (%s)",
2361
+ round(torrent.progress * 100, 2),
2362
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2363
+ round(torrent.availability * 100, 2),
2364
+ timedelta(seconds=torrent.eta),
2365
+ datetime.fromtimestamp(torrent.last_activity),
2366
+ torrent.state_enum,
2367
+ torrent.name,
2368
+ torrent.hash,
2369
+ )
2370
+ else:
2371
+ self.pause.add(torrent.hash)
2372
+ self.logger.trace(
2373
+ "Pausing torrent: Queued Upload | "
2374
+ "[Progress: %s%%][Added On: %s]"
2375
+ "[Availability: %s%%][Time Left: %s]"
2376
+ "[Last active: %s] "
2377
+ "| [%s] | %s (%s)",
2378
+ round(torrent.progress * 100, 2),
2379
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2380
+ round(torrent.availability * 100, 2),
2381
+ timedelta(seconds=torrent.eta),
2382
+ datetime.fromtimestamp(torrent.last_activity),
2383
+ torrent.state_enum,
2384
+ torrent.name,
2385
+ torrent.hash,
2386
+ )
2387
+
2388
+ def _process_single_torrent_stalled_torrent(
2389
+ self, torrent: qbittorrentapi.TorrentDictionary, extra: str
2390
+ ):
2391
+ # Process torrents who have stalled at this point, only mark for
2392
+ # deletion if they have been added more than "IgnoreTorrentsYoungerThan"
2393
+ # seconds ago
2394
+ if (
2395
+ self.recently_queue.get(torrent.hash, torrent.added_on)
2396
+ < time.time() - self.ignore_torrents_younger_than
2397
+ ):
2398
+ self.logger.info(
2399
+ "Deleting Stale torrent: %s | "
2400
+ "[Progress: %s%%][Added On: %s]"
2401
+ "[Availability: %s%%][Time Left: %s]"
2402
+ "[Last active: %s] "
2403
+ "| [%s] | %s (%s)",
2404
+ extra,
2405
+ round(torrent.progress * 100, 2),
2406
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2407
+ round(torrent.availability * 100, 2),
2408
+ timedelta(seconds=torrent.eta),
2409
+ datetime.fromtimestamp(torrent.last_activity),
2410
+ torrent.state_enum,
2411
+ torrent.name,
2412
+ torrent.hash,
2413
+ )
2414
+ self.delete.add(torrent.hash)
2415
+ else:
2416
+ self.logger.trace(
2417
+ "Ignoring Stale torrent: "
2418
+ "[Progress: %s%%][Added On: %s]"
2419
+ "[Availability: %s%%][Time Left: %s]"
2420
+ "[Last active: %s] "
2421
+ "| [%s] | %s (%s)",
2422
+ round(torrent.progress * 100, 2),
2423
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2424
+ round(torrent.availability * 100, 2),
2425
+ timedelta(seconds=torrent.eta),
2426
+ datetime.fromtimestamp(torrent.last_activity),
2427
+ torrent.state_enum,
2428
+ torrent.name,
2429
+ torrent.hash,
2430
+ )
2431
+
2432
+ def _process_single_torrent_percentage_threshold(
2433
+ self, torrent: qbittorrentapi.TorrentDictionary, maximum_eta: int
2434
+ ):
2435
+ # Ignore torrents who have reached maximum percentage as long as
2436
+ # the last activity is within the MaximumETA set for this category
2437
+ # For example if you set MaximumETA to 5 mines, this will ignore all
2438
+ # torrents that have stalled at a higher percentage as long as there is activity
2439
+ # And the window of activity is determined by the current time - MaximumETA,
2440
+ # if the last active was after this value ignore this torrent
2441
+ # the idea here is that if a torrent isn't completely dead some leecher/seeder
2442
+ # may contribute towards your progress.
2443
+ # However if its completely dead and no activity is observed, then lets
2444
+ # remove it and requeue a new torrent.
2445
+ if maximum_eta > 0 and torrent.last_activity < (time.time() - maximum_eta):
2446
+ self.logger.info(
2447
+ "Deleting Stale torrent: Last activity is older than Maximum ETA "
2448
+ "[Progress: %s%%][Added On: %s]"
2449
+ "[Availability: %s%%][Time Left: %s]"
2450
+ "[Last active: %s] "
2451
+ "| [%s] | %s (%s)",
2452
+ round(torrent.progress * 100, 2),
2453
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2454
+ round(torrent.availability * 100, 2),
2455
+ timedelta(seconds=torrent.eta),
2456
+ datetime.fromtimestamp(torrent.last_activity),
2457
+ torrent.state_enum,
2458
+ torrent.name,
2459
+ torrent.hash,
2460
+ )
2461
+ self.delete.add(torrent.hash)
2462
+ else:
2463
+ self.logger.trace(
2464
+ "Skipping torrent: Reached Maximum completed "
2465
+ "percentage and is active | "
2466
+ "[Progress: %s%%][Added On: %s]"
2467
+ "[Availability: %s%%][Time Left: %s]"
2468
+ "[Last active: %s] "
2469
+ "| [%s] | %s (%s)",
2470
+ round(torrent.progress * 100, 2),
2471
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2472
+ round(torrent.availability * 100, 2),
2473
+ timedelta(seconds=torrent.eta),
2474
+ datetime.fromtimestamp(torrent.last_activity),
2475
+ torrent.state_enum,
2476
+ torrent.name,
2477
+ torrent.hash,
2478
+ )
2479
+ return
2480
+
2481
+ def _process_single_torrent_paused(self, torrent: qbittorrentapi.TorrentDictionary):
2482
+ self.timed_ignore_cache.add(torrent.hash)
2483
+ self.resume.add(torrent.hash)
2484
+ self.logger.debug(
2485
+ "Resuming incomplete paused torrent: "
2486
+ "[Progress: %s%%][Added On: %s]"
2487
+ "[Availability: %s%%][Time Left: %s]"
2488
+ "[Last active: %s] "
2489
+ "| [%s] | %s (%s)",
2490
+ round(torrent.progress * 100, 2),
2491
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2492
+ round(torrent.availability * 100, 2),
2493
+ timedelta(seconds=torrent.eta),
2494
+ datetime.fromtimestamp(torrent.last_activity),
2495
+ torrent.state_enum,
2496
+ torrent.name,
2497
+ torrent.hash,
2498
+ )
2499
+
2500
+ def _process_single_torrent_already_sent_to_scan(
2501
+ self, torrent: qbittorrentapi.TorrentDictionary
2502
+ ):
2503
+ self.logger.trace(
2504
+ "Skipping torrent: Already sent for import | "
2505
+ "[Progress: %s%%][Added On: %s]"
2506
+ "[Availability: %s%%][Time Left: %s]"
2507
+ "[Last active: %s] "
2508
+ "| [%s] | %s (%s)",
2509
+ round(torrent.progress * 100, 2),
2510
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2511
+ round(torrent.availability * 100, 2),
2512
+ timedelta(seconds=torrent.eta),
2513
+ datetime.fromtimestamp(torrent.last_activity),
2514
+ torrent.state_enum,
2515
+ torrent.name,
2516
+ torrent.hash,
2517
+ )
2518
+
2519
+ def _process_single_torrent_errored(self, torrent: qbittorrentapi.TorrentDictionary):
2520
+ self.logger.trace(
2521
+ "Rechecking Errored torrent: "
2522
+ "[Progress: %s%%][Added On: %s]"
2523
+ "[Availability: %s%%][Time Left: %s]"
2524
+ "[Last active: %s] "
2525
+ "| [%s] | %s (%s)",
2526
+ round(torrent.progress * 100, 2),
2527
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2528
+ round(torrent.availability * 100, 2),
2529
+ timedelta(seconds=torrent.eta),
2530
+ datetime.fromtimestamp(torrent.last_activity),
2531
+ torrent.state_enum,
2532
+ torrent.name,
2533
+ torrent.hash,
2534
+ )
2535
+ self.recheck.add(torrent.hash)
2536
+
2537
+ def _process_single_torrent_fully_completed_torrent(
2538
+ self, torrent: qbittorrentapi.TorrentDictionary, leave_alone: bool
2539
+ ):
2540
+ if leave_alone or torrent.state_enum == TorrentStates.FORCED_UPLOAD:
2541
+ self.logger.trace(
2542
+ "Torrent State: Completed | Allowing Seeding | "
2543
+ "[Progress: %s%%][Added On: %s]"
2544
+ "[Availability: %s%%][Time Left: %s]"
2545
+ "[Last active: %s] "
2546
+ "| [%s] | %s (%s)",
2547
+ round(torrent.progress * 100, 2),
2548
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2549
+ round(torrent.availability * 100, 2),
2550
+ timedelta(seconds=torrent.eta),
2551
+ datetime.fromtimestamp(torrent.last_activity),
2552
+ torrent.state_enum,
2553
+ torrent.name,
2554
+ torrent.hash,
2555
+ )
2556
+ else:
2557
+ self.logger.info(
2558
+ "Pausing Completed torrent: "
2559
+ "[Progress: %s%%][Added On: %s]"
2560
+ "[Availability: %s%%][Time Left: %s]"
2561
+ "[Last active: %s] "
2562
+ "| [%s] | %s (%s)",
2563
+ round(torrent.progress * 100, 2),
2564
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2565
+ round(torrent.availability * 100, 2),
2566
+ timedelta(seconds=torrent.eta),
2567
+ datetime.fromtimestamp(torrent.last_activity),
2568
+ torrent.state_enum,
2569
+ torrent.name,
2570
+ torrent.hash,
2571
+ )
2572
+ self.pause.add(torrent.hash)
2573
+ content_path = pathlib.Path(torrent.content_path)
2574
+ if content_path.is_dir() and content_path.name == torrent.name:
2575
+ torrent_folder = content_path
2576
+ else:
2577
+ if content_path.is_file() and content_path.parent.name == torrent.name:
2578
+ torrent_folder = content_path.parent
2579
+ else:
2580
+ torrent_folder = content_path
2581
+ self.files_to_cleanup.add((torrent.hash, torrent_folder))
2582
+ self.import_torrents.append(torrent)
2583
+
2584
+ def _process_single_torrent_missing_files(self, torrent: qbittorrentapi.TorrentDictionary):
2585
+ # Sometimes Sonarr/Radarr does not automatically remove the
2586
+ # torrent for some reason,
2587
+ # this ensures that we can safely remove it if the client is reporting
2588
+ # the status of the client as "Missing files"
2589
+ self.logger.info(
2590
+ "Deleting torrent with missing files: "
2591
+ "[Progress: %s%%][Added On: %s]"
2592
+ "[Availability: %s%%][Time Left: %s]"
2593
+ "[Last active: %s] "
2594
+ "| [%s] | %s (%s)",
2595
+ round(torrent.progress * 100, 2),
2596
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2597
+ round(torrent.availability * 100, 2),
2598
+ timedelta(seconds=torrent.eta),
2599
+ datetime.fromtimestamp(torrent.last_activity),
2600
+ torrent.state_enum,
2601
+ torrent.name,
2602
+ torrent.hash,
2603
+ )
2604
+ # We do not want to blacklist these!!
2605
+ self.remove_from_qbit.add(torrent.hash)
2606
+
2607
+ def _process_single_torrent_uploading(
2608
+ self, torrent: qbittorrentapi.TorrentDictionary, leave_alone: bool
2609
+ ):
2610
+ if leave_alone or torrent.state_enum == TorrentStates.FORCED_UPLOAD:
2611
+ self.logger.trace(
2612
+ "Torrent State: Queued Upload | Allowing Seeding | "
2613
+ "[Progress: %s%%][Added On: %s]"
2614
+ "[Availability: %s%%][Time Left: %s]"
2615
+ "[Last active: %s] "
2616
+ "| [%s] | %s (%s)",
2617
+ round(torrent.progress * 100, 2),
2618
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2619
+ round(torrent.availability * 100, 2),
2620
+ timedelta(seconds=torrent.eta),
2621
+ datetime.fromtimestamp(torrent.last_activity),
2622
+ torrent.state_enum,
2623
+ torrent.name,
2624
+ torrent.hash,
2625
+ )
2626
+ else:
2627
+ self.logger.info(
2628
+ "Pausing uploading torrent: "
2629
+ "[Progress: %s%%][Added On: %s]"
2630
+ "[Availability: %s%%][Time Left: %s]"
2631
+ "[Last active: %s] "
2632
+ "| [%s] | %s (%s)",
2633
+ round(torrent.progress * 100, 2),
2634
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2635
+ round(torrent.availability * 100, 2),
2636
+ timedelta(seconds=torrent.eta),
2637
+ datetime.fromtimestamp(torrent.last_activity),
2638
+ torrent.state_enum,
2639
+ torrent.name,
2640
+ torrent.hash,
2641
+ )
2642
+ self.pause.add(torrent.hash)
2643
+
2644
+ def _process_single_torrent_already_cleaned_up(
2645
+ self, torrent: qbittorrentapi.TorrentDictionary
2646
+ ):
2647
+ self.logger.trace(
2648
+ "Skipping file check: Already been cleaned up | "
2649
+ "[Progress: %s%%][Added On: %s]"
2650
+ "[Availability: %s%%][Time Left: %s]"
2651
+ "[Last active: %s] "
2652
+ "| [%s] | %s (%s)",
2653
+ round(torrent.progress * 100, 2),
2654
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2655
+ round(torrent.availability * 100, 2),
2656
+ timedelta(seconds=torrent.eta),
2657
+ datetime.fromtimestamp(torrent.last_activity),
2658
+ torrent.state_enum,
2659
+ torrent.name,
2660
+ torrent.hash,
2661
+ )
2662
+
2663
+ def _process_single_torrent_delete_slow(self, torrent: qbittorrentapi.TorrentDictionary):
2664
+ self.logger.trace(
2665
+ "Deleting slow torrent: "
2666
+ "[Progress: %s%%][Added On: %s]"
2667
+ "[Availability: %s%%][Time Left: %s]"
2668
+ "[Last active: %s] "
2669
+ "| [%s] | %s (%s)",
2670
+ round(torrent.progress * 100, 2),
2671
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2672
+ round(torrent.availability * 100, 2),
2673
+ timedelta(seconds=torrent.eta),
2674
+ datetime.fromtimestamp(torrent.last_activity),
2675
+ torrent.state_enum,
2676
+ torrent.name,
2677
+ torrent.hash,
2678
+ )
2679
+ self.delete.add(torrent.hash)
2680
+
2681
+ def _process_single_torrent_delete_ratio_seed(self, torrent: qbittorrentapi.TorrentDictionary):
2682
+ self.logger.info(
2683
+ "Deleting torrent that met remove config: "
2684
+ "[Progress: %s%%][Added On: %s]"
2685
+ "[Ratio: %s%%][Seeding time: %s]"
2686
+ "[Last active: %s] "
2687
+ "| [%s] | %s (%s)",
2688
+ round(torrent.progress * 100, 2),
2689
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2690
+ torrent.ratio,
2691
+ timedelta(seconds=torrent.seeding_time),
2692
+ datetime.fromtimestamp(torrent.last_activity),
2693
+ torrent.state_enum,
2694
+ torrent.name,
2695
+ torrent.hash,
2696
+ )
2697
+ self.delete.add(torrent.hash)
2698
+
2699
+ def _process_single_torrent_process_files(
2700
+ self, torrent: qbittorrentapi.TorrentDictionary, special_case: bool = False
2701
+ ):
2702
+ _remove_files = set()
2703
+ total = len(torrent.files)
2704
+ if total == 0:
2705
+ return
2706
+ elif special_case:
2707
+ self.special_casing_file_check.add(torrent.hash)
2708
+ for file in torrent.files:
2709
+ file_path = pathlib.Path(file.name)
2710
+ # Acknowledge files that already been marked as "Don't download"
2711
+ if file.priority == 0:
2712
+ total -= 1
2713
+ continue
2714
+ # A folder within the folder tree matched the terms
2715
+ # in FolderExclusionRegex, mark it for exclusion.
2716
+ if self.folder_exclusion_regex and any(
2717
+ self.folder_exclusion_regex_re.search(p.name.lower())
2718
+ for p in file_path.parents
2719
+ if (folder_match := p.name)
2720
+ ):
2721
+ self.logger.debug(
2722
+ "Removing File: Not allowed | Parent: %s | %s (%s) | %s ",
2723
+ folder_match,
2724
+ torrent.name,
2725
+ torrent.hash,
2726
+ file.name,
2727
+ )
2728
+ _remove_files.add(file.id)
2729
+ total -= 1
2730
+ # A file matched and entry in FileNameExclusionRegex, mark it for
2731
+ # exclusion.
2732
+ elif self.file_name_exclusion_regex and (
2733
+ (match := self.file_name_exclusion_regex_re.search(file_path.name))
2734
+ and match.group()
2735
+ ):
2736
+ self.logger.debug(
2737
+ "Removing File: Not allowed | Name: %s | %s (%s) | %s ",
2738
+ match.group(),
2739
+ torrent.name,
2740
+ torrent.hash,
2741
+ file.name,
2742
+ )
2743
+ _remove_files.add(file.id)
2744
+ total -= 1
2745
+ elif (
2746
+ self.file_extension_allowlist
2747
+ and file_path.suffix.lower() not in self.file_extension_allowlist
2748
+ ):
2749
+ self.logger.debug(
2750
+ "Removing File: Not allowed | Extension: %s | %s (%s) | %s ",
2751
+ file_path.suffix,
2752
+ torrent.name,
2753
+ torrent.hash,
2754
+ file.name,
2755
+ )
2756
+ _remove_files.add(file.id)
2757
+ total -= 1
2758
+ # If all files in the torrent are marked for exclusion then delete the
2759
+ # torrent.
2760
+ if total == 0:
2761
+ self.logger.info(
2762
+ "Deleting All files ignored: "
2763
+ "[Progress: %s%%][Added On: %s]"
2764
+ "[Availability: %s%%][Time Left: %s]"
2765
+ "[Last active: %s] "
2766
+ "| [%s] | %s (%s)",
2767
+ round(torrent.progress * 100, 2),
2768
+ datetime.fromtimestamp(
2769
+ self.recently_queue.get(torrent.hash, torrent.added_on)
2770
+ ),
2771
+ round(torrent.availability * 100, 2),
2772
+ timedelta(seconds=torrent.eta),
2773
+ datetime.fromtimestamp(torrent.last_activity),
2774
+ torrent.state_enum,
2775
+ torrent.name,
2776
+ torrent.hash,
2777
+ )
2778
+ self.delete.add(torrent.hash)
2779
+ # Mark all bad files and folder for exclusion.
2780
+ elif _remove_files and torrent.hash not in self.change_priority:
2781
+ self.change_priority[torrent.hash] = list(_remove_files)
2782
+ elif _remove_files and torrent.hash in self.change_priority:
2783
+ self.change_priority[torrent.hash] = list(_remove_files)
2784
+
2785
+ self.cleaned_torrents.add(torrent.hash)
2786
+
2787
+ def _process_single_torrent_unprocessed(self, torrent: qbittorrentapi.TorrentDictionary):
2788
+ self.logger.trace(
2789
+ "Skipping torrent: Unresolved state: "
2790
+ "[Progress: %s%%][Added On: %s]"
2791
+ "[Availability: %s%%][Time Left: %s]"
2792
+ "[Last active: %s] "
2793
+ "| [%s] | %s (%s)",
2794
+ round(torrent.progress * 100, 2),
2795
+ datetime.fromtimestamp(self.recently_queue.get(torrent.hash, torrent.added_on)),
2796
+ round(torrent.availability * 100, 2),
2797
+ timedelta(seconds=torrent.eta),
2798
+ datetime.fromtimestamp(torrent.last_activity),
2799
+ torrent.state_enum,
2800
+ torrent.name,
2801
+ torrent.hash,
2802
+ )
2803
+
2804
+ def _get_torrent_important_trackers(
2805
+ self, torrent: qbittorrentapi.TorrentDictionary
2806
+ ) -> tuple[set[str], set[str]]:
2807
+ current_trackers = {i.url for i in torrent.trackers}
2808
+ monitored_trackers = self._monitored_tracker_urls.intersection(current_trackers)
2809
+ need_to_be_added = self._add_trackers_if_missing.difference(current_trackers)
2810
+ monitored_trackers = monitored_trackers.union(need_to_be_added)
2811
+ return need_to_be_added, monitored_trackers
2812
+
2813
+ @staticmethod
2814
+ def __return_max(x: dict):
2815
+ return x.get("Priority", -100)
2816
+
2817
+ def _get_most_important_tracker_and_tags(
2818
+ self, monitored_trackers, removed
2819
+ ) -> tuple[dict, set[str]]:
2820
+ new_list = [
2821
+ i
2822
+ for i in self.monitored_trackers
2823
+ if (i.get("URI") in monitored_trackers) and not i.get("RemoveIfExists") is True
2824
+ ]
2825
+ _list_of_tags = [i.get("AddTags", []) for i in new_list if i.get("URI") not in removed]
2826
+ if new_list:
2827
+ max_item = max(new_list, key=self.__return_max)
2828
+ else:
2829
+ max_item = {}
2830
+ return max_item, set(itertools.chain.from_iterable(_list_of_tags))
2831
+
2832
+ def _get_torrent_limit_meta(self, torrent: qbittorrentapi.TorrentDictionary):
2833
+ _, monitored_trackers = self._get_torrent_important_trackers(torrent)
2834
+ most_important_tracker, unique_tags = self._get_most_important_tracker_and_tags(
2835
+ monitored_trackers, {}
2836
+ )
2837
+
2838
+ data_settings = {
2839
+ "ratio_limit": r
2840
+ if (
2841
+ r := most_important_tracker.get(
2842
+ "MaxUploadRatio", self.seeding_mode_global_max_upload_ratio
2843
+ )
2844
+ )
2845
+ > 0
2846
+ else -5,
2847
+ "seeding_time_limit": r
2848
+ if (
2849
+ r := most_important_tracker.get(
2850
+ "MaxSeedingTime", self.seeding_mode_global_max_seeding_time
2851
+ )
2852
+ )
2853
+ > 0
2854
+ else -5,
2855
+ "dl_limit": r
2856
+ if (
2857
+ r := most_important_tracker.get(
2858
+ "DownloadRateLimit", self.seeding_mode_global_download_limit
2859
+ )
2860
+ )
2861
+ > 0
2862
+ else -5,
2863
+ "up_limit": r
2864
+ if (
2865
+ r := most_important_tracker.get(
2866
+ "UploadRateLimit", self.seeding_mode_global_upload_limit
2867
+ )
2868
+ )
2869
+ > 0
2870
+ else -5,
2871
+ "super_seeding": most_important_tracker.get("SuperSeedMode", torrent.super_seeding),
2872
+ "max_eta": most_important_tracker.get("MaximumETA", self.maximum_eta),
2873
+ }
2874
+
2875
+ data_torrent = {
2876
+ "ratio_limit": r if (r := torrent.ratio_limit) > 0 else -5,
2877
+ "seeding_time_limit": r if (r := torrent.seeding_time_limit) > 0 else -5,
2878
+ "dl_limit": r if (r := torrent.dl_limit) > 0 else -5,
2879
+ "up_limit": r if (r := torrent.up_limit) > 0 else -5,
2880
+ "super_seeding": torrent.super_seeding,
2881
+ }
2882
+ return data_settings, data_torrent
2883
+
2884
+ def _should_leave_alone(self, torrent: qbittorrentapi.TorrentDictionary) -> tuple[bool, int]:
2885
+ return_value = True
2886
+ if torrent.super_seeding or torrent.state_enum == TorrentStates.FORCED_UPLOAD:
2887
+ return return_value, -1 # Do not touch super seeding torrents.
2888
+ data_settings, data_torrent = self._get_torrent_limit_meta(torrent)
2889
+ self.logger.trace("Config Settings for torrent [%s]: %r", torrent.name, data_settings)
2890
+ self.logger.trace("Torrent Settings for torrent [%s]: %r", torrent.name, data_torrent)
2891
+ # self.logger.trace("%r", torrent)
2892
+
2893
+ ratio_limit_dat = data_settings.get("ratio_limit", -5)
2894
+ ratio_limit_tor = data_torrent.get("ratio_limit", -5)
2895
+ seeding_time_limit_dat = data_settings.get("seeding_time_limit", -5)
2896
+ seeding_time_limit_tor = data_torrent.get("seeding_time_limit", -5)
2897
+
2898
+ seeding_time_limit = max(seeding_time_limit_dat, seeding_time_limit_tor)
2899
+ ratio_limit = max(ratio_limit_dat, ratio_limit_tor)
2900
+
2901
+ if torrent.ratio >= ratio_limit:
2902
+ return_value = False # Seeding ratio met - Can be cleaned up.
2903
+ if torrent.seeding_time >= seeding_time_limit:
2904
+ return_value = False # Seeding time met - Can be cleaned up.
2905
+ if data_settings.get("super_seeding", False) or data_torrent.get("super_seeding", False):
2906
+ return_value = True
2907
+ if return_value is True and "qBitrr-allowed_seeding" not in torrent.tags:
2908
+ torrent.add_tags(tags=["qBitrr-allowed_seeding"])
2909
+ elif return_value is False and "qBitrr-allowed_seeding" in torrent.tags:
2910
+ torrent.remove_tags(tags=["qBitrr-allowed_seeding"])
2911
+ return return_value, data_settings.get(
2912
+ "max_eta", self.maximum_eta
2913
+ ) # Seeding is not complete needs more time
2914
+
2915
+ def _process_single_torrent_trackers(self, torrent: qbittorrentapi.TorrentDictionary):
2916
+ if torrent.hash in self.tracker_delay:
2917
+ return
2918
+ self.tracker_delay.add(torrent.hash)
2919
+ _remove_urls = set()
2920
+ need_to_be_added, monitored_trackers = self._get_torrent_important_trackers(torrent)
2921
+ if need_to_be_added:
2922
+ torrent.add_trackers(need_to_be_added)
2923
+ try:
2924
+ for tracker in torrent.trackers:
2925
+ if (
2926
+ self.remove_dead_trackers
2927
+ and (
2928
+ any(tracker.msg == m for m in self.seeding_mode_global_bad_tracker_msg)
2929
+ ) # TODO: Add more messages
2930
+ ) or tracker.url in self._remove_trackers_if_exists:
2931
+ _remove_urls.add(tracker.url)
2932
+ except BaseException:
2933
+ pass
2934
+ if _remove_urls:
2935
+ self.logger.trace(
2936
+ "Removing trackers from torrent: %s (%s) - %s",
2937
+ torrent.name,
2938
+ torrent.hash,
2939
+ _remove_urls,
2940
+ )
2941
+ with contextlib.suppress(qbittorrentapi.Conflict409Error):
2942
+ torrent.remove_trackers(_remove_urls)
2943
+ most_important_tracker, unique_tags = self._get_most_important_tracker_and_tags(
2944
+ monitored_trackers, _remove_urls
2945
+ )
2946
+ if monitored_trackers and most_important_tracker:
2947
+ # Only use globals if there is not a configured equivalent value on the
2948
+ # highest priority tracker
2949
+ data = {
2950
+ "ratio_limit": r
2951
+ if (
2952
+ r := most_important_tracker.get(
2953
+ "MaxUploadRatio", self.seeding_mode_global_max_upload_ratio
2954
+ )
2955
+ )
2956
+ > 0
2957
+ else None,
2958
+ "seeding_time_limit": r
2959
+ if (
2960
+ r := most_important_tracker.get(
2961
+ "MaxSeedingTime", self.seeding_mode_global_max_seeding_time
2962
+ )
2963
+ )
2964
+ > 0
2965
+ else None,
2966
+ }
2967
+ if any(r is not None for r in data):
2968
+ if (
2969
+ (_l1 := data.get("seeding_time_limit"))
2970
+ and _l1 > 0
2971
+ and torrent.seeding_time_limit != data.get("seeding_time_limit")
2972
+ ):
2973
+ data.pop("seeding_time_limit")
2974
+ if (
2975
+ (_l2 := data.get("ratio_limit"))
2976
+ and _l2 > 0
2977
+ and torrent.seeding_time_limit != data.get("ratio_limit")
2978
+ ):
2979
+ data.pop("ratio_limit")
2980
+
2981
+ if not _l1:
2982
+ data["seeding_time_limit"] = None
2983
+ elif _l1 < 0:
2984
+ data["seeding_time_limit"] = None
2985
+ if not _l2:
2986
+ data["ratio_limit"] = None
2987
+ elif _l2 < 0:
2988
+ data["ratio_limit"] = None
2989
+
2990
+ if any(v is not None for v in data.values()):
2991
+ if any(v is not None for v in data.values()):
2992
+ if data:
2993
+ with contextlib.suppress(Exception):
2994
+ torrent.set_share_limits(**data)
2995
+ if (
2996
+ r := most_important_tracker.get(
2997
+ "DownloadRateLimit", self.seeding_mode_global_download_limit
2998
+ )
2999
+ != 0
3000
+ and torrent.dl_limit != r
3001
+ ):
3002
+ torrent.set_download_limit(limit=r)
3003
+ elif r < 0:
3004
+ torrent.set_upload_limit(limit=-1)
3005
+ if (
3006
+ r := most_important_tracker.get(
3007
+ "UploadRateLimit", self.seeding_mode_global_upload_limit
3008
+ )
3009
+ != 0
3010
+ and torrent.up_limit != r
3011
+ ):
3012
+ torrent.set_upload_limit(limit=r)
3013
+ elif r < 0:
3014
+ torrent.set_upload_limit(limit=-1)
3015
+ if (
3016
+ r := most_important_tracker.get("SuperSeedMode", False)
3017
+ and torrent.super_seeding != r
3018
+ ):
3019
+ torrent.set_super_seeding(enabled=r)
3020
+
3021
+ else:
3022
+ data = {
3023
+ "ratio_limit": r if (r := self.seeding_mode_global_max_upload_ratio) > 0 else None,
3024
+ "seeding_time_limit": r
3025
+ if (r := self.seeding_mode_global_max_seeding_time) > 0
3026
+ else None,
3027
+ }
3028
+ if any(r is not None for r in data):
3029
+ if (
3030
+ (_l1 := data.get("seeding_time_limit"))
3031
+ and _l1 > 0
3032
+ and torrent.seeding_time_limit != data.get("seeding_time_limit")
3033
+ ):
3034
+ data.pop("seeding_time_limit")
3035
+ if (
3036
+ (_l2 := data.get("ratio_limit"))
3037
+ and _l2 > 0
3038
+ and torrent.seeding_time_limit != data.get("ratio_limit")
3039
+ ):
3040
+ data.pop("ratio_limit")
3041
+ if not _l1:
3042
+ data["seeding_time_limit"] = None
3043
+ elif _l1 < 0:
3044
+ data["seeding_time_limit"] = None
3045
+ if not _l2:
3046
+ data["ratio_limit"] = None
3047
+ elif _l2 < 0:
3048
+ data["ratio_limit"] = None
3049
+ if any(v is not None for v in data.values()):
3050
+ if data:
3051
+ with contextlib.suppress(Exception):
3052
+ torrent.set_share_limits(**data)
3053
+
3054
+ if r := self.seeding_mode_global_download_limit != 0 and torrent.dl_limit != r:
3055
+ torrent.set_download_limit(limit=r)
3056
+ elif r < 0:
3057
+ torrent.set_download_limit(limit=-1)
3058
+ if r := self.seeding_mode_global_upload_limit != 0 and torrent.up_limit != r:
3059
+ torrent.set_upload_limit(limit=r)
3060
+ elif r < 0:
3061
+ torrent.set_upload_limit(limit=-1)
3062
+
3063
+ if unique_tags:
3064
+ current_tags = set(torrent.tags.split(", "))
3065
+ add_tags = unique_tags.difference(current_tags)
3066
+ if add_tags:
3067
+ torrent.add_tags(add_tags)
3068
+
3069
+ def _process_single_torrent(self, torrent: qbittorrentapi.TorrentDictionary):
3070
+ if torrent.category != RECHECK_CATEGORY:
3071
+ self.manager.qbit_manager.cache[torrent.hash] = torrent.category
3072
+ self._process_single_torrent_trackers(torrent)
3073
+ self.manager.qbit_manager.name_cache[torrent.hash] = torrent.name
3074
+ time_now = time.time()
3075
+ try:
3076
+ leave_alone, _tracker_max_eta = self._should_leave_alone(torrent)
3077
+ except BaseException as e:
3078
+ self.logger.warning(e)
3079
+ raise DelayLoopException(length=300, type="qbit")
3080
+ self.logger.trace(
3081
+ "Torrent [%s]: Leave Alone (allow seeding): %s, Max ETA: %s",
3082
+ torrent.name,
3083
+ leave_alone,
3084
+ _tracker_max_eta,
3085
+ )
3086
+ maximum_eta = _tracker_max_eta
3087
+ if self.remove_torrent(torrent) and torrent.amount_left == 0:
3088
+ self._process_single_torrent_delete_ratio_seed(torrent)
3089
+ elif torrent.category == FAILED_CATEGORY:
3090
+ # Bypass everything if manually marked as failed
3091
+ self._process_single_torrent_failed_cat(torrent)
3092
+ elif torrent.category == RECHECK_CATEGORY:
3093
+ # Bypass everything else if manually marked for rechecking
3094
+ self._process_single_torrent_recheck_cat(torrent)
3095
+ elif self.is_ignored_state(torrent):
3096
+ self._process_single_torrent_ignored(torrent)
3097
+ return # Since to torrent is being ignored early exit here
3098
+ elif (
3099
+ torrent.state_enum.is_downloading
3100
+ and torrent.state_enum != TorrentStates.METADATA_DOWNLOAD
3101
+ and torrent.hash not in self.special_casing_file_check
3102
+ and torrent.hash not in self.cleaned_torrents
3103
+ ):
3104
+ self._process_single_torrent_process_files(torrent, True)
3105
+ elif torrent.hash in self.timed_ignore_cache:
3106
+ # Do not touch torrents recently resumed/reached (A torrent can temporarily
3107
+ # stall after being resumed from a paused state).
3108
+ self._process_single_torrent_added_to_ignore_cache(torrent)
3109
+ return
3110
+ elif torrent.state_enum == TorrentStates.QUEUED_UPLOAD:
3111
+ self._process_single_torrent_queued_upload(torrent, leave_alone)
3112
+ elif torrent.state_enum in (
3113
+ TorrentStates.METADATA_DOWNLOAD,
3114
+ TorrentStates.STALLED_DOWNLOAD,
3115
+ ):
3116
+ self._process_single_torrent_stalled_torrent(torrent, "Stalled State")
3117
+ elif (
3118
+ torrent.progress >= self.maximum_deletable_percentage
3119
+ and self.is_complete_state(torrent) is False
3120
+ ) and torrent.hash in self.cleaned_torrents:
3121
+ self._process_single_torrent_percentage_threshold(torrent, maximum_eta)
3122
+ # Resume monitored downloads which have been paused.
3123
+ elif torrent.state_enum == TorrentStates.PAUSED_DOWNLOAD and torrent.amount_left != 0:
3124
+ self._process_single_torrent_paused(torrent)
3125
+ # Ignore torrents which have been submitted to their respective Arr
3126
+ # instance for import.
3127
+ elif (
3128
+ torrent.hash in self.manager.managed_objects[torrent.category].sent_to_scan_hashes
3129
+ ) and torrent.hash in self.cleaned_torrents:
3130
+ self._process_single_torrent_already_sent_to_scan(torrent)
3131
+ return
3132
+ # Sometimes torrents will error, this causes them to be rechecked so they
3133
+ # complete downloading.
3134
+ elif torrent.state_enum == TorrentStates.ERROR:
3135
+ self._process_single_torrent_errored(torrent)
3136
+ # If a torrent was not just added,
3137
+ # and the amount left to download is 0 and the torrent
3138
+ # is Paused tell the Arr tools to process it.
3139
+ elif (
3140
+ torrent.added_on > 0
3141
+ and torrent.completion_on
3142
+ and torrent.amount_left == 0
3143
+ and torrent.state_enum != TorrentStates.PAUSED_UPLOAD
3144
+ and self.is_complete_state(torrent)
3145
+ and torrent.content_path
3146
+ and torrent.completion_on < time_now - 60
3147
+ ):
3148
+ self._process_single_torrent_fully_completed_torrent(torrent, leave_alone)
3149
+ elif torrent.state_enum == TorrentStates.MISSING_FILES:
3150
+ self._process_single_torrent_missing_files(torrent)
3151
+ # If a torrent is Uploading Pause it, as long as its for being Forced Uploaded.
3152
+ elif (
3153
+ self.is_uploading_state(torrent)
3154
+ and torrent.seeding_time > 1
3155
+ and torrent.amount_left == 0
3156
+ and torrent.added_on > 0
3157
+ and torrent.content_path
3158
+ and torrent.amount_left == 0
3159
+ ) and torrent.hash in self.cleaned_torrents:
3160
+ self._process_single_torrent_uploading(torrent, leave_alone)
3161
+ # Mark a torrent for deletion
3162
+ elif (
3163
+ torrent.state_enum != TorrentStates.PAUSED_DOWNLOAD
3164
+ and torrent.state_enum.is_downloading
3165
+ and self.recently_queue.get(torrent.hash, torrent.added_on)
3166
+ < time_now - self.ignore_torrents_younger_than
3167
+ and 0 < maximum_eta < torrent.eta
3168
+ and not self.do_not_remove_slow
3169
+ ):
3170
+ self._process_single_torrent_delete_slow(torrent)
3171
+ # Process uncompleted torrents
3172
+ elif torrent.state_enum.is_downloading:
3173
+ # If a torrent availability hasn't reached 100% or more within the configurable
3174
+ # "IgnoreTorrentsYoungerThan" variable, mark it for deletion.
3175
+ if (
3176
+ self.recently_queue.get(torrent.hash, torrent.added_on)
3177
+ < time_now - self.ignore_torrents_younger_than
3178
+ and torrent.availability < 1
3179
+ ) and torrent.hash in self.cleaned_torrents:
3180
+ self._process_single_torrent_stalled_torrent(torrent, "Unavailable")
3181
+ else:
3182
+ if torrent.hash in self.cleaned_torrents:
3183
+ self._process_single_torrent_already_cleaned_up(torrent)
3184
+ return
3185
+ # A downloading torrent is not stalled, parse its contents.
3186
+ self._process_single_torrent_process_files(torrent)
3187
+ else:
3188
+ self._process_single_torrent_unprocessed(torrent)
3189
+
3190
+ def remove_torrent(self, torrent: qbittorrentapi.TorrentDictionary):
3191
+ if (
3192
+ self.seeding_mode_global_max_seeding_time > 0
3193
+ and self.seeding_mode_global_max_upload_ratio > 0
3194
+ ):
3195
+ if (
3196
+ self.seeding_mode_global_remove_torrent == 4
3197
+ and torrent.ratio >= self.seeding_mode_global_max_upload_ratio
3198
+ and torrent.seeding_time >= self.seeding_mode_global_max_seeding_time
3199
+ ):
3200
+ return True
3201
+ elif (
3202
+ self.seeding_mode_global_max_seeding_time > 0
3203
+ or self.seeding_mode_global_max_upload_ratio > 0
3204
+ ):
3205
+ if self.seeding_mode_global_remove_torrent == 3 and (
3206
+ torrent.ratio >= self.seeding_mode_global_max_upload_ratio
3207
+ or torrent.seeding_time >= self.seeding_mode_global_max_seeding_time
3208
+ ):
3209
+ return True
3210
+ elif (
3211
+ self.seeding_mode_global_remove_torrent == 2
3212
+ and torrent.seeding_time >= self.seeding_mode_global_max_seeding_time
3213
+ ):
3214
+ return True
3215
+ elif (
3216
+ self.seeding_mode_global_remove_torrent == 1
3217
+ and torrent.ratio >= self.seeding_mode_global_max_upload_ratio
3218
+ ):
3219
+ return True
3220
+ else:
3221
+ return False
3222
+ else:
3223
+ return False
3224
+
3225
+ def refresh_download_queue(self):
3226
+ if self.type == "sonarr":
3227
+ self.queue = self.get_queue()
3228
+ elif self.type == "radarr":
3229
+ self.queue = self.get_queue()
3230
+ self.cache = {
3231
+ entry["downloadId"]: entry["id"] for entry in self.queue if entry.get("downloadId")
3232
+ }
3233
+ if self.type == "sonarr":
3234
+ self.requeue_cache = defaultdict(set)
3235
+ for entry in self.queue:
3236
+ if r := entry.get("episodeId"):
3237
+ self.requeue_cache[entry["id"]].add(r)
3238
+ self.queue_file_ids = {
3239
+ entry["episodeId"] for entry in self.queue if entry.get("episodeId")
3240
+ }
3241
+ elif self.type == "radarr":
3242
+ self.requeue_cache = {
3243
+ entry["id"]: entry["movieId"] for entry in self.queue if entry.get("movieId")
3244
+ }
3245
+ self.queue_file_ids = {
3246
+ entry["movieId"] for entry in self.queue if entry.get("movieId")
3247
+ }
3248
+ self._update_bad_queue_items()
3249
+
3250
+ def get_queue(
3251
+ self,
3252
+ page=1,
3253
+ page_size=10000,
3254
+ sort_direction="ascending",
3255
+ sort_key="timeLeft",
3256
+ messages: bool = True,
3257
+ ):
3258
+ params = {
3259
+ "page": page,
3260
+ "pageSize": page_size,
3261
+ "sortDirection": sort_direction,
3262
+ "sortKey": sort_key,
3263
+ }
3264
+ if messages:
3265
+ pass
3266
+ else:
3267
+ pass
3268
+ try:
3269
+ res = self.client.get_queue(
3270
+ page=1, page_size=10000, sort_key="timeLeft", sort_dir="ascending"
3271
+ )
3272
+ except requests.exceptions.ConnectionError:
3273
+ self.logger.error("Failed to get queue")
3274
+ raise DelayLoopException(length=300, type=self._name)
3275
+ try:
3276
+ res = res.get("records", [])
3277
+ except AttributeError:
3278
+ pass
3279
+ return res
3280
+
3281
+ def _update_bad_queue_items(self):
3282
+ if not self.arr_error_codes_to_blocklist:
3283
+ return
3284
+ _temp = self.get_queue()
3285
+ _temp = filter(
3286
+ lambda x: x.get("status") == "completed"
3287
+ and x.get("trackedDownloadState") == "importPending"
3288
+ and x.get("trackedDownloadStatus") == "warning",
3289
+ _temp,
3290
+ )
3291
+ _path_filter = set()
3292
+ _temp = list(_temp)
3293
+ for entry in _temp:
3294
+ messages = entry.get("statusMessages", [])
3295
+ output_path = entry.get("outputPath")
3296
+ for m in messages:
3297
+ title = m.get("title")
3298
+ if not title:
3299
+ continue
3300
+ for _m in m.get("messages", []):
3301
+ if _m in self.arr_error_codes_to_blocklist:
3302
+ e = entry.get("downloadId")
3303
+ _path_filter.add((e, pathlib.Path(output_path).joinpath(title)))
3304
+ # self.downloads_with_bad_error_message_blocklist.add(e)
3305
+ if len(_path_filter):
3306
+ self.needs_cleanup = True
3307
+ self.files_to_explicitly_delete = iter(_path_filter.copy())
3308
+
3309
+ def force_grab(self):
3310
+ # return # TODO: This may not be needed, pending more testing before it is enabled
3311
+ _temp = self.get_queue()
3312
+ _temp = filter(
3313
+ lambda x: x.get("status") == "delay",
3314
+ _temp,
3315
+ )
3316
+ ids = set()
3317
+ for entry in _temp:
3318
+ if id_ := entry.get("id"):
3319
+ ids.add(id_)
3320
+ self.logger.notice("Attempting to force grab: %s = %s", id_, entry.get("title"))
3321
+ if ids:
3322
+ with ThreadPoolExecutor(max_workers=16) as executor:
3323
+ executor.map(self._force_grab, ids)
3324
+
3325
+ def _force_grab(self, id_):
3326
+ try:
3327
+ path = f"queue/grab/{id_}"
3328
+ res = self.client._post(path, self.client.ver_uri)
3329
+ self.logger.trace("Successful Grab: %s", id_)
3330
+ return res
3331
+ except Exception:
3332
+ self.logger.error("Exception when trying to force grab - %s", id_)
3333
+
3334
+ def register_search_mode(self):
3335
+ if self.search_setup_completed:
3336
+ return
3337
+ if self.search_missing is False:
3338
+ self.search_setup_completed = True
3339
+ return
3340
+ if not self.arr_db_file.exists():
3341
+ self.search_missing = False
3342
+ return
3343
+ else:
3344
+ self.arr_db = SqliteDatabase(None)
3345
+ self.arr_db.init(f"file:{self.arr_db_file}?mode=ro", uri=True)
3346
+ self.arr_db.connect()
3347
+
3348
+ self.db = SqliteDatabase(None)
3349
+ self.db.init(
3350
+ str(self.search_db_file),
3351
+ pragmas={
3352
+ "journal_mode": "wal",
3353
+ "cache_size": -1 * 64000, # 64MB
3354
+ "foreign_keys": 1,
3355
+ "ignore_check_constraints": 0,
3356
+ "synchronous": 0,
3357
+ },
3358
+ )
3359
+
3360
+ db1, db2, db3 = self._get_models()
3361
+
3362
+ class Files(db1):
3363
+ class Meta:
3364
+ database = self.db
3365
+
3366
+ class Queue(db2):
3367
+ class Meta:
3368
+ database = self.db
3369
+
3370
+ class PersistingQueue(FilesQueued):
3371
+ class Meta:
3372
+ database = self.db
3373
+
3374
+ self.db.connect()
3375
+ if db3:
3376
+
3377
+ class Series(db3):
3378
+ class Meta:
3379
+ database = self.db
3380
+
3381
+ self.db.create_tables([Files, Queue, PersistingQueue, Series])
3382
+ self.series_file_model = Series
3383
+ else:
3384
+ self.db.create_tables([Files, Queue, PersistingQueue])
3385
+
3386
+ self.model_file = Files
3387
+ self.model_queue = Queue
3388
+ self.persistent_queue = PersistingQueue
3389
+
3390
+ db1, db2, db3 = self._get_arr_modes()
3391
+
3392
+ class Files(db1):
3393
+ class Meta:
3394
+ database = self.arr_db
3395
+ if self.type == "sonarr":
3396
+ table_name = "Episodes"
3397
+ elif self.type == "radarr":
3398
+ table_name = "Movies"
3399
+
3400
+ class Commands(db2):
3401
+ class Meta:
3402
+ database = self.arr_db
3403
+ table_name = "Commands"
3404
+
3405
+ if self.type == "sonarr":
3406
+
3407
+ class Series(db3):
3408
+ class Meta:
3409
+ database = self.arr_db
3410
+ table_name = "Series"
3411
+
3412
+ self.model_arr_series_file = Series
3413
+
3414
+ elif self.type == "radarr":
3415
+
3416
+ class Movies(db3):
3417
+ class Meta:
3418
+ database = self.arr_db
3419
+ table_name = "MovieMetadata"
3420
+
3421
+ self.model_arr_movies_file = Movies
3422
+
3423
+ self.model_arr_file = Files
3424
+ self.model_arr_command = Commands
3425
+ self.search_setup_completed = True
3426
+
3427
+ def run_request_search(self):
3428
+ if self.request_search_timer is None or (
3429
+ self.request_search_timer > time.time() - self.search_requests_every_x_seconds
3430
+ ):
3431
+ return None
3432
+ self.register_search_mode()
3433
+ if not self.search_missing:
3434
+ return None
3435
+ self.logger.notice("Starting Request search")
3436
+
3437
+ while True:
3438
+ try:
3439
+ self.db_request_update()
3440
+ try:
3441
+ for entry in self.db_get_request_files():
3442
+ while self.maybe_do_search(entry, request=True) is False:
3443
+ time.sleep(30)
3444
+ self.request_search_timer = time.time()
3445
+ return
3446
+ except NoConnectionrException as e:
3447
+ self.logger.error(e.message)
3448
+ raise DelayLoopException(length=300, type=e.type)
3449
+ except DelayLoopException:
3450
+ raise
3451
+ except Exception as e:
3452
+ self.logger.exception(e, exc_info=sys.exc_info())
3453
+ time.sleep(LOOP_SLEEP_TIMER)
3454
+ except DelayLoopException as e:
3455
+ if e.type == "qbit":
3456
+ self.logger.critical(
3457
+ "Failed to connected to qBit client, sleeping for %s",
3458
+ timedelta(seconds=e.length),
3459
+ )
3460
+ elif e.type == "internet":
3461
+ self.logger.critical(
3462
+ "Failed to connected to the internet, sleeping for %s",
3463
+ timedelta(seconds=e.length),
3464
+ )
3465
+ elif e.type == "arr":
3466
+ self.logger.critical(
3467
+ "Failed to connected to the Arr instance, sleeping for %s",
3468
+ timedelta(seconds=e.length),
3469
+ )
3470
+ elif e.type == "delay":
3471
+ self.logger.critical(
3472
+ "Forced delay due to temporary issue with environment, sleeping for %s",
3473
+ timedelta(seconds=e.length),
3474
+ )
3475
+ elif e.type == "no_downloads":
3476
+ self.logger.debug(
3477
+ "No downloads in category, sleeping for %s",
3478
+ timedelta(seconds=e.length),
3479
+ )
3480
+ time.sleep(e.length)
3481
+
3482
+ def get_year_search(self) -> tuple[list[int], int]:
3483
+ with self.db.atomic():
3484
+ if self.type == "radarr":
3485
+ if self.search_in_reverse:
3486
+ years_query = (
3487
+ self.model_arr_movies_file.select(
3488
+ self.model_arr_movies_file.Year.distinct()
3489
+ )
3490
+ .where(
3491
+ self.model_arr_movies_file.Year
3492
+ <= datetime.now().year & self.model_arr_movies_file.Year
3493
+ != 0
3494
+ )
3495
+ .order_by(self.model_arr_movies_file.Year.asc())
3496
+ .execute()
3497
+ )
3498
+ else:
3499
+ years_query = (
3500
+ self.model_arr_movies_file.select(
3501
+ self.model_arr_movies_file.Year.distinct()
3502
+ )
3503
+ .where(
3504
+ self.model_arr_movies_file.Year
3505
+ <= datetime.now().year & self.model_arr_movies_file.Year
3506
+ != 0
3507
+ )
3508
+ .order_by(self.model_arr_movies_file.Year.desc())
3509
+ .execute()
3510
+ )
3511
+ years = [y.Year for y in years_query]
3512
+ self.logger.trace("Years: %s", years)
3513
+ years_count = len(years)
3514
+ elif self.type == "sonarr":
3515
+ if self.search_in_reverse:
3516
+ years_query = (
3517
+ self.model_arr_file.select(
3518
+ fn.Substr(self.model_arr_file.AirDate, 1, 4).distinct().alias("Year")
3519
+ )
3520
+ .where(fn.Substr(self.model_arr_file.AirDate, 1, 4) <= datetime.now().year)
3521
+ .order_by(fn.Substr(self.model_arr_file.AirDate, 1, 4).asc())
3522
+ .execute()
3523
+ )
3524
+ else:
3525
+ years_query = (
3526
+ self.model_arr_file.select(
3527
+ fn.Substr(self.model_arr_file.AirDate, 1, 4).distinct().alias("Year")
3528
+ )
3529
+ .where(fn.Substr(self.model_arr_file.AirDate, 1, 4) <= datetime.now().year)
3530
+ .order_by(fn.Substr(self.model_arr_file.AirDate, 1, 4).desc())
3531
+ .execute()
3532
+ )
3533
+ years = [y.Year for y in years_query]
3534
+ self.logger.trace("Years: %s", years)
3535
+ years_count = len(years)
3536
+ self.logger.trace("Years count: %s, Years: %s", years_count, years)
3537
+ return years, years_count
3538
+
3539
+ def run_search_loop(self) -> NoReturn:
3540
+ run_logs(self.logger)
3541
+ try:
3542
+ self.register_search_mode()
3543
+ if not self.search_missing:
3544
+ return None
3545
+ loop_timer = timedelta(minutes=15)
3546
+ years_index = 0
3547
+ while True:
3548
+ if self.search_by_year and years_index == 0:
3549
+ years, years_count = self.get_year_search()
3550
+ try:
3551
+ self.search_current_year = years[years_index]
3552
+ except BaseException:
3553
+ self.search_current_year = years[: years_index + 1]
3554
+ timer = datetime.now(timezone.utc)
3555
+ try:
3556
+ self.db_maybe_reset_entry_searched_state()
3557
+ self.db_update()
3558
+ self.run_request_search()
3559
+ self.force_grab()
3560
+ try:
3561
+ for entry, todays, limit_bypass, series_search in self.db_get_files():
3562
+ if timer < (datetime.now(timezone.utc) - loop_timer):
3563
+ self.refresh_download_queue()
3564
+ self.force_grab()
3565
+ raise RestartLoopException
3566
+ while (
3567
+ self.maybe_do_search(
3568
+ entry,
3569
+ todays=todays,
3570
+ bypass_limit=limit_bypass,
3571
+ series_search=series_search,
3572
+ )
3573
+ is False
3574
+ ):
3575
+ time.sleep(30)
3576
+ if self.search_by_year:
3577
+ if years.index(self.search_current_year) != years_count - 1:
3578
+ years_index += 1
3579
+ self.search_current_year = years[years_index]
3580
+ else:
3581
+ years_index = 0
3582
+ self.loop_completed = True
3583
+ else:
3584
+ self.loop_completed = True
3585
+ except RestartLoopException:
3586
+ self.logger.debug("Loop timer elapsed, restarting it.")
3587
+ except NoConnectionrException as e:
3588
+ self.logger.error(e.message)
3589
+ self.manager.qbit_manager.should_delay_torrent_scan = True
3590
+ raise DelayLoopException(length=300, type=e.type)
3591
+ except DelayLoopException:
3592
+ raise
3593
+ except ValueError:
3594
+ self.logger.debug("Loop completed, restarting it.")
3595
+ self.loop_completed = True
3596
+ except qbittorrentapi.exceptions.APIConnectionError as e:
3597
+ self.logger.warning(e)
3598
+ raise DelayLoopException(length=300, type="qbit")
3599
+ except Exception as e:
3600
+ self.logger.exception(e, exc_info=sys.exc_info())
3601
+ time.sleep(LOOP_SLEEP_TIMER)
3602
+ except DelayLoopException as e:
3603
+ if e.type == "qbit":
3604
+ self.logger.critical(
3605
+ "Failed to connected to qBit client, sleeping for %s",
3606
+ timedelta(seconds=e.length),
3607
+ )
3608
+ elif e.type == "internet":
3609
+ self.logger.critical(
3610
+ "Failed to connected to the internet, sleeping for %s",
3611
+ timedelta(seconds=e.length),
3612
+ )
3613
+ elif e.type == "arr":
3614
+ self.logger.critical(
3615
+ "Failed to connected to the Arr instance, sleeping for %s",
3616
+ timedelta(seconds=e.length),
3617
+ )
3618
+ elif e.type == "delay":
3619
+ self.logger.critical(
3620
+ "Forced delay due to temporary issue with environment, "
3621
+ "sleeping for %s",
3622
+ timedelta(seconds=e.length),
3623
+ )
3624
+ time.sleep(e.length)
3625
+ self.manager.qbit_manager.should_delay_torrent_scan = False
3626
+ except KeyboardInterrupt:
3627
+ self.logger.hnotice("Detected Ctrl+C - Terminating process")
3628
+ sys.exit(0)
3629
+ else:
3630
+ time.sleep(5)
3631
+ except KeyboardInterrupt:
3632
+ self.logger.hnotice("Detected Ctrl+C - Terminating process")
3633
+ sys.exit(0)
3634
+
3635
+ def run_torrent_loop(self) -> NoReturn:
3636
+ run_logs(self.logger)
3637
+ self.logger.hnotice("Starting torrent monitoring for %s", self._name)
3638
+ while True:
3639
+ try:
3640
+ try:
3641
+ try:
3642
+ if not self.manager.qbit_manager.is_alive:
3643
+ raise NoConnectionrException(
3644
+ "Could not connect to qBit client.", type="qbit"
3645
+ )
3646
+ if not self.is_alive:
3647
+ raise NoConnectionrException(
3648
+ "Could not connect to %s" % self.uri, type="arr"
3649
+ )
3650
+ self.process_torrents()
3651
+ except NoConnectionrException as e:
3652
+ self.logger.error(e.message)
3653
+ self.manager.qbit_manager.should_delay_torrent_scan = True
3654
+ raise DelayLoopException(length=300, type="arr")
3655
+ except qbittorrentapi.exceptions.APIConnectionError as e:
3656
+ self.logger.warning(e)
3657
+ raise DelayLoopException(length=300, type="qbit")
3658
+ except qbittorrentapi.exceptions.APIError as e:
3659
+ self.logger.warning(e)
3660
+ raise DelayLoopException(length=300, type="qbit")
3661
+ except DelayLoopException:
3662
+ raise
3663
+ except KeyboardInterrupt:
3664
+ self.logger.hnotice("Detected Ctrl+C - Terminating process")
3665
+ sys.exit(0)
3666
+ except Exception as e:
3667
+ self.logger.error(e, exc_info=sys.exc_info())
3668
+ time.sleep(LOOP_SLEEP_TIMER)
3669
+ except DelayLoopException as e:
3670
+ if e.type == "qbit":
3671
+ self.logger.critical(
3672
+ "Failed to connected to qBit client, sleeping for %s",
3673
+ timedelta(seconds=e.length),
3674
+ )
3675
+ elif e.type == "internet":
3676
+ self.logger.critical(
3677
+ "Failed to connected to the internet, sleeping for %s",
3678
+ timedelta(seconds=e.length),
3679
+ )
3680
+ elif e.type == "arr":
3681
+ self.logger.critical(
3682
+ "Failed to connected to the Arr instance, sleeping for %s",
3683
+ timedelta(seconds=e.length),
3684
+ )
3685
+ elif e.type == "delay":
3686
+ self.logger.critical(
3687
+ "Forced delay due to temporary issue with environment, "
3688
+ "sleeping for %s.",
3689
+ timedelta(seconds=e.length),
3690
+ )
3691
+ elif e.type == "no_downloads":
3692
+ self.logger.debug(
3693
+ "No downloads in category, sleeping for %s",
3694
+ timedelta(seconds=e.length),
3695
+ )
3696
+ time.sleep(e.length)
3697
+ self.manager.qbit_manager.should_delay_torrent_scan = False
3698
+ except KeyboardInterrupt:
3699
+ self.logger.hnotice("Detected Ctrl+C - Terminating process")
3700
+ sys.exit(0)
3701
+ except KeyboardInterrupt:
3702
+ self.logger.hnotice("Detected Ctrl+C - Terminating process")
3703
+ sys.exit(0)
3704
+
3705
+ def spawn_child_processes(self):
3706
+ _temp = []
3707
+ if self.search_missing:
3708
+ self.process_search_loop = pathos.helpers.mp.Process(
3709
+ target=self.run_search_loop, daemon=True
3710
+ )
3711
+ self.manager.qbit_manager.child_processes.append(self.process_search_loop)
3712
+ _temp.append(self.process_search_loop)
3713
+ if not any([QBIT_DISABLED, SEARCH_ONLY]):
3714
+ self.process_torrent_loop = pathos.helpers.mp.Process(
3715
+ target=self.run_torrent_loop, daemon=True
3716
+ )
3717
+ self.manager.qbit_manager.child_processes.append(self.process_torrent_loop)
3718
+ _temp.append(self.process_torrent_loop)
3719
+
3720
+ return len(_temp), _temp
3721
+
3722
+
3723
+ class PlaceHolderArr(Arr):
3724
+ def __init__(
3725
+ self,
3726
+ name: str,
3727
+ manager: ArrManager,
3728
+ ):
3729
+ if name in manager.groups:
3730
+ raise OSError("Group '{name}' has already been registered.")
3731
+ self._name = name.title()
3732
+ self.category = name
3733
+ self.manager = manager
3734
+ self.queue = []
3735
+ self.cache = {}
3736
+ self.requeue_cache = {}
3737
+ self.recently_queue = {}
3738
+ self.sent_to_scan = set()
3739
+ self.sent_to_scan_hashes = set()
3740
+ self.files_probed = set()
3741
+ self.import_torrents = []
3742
+ self.change_priority = dict()
3743
+ self.recheck = set()
3744
+ self.pause = set()
3745
+ self.skip_blacklist = set()
3746
+ self.remove_from_qbit = set()
3747
+ self.delete = set()
3748
+ self.resume = set()
3749
+ self.expiring_bool = ExpiringSet(max_age_seconds=10)
3750
+ self.ignore_torrents_younger_than = CONFIG.get(
3751
+ "Settings.IgnoreTorrentsYoungerThan", fallback=600
3752
+ )
3753
+ self.timed_ignore_cache = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than)
3754
+ self.timed_skip = ExpiringSet(max_age_seconds=self.ignore_torrents_younger_than)
3755
+ self.tracker_delay = ExpiringSet(max_age_seconds=600)
3756
+ self._LOG_LEVEL = self.manager.qbit_manager.logger.level
3757
+ self.logger = logging.getLogger(f"qBitrr.{self._name}")
3758
+ run_logs(self.logger)
3759
+ self.search_missing = False
3760
+ self.session = None
3761
+ self.logger.hnotice("Starting %s monitor", self._name)
3762
+
3763
+ def _process_errored(self):
3764
+ # Recheck all torrents marked for rechecking.
3765
+ if self.recheck:
3766
+ temp = defaultdict(list)
3767
+ updated_recheck = []
3768
+ for h in self.recheck:
3769
+ updated_recheck.append(h)
3770
+ if c := self.manager.qbit_manager.cache.get(h):
3771
+ temp[c].append(h)
3772
+ self.manager.qbit.torrents_recheck(torrent_hashes=updated_recheck)
3773
+ for k, v in temp.items():
3774
+ self.manager.qbit.torrents_set_category(torrent_hashes=v, category=k)
3775
+
3776
+ for k in updated_recheck:
3777
+ self.timed_ignore_cache.add(k)
3778
+ self.recheck.clear()
3779
+
3780
+ def _process_failed(self):
3781
+ if not (self.delete or self.skip_blacklist):
3782
+ return
3783
+ to_delete_all = self.delete
3784
+ skip_blacklist = {i.upper() for i in self.skip_blacklist}
3785
+ if to_delete_all:
3786
+ for arr in self.manager.managed_objects.values():
3787
+ payload, hashes = arr.process_entries(to_delete_all)
3788
+ if payload:
3789
+ for entry, hash_ in payload:
3790
+ if hash_ in arr.cache:
3791
+ arr._process_failed_individual(
3792
+ hash_=hash_, entry=entry, skip_blacklist=skip_blacklist
3793
+ )
3794
+ if self.remove_from_qbit or self.skip_blacklist or to_delete_all:
3795
+ # Remove all bad torrents from the Client.
3796
+ temp_to_delete = set()
3797
+ if to_delete_all:
3798
+ self.manager.qbit.torrents_delete(hashes=to_delete_all, delete_files=True)
3799
+ if self.remove_from_qbit or self.skip_blacklist:
3800
+ temp_to_delete = self.remove_from_qbit.union(self.skip_blacklist)
3801
+ self.manager.qbit.torrents_delete(hashes=temp_to_delete, delete_files=True)
3802
+ to_delete_all = to_delete_all.union(temp_to_delete)
3803
+ for h in to_delete_all:
3804
+ if h in self.manager.qbit_manager.name_cache:
3805
+ del self.manager.qbit_manager.name_cache[h]
3806
+ if h in self.manager.qbit_manager.cache:
3807
+ del self.manager.qbit_manager.cache[h]
3808
+ self.skip_blacklist.clear()
3809
+ self.remove_from_qbit.clear()
3810
+ self.delete.clear()
3811
+
3812
+ def process(self):
3813
+ self._process_errored()
3814
+ self._process_failed()
3815
+
3816
+ def process_torrents(self):
3817
+ try:
3818
+ try:
3819
+ torrents = self.manager.qbit_manager.client.torrents.info(
3820
+ status_filter="all", category=self.category, sort="added_on", reverse=False
3821
+ )
3822
+ torrents = [t for t in torrents if hasattr(t, "category")]
3823
+ if not len(torrents):
3824
+ raise DelayLoopException(length=5, type="no_downloads")
3825
+ if has_internet() is False:
3826
+ self.manager.qbit_manager.should_delay_torrent_scan = True
3827
+ raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="internet")
3828
+ if self.manager.qbit_manager.should_delay_torrent_scan:
3829
+ raise DelayLoopException(length=NO_INTERNET_SLEEP_TIMER, type="delay")
3830
+ for torrent in torrents:
3831
+ if torrent.category != RECHECK_CATEGORY:
3832
+ self.manager.qbit_manager.cache[torrent.hash] = torrent.category
3833
+ self.manager.qbit_manager.name_cache[torrent.hash] = torrent.name
3834
+ if torrent.category == FAILED_CATEGORY:
3835
+ # Bypass everything if manually marked as failed
3836
+ self._process_single_torrent_failed_cat(torrent)
3837
+ elif torrent.category == RECHECK_CATEGORY:
3838
+ # Bypass everything else if manually marked for rechecking
3839
+ self._process_single_torrent_recheck_cat(torrent)
3840
+ self.process()
3841
+ except NoConnectionrException as e:
3842
+ self.logger.error(e.message)
3843
+ except qbittorrentapi.exceptions.APIError as e:
3844
+ self.logger.error("The qBittorrent API returned an unexpected error")
3845
+ self.logger.debug("Unexpected APIError from qBitTorrent", exc_info=e)
3846
+ raise DelayLoopException(length=300, type="qbit")
3847
+ except qbittorrentapi.exceptions.APIConnectionError as e:
3848
+ self.logger.warning("Max retries exceeded")
3849
+ raise DelayLoopException(length=300, type="qbit")
3850
+ except DelayLoopException:
3851
+ raise
3852
+ except KeyboardInterrupt:
3853
+ self.logger.hnotice("Detected Ctrl+C - Terminating process")
3854
+ sys.exit(0)
3855
+ except Exception as e:
3856
+ self.logger.error(e, exc_info=sys.exc_info())
3857
+ except KeyboardInterrupt:
3858
+ self.logger.hnotice("Detected Ctrl+C - Terminating process")
3859
+ sys.exit(0)
3860
+ except DelayLoopException:
3861
+ raise
3862
+
3863
+ def run_search_loop(self):
3864
+ return
3865
+
3866
+
3867
+ class ArrManager:
3868
+ def __init__(self, qbitmanager: qBitManager):
3869
+ self.groups: set[str] = set()
3870
+ self.uris: set[str] = set()
3871
+ self.special_categories: set[str] = {FAILED_CATEGORY, RECHECK_CATEGORY}
3872
+ self.category_allowlist: set[str] = self.special_categories.copy()
3873
+ self.completed_folders: set[pathlib.Path] = set()
3874
+ self.managed_objects: dict[str, Arr] = {}
3875
+ self.qbit: qbittorrentapi.Client = qbitmanager.client
3876
+ self.qbit_manager: qBitManager = qbitmanager
3877
+ self.ffprobe_available: bool = self.qbit_manager.ffprobe_downloader.probe_path.exists()
3878
+ self.logger = logging.getLogger(
3879
+ "qBitrr.ArrManager",
3880
+ )
3881
+ run_logs(self.logger)
3882
+ if not self.ffprobe_available:
3883
+ if not any([QBIT_DISABLED, SEARCH_ONLY]):
3884
+ self.logger.error(
3885
+ "'%s' was not found, disabling all functionality dependant on it",
3886
+ self.qbit_manager.ffprobe_downloader.probe_path,
3887
+ )
3888
+
3889
+ def build_arr_instances(self):
3890
+ for key in CONFIG.sections():
3891
+ if search := re.match("(rad|son|anim)arr.*", key, re.IGNORECASE):
3892
+ name = search.group(0)
3893
+ match = search.group(1)
3894
+ if match.lower() == "son":
3895
+ call_cls = SonarrAPI
3896
+ elif match.lower() == "anim":
3897
+ call_cls = SonarrAPI
3898
+ elif match.lower() == "rad":
3899
+ call_cls = RadarrAPI
3900
+ else:
3901
+ call_cls = None
3902
+ try:
3903
+ managed_object = Arr(name, self, client_cls=call_cls)
3904
+ self.groups.add(name)
3905
+ self.uris.add(managed_object.uri)
3906
+ self.managed_objects[managed_object.category] = managed_object
3907
+ except KeyError as e:
3908
+ self.logger.critical(e)
3909
+ except ValueError as e:
3910
+ self.logger.exception(e)
3911
+ except SkipException:
3912
+ continue
3913
+ except (OSError, TypeError) as e:
3914
+ self.logger.exception(e)
3915
+ for cat in self.special_categories:
3916
+ managed_object = PlaceHolderArr(cat, self)
3917
+ self.managed_objects[cat] = managed_object
3918
+ return self