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/__init__.py +15 -0
- qBitrr/arr_tables.py +138 -0
- qBitrr/arss.py +3918 -0
- qBitrr/bundled_data.py +6 -0
- qBitrr/config.py +172 -0
- qBitrr/env_config.py +60 -0
- qBitrr/errors.py +41 -0
- qBitrr/ffprobe.py +105 -0
- qBitrr/gen_config.py +773 -0
- qBitrr/home_path.py +23 -0
- qBitrr/logger.py +146 -0
- qBitrr/main.py +204 -0
- qBitrr/tables.py +52 -0
- qBitrr/utils.py +192 -0
- qBitrr2-3.7.1.dist-info/LICENSE +21 -0
- qBitrr2-3.7.1.dist-info/METADATA +224 -0
- qBitrr2-3.7.1.dist-info/RECORD +20 -0
- qBitrr2-3.7.1.dist-info/WHEEL +5 -0
- qBitrr2-3.7.1.dist-info/entry_points.txt +2 -0
- qBitrr2-3.7.1.dist-info/top_level.txt +1 -0
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
|