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