Skip to content

Commit 5050cc4

Browse files
authored
Merge pull request ManiMatter#366 from jrhager84/startup-degraded-mode
Degrade unreachable instances at startup instead of exiting the whole app
2 parents 852cea5 + cf225b8 commit 5050cc4

22 files changed

Lines changed: 886 additions & 75 deletions

main.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from src.job_manager import JobManager
99
from src.settings.settings import Settings
1010
from src.utils.log_setup import logger
11-
from src.utils.startup import launch_steps
11+
from src.utils.startup import launch_steps, retry_degraded_instances
1212

1313
settings = Settings()
1414
job_manager = JobManager(settings)
@@ -53,13 +53,18 @@ async def main():
5353
await launch_steps(settings)
5454

5555
if settings.jobs.detect_deletions.enabled:
56-
await WatcherManager(settings).setup()
56+
await watch_manager.setup()
5757
# Start Cleaning
5858
while True:
5959
logger.info("-" * 50)
6060

61+
# Give degraded instances a chance to rejoin before this cycle's jobs
62+
await retry_degraded_instances(settings, watch_manager)
63+
6164
# Refresh qBit Cookies (SABnzbd doesn't need cookie refresh)
6265
for qbit in settings.download_clients.qbittorrent:
66+
if not qbit.ready:
67+
continue
6368
try:
6469
await qbit.refresh_cookie()
6570
except Exception as err: # noqa: BLE001
@@ -70,6 +75,8 @@ async def main():
7075

7176
# Run script for each instance
7277
for arr in settings.instances:
78+
if not arr.ready: # skip was already logged by retry_degraded_instances
79+
continue
7380
await job_manager.run_jobs(arr)
7481
logger.verbose("")
7582

src/deletion_handler/deletion_handler.py

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -99,35 +99,52 @@ async def setup(self):
9999
for arr, folder_path in folders_to_watch:
100100
self.set_watcher(arr, folder_path)
101101

102+
async def setup_for_arr(self, arr):
103+
"""Set up deletion watchers for a single arr (e.g. one that rejoined after a degraded startup)."""
104+
if self.loop is None:
105+
self.loop = asyncio.get_running_loop()
106+
for folder_path in await self.get_folders_to_watch_for_arr(arr):
107+
self.set_watcher(arr, folder_path)
108+
102109
async def get_folders_to_watch(self):
103110
"""Gets from all arrs the root folders and lists those that are accessible for the arr, and have present for decluttarr."""
104111
folders_to_watch = []
105112
logger.verbose("")
106113
logger.verbose("*** Setting up monitoring for deletions ***")
107114
for arr in self.settings.instances:
108-
if arr.arr_type not in (
109-
"sonarr",
110-
"radarr",
111-
): # only working for sonarr / radarr for now
112-
continue
113-
root_folders = await arr.get_root_folders()
114-
115-
for folder in root_folders:
116-
if folder.get("accessible") and "path" in folder:
117-
path = Path(folder["path"])
118-
if path.exists():
119-
folders_to_watch.append((arr, folder["path"]))
120-
else:
121-
logger.warning(
122-
f"Job 'detect_deletions' on {arr.name} ({arr.base_url}) does not have access to this path and will not monitor it: '{path}'"
123-
)
115+
for folder_path in await self.get_folders_to_watch_for_arr(arr):
116+
folders_to_watch.append((arr, folder_path))
117+
118+
return folders_to_watch
119+
120+
async def get_folders_to_watch_for_arr(self, arr):
121+
"""Root folder paths of one arr that are accessible for both the arr and decluttarr."""
122+
folders_to_watch = []
123+
if arr.arr_type not in (
124+
"sonarr",
125+
"radarr",
126+
): # only working for sonarr / radarr for now
127+
return folders_to_watch
128+
if not arr.ready: # degraded instance; watchers are added when it rejoins
129+
return folders_to_watch
130+
root_folders = await arr.get_root_folders()
131+
132+
for folder in root_folders:
133+
if folder.get("accessible") and "path" in folder:
134+
path = Path(folder["path"])
135+
if path.exists():
136+
folders_to_watch.append(folder["path"])
137+
else:
138+
logger.warning(
139+
f"Job 'detect_deletions' on {arr.name} ({arr.base_url}) does not have access to this path and will not monitor it: '{path}'"
140+
)
141+
logger.info(
142+
">>> 💡 Tip: Make sure that the paths in decluttarr and in your arr instance are identical."
143+
)
144+
if self.settings.envs.in_docker:
124145
logger.info(
125-
">>> 💡 Tip: Make sure that the paths in decluttarr and in your arr instance are identical."
146+
">>> 💡 Tip: Make sure decluttarr and your arr instance have the same mount points"
126147
)
127-
if self.settings.envs.in_docker:
128-
logger.info(
129-
">>> 💡 Tip: Make sure decluttarr and your arr instance have the same mount points"
130-
)
131148

132149
return folders_to_watch
133150

src/job_manager.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ async def run_download_client_jobs(self):
5656
)
5757

5858
for client in download_clients:
59+
if not client.ready:
60+
continue
61+
5962
# Get jobs for this client
6063
download_client_jobs = self._get_download_client_jobs_for_client(
6164
client,
@@ -150,6 +153,8 @@ async def _download_clients_connected(self):
150153

151154
async def _check_client_connection_status(self, clients):
152155
for client in clients:
156+
if not client.ready: # never-set-up client must not veto or be polled
157+
continue
153158
logger.debug(
154159
f"job_manager.py/_check_client_connection_status: Checking if {client.name} is connected",
155160
)

src/jobs/removal_handler.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ async def _tag_as_obsolete(self, affected_download, download_id):
4444
f"Job '{self.job_name}' triggered obsolete-tagging: {affected_download['title']}"
4545
)
4646
for qbit in self.settings.download_clients.qbittorrent:
47+
if not qbit.ready:
48+
continue
4749
await qbit.set_tag(
4850
tags=[self.settings.general.obsolete_tag], hashes=[download_id]
4951
)
@@ -55,12 +57,12 @@ async def _get_handling_method(self, download_id, affected_download):
5557
download_client_name = affected_download["downloadClient"]
5658
_, download_client_type = (
5759
self.settings.download_clients.get_download_client_by_name(
58-
download_client_name
60+
download_client_name, ready_only=True
5961
)
6062
)
6163

6264
if download_client_type != "qbittorrent":
63-
return "remove" # handling is only implemented for qbit
65+
return "remove" # handling is only implemented for qbit (and only if ready)
6466

6567
if len(self.settings.download_clients.qbittorrent) == 0:
6668
return "remove" # qbit not configured, thus can't tag

src/jobs/removal_job.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ async def run(self) -> int:
5050

5151
# -- Checks --
5252
self._ignore_protected()
53+
self._ignore_degraded_client_downloads()
5354
if self.max_strikes:
5455
self.affected_downloads = self.strikes_handler.filter_strike_exceeds(
5556
self.affected_downloads, self.queue
@@ -76,6 +77,36 @@ def _ignore_protected(self):
7677
if download_id not in self.arr.tracker.protected
7778
}
7879

80+
def _ignore_degraded_client_downloads(self):
81+
"""Fail-closed: never remove a download whose configured client is degraded.
82+
83+
A degraded (not-ready) download client can't be queried for protection
84+
status (protected tag, private/public tracker), so its downloads must be
85+
left untouched rather than deleted blindly. Downloads on healthy clients,
86+
or on clients not configured in decluttarr, are unaffected.
87+
"""
88+
safe = {}
89+
skipped = 0
90+
for download_id, download in self.affected_downloads.items():
91+
# download is the grouped metadata dict from group_by_download_id.
92+
client_name = download.get("downloadClient")
93+
client = None
94+
if client_name:
95+
client, _ = self.settings.download_clients.get_download_client_by_name(
96+
client_name
97+
)
98+
if client is not None and not client.ready:
99+
skipped += 1
100+
continue
101+
safe[download_id] = download
102+
103+
if skipped:
104+
logger.warning(
105+
f">>> {self.job_name}: skipped {skipped} download(s) on {self.arr.name} "
106+
"whose download client is degraded (protection can't be verified; left untouched).",
107+
)
108+
self.affected_downloads = safe
109+
79110
@abstractmethod # Implemented on level of each removal job
80111
async def _find_affected_items(self) -> None:
81112
pass

src/jobs/remove_bad_files.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def _group_download_ids_by_client(self):
6262

6363
download_client, download_client_type = (
6464
self.settings.download_clients.get_download_client_by_name(
65-
download_client_name
65+
download_client_name, ready_only=True
6666
)
6767
)
6868
if not download_client or not download_client_type:

src/jobs/remove_slow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ async def add_download_client_to_queue_items(self):
168168
download_client_name = item["downloadClient"]
169169
download_client, download_client_type = (
170170
self.settings.download_clients.get_download_client_by_name(
171-
download_client_name
171+
download_client_name, ready_only=True
172172
)
173173
)
174174
item["download_client"] = download_client

src/settings/_download_clients.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,16 @@ def check_unique_download_client_types(self):
8181
seen.add(name.lower())
8282

8383
def get_download_client_by_name(
84-
self, name: str, download_client_type: str | None = None
84+
self,
85+
name: str,
86+
download_client_type: str | None = None,
87+
ready_only: bool = False,
8588
):
8689
"""
8790
Retrieve the download client and download client type by its name.
8891
If download_client_type is provided, search only in that type.
92+
If ready_only is True, a client that failed its startup check (not ready)
93+
is treated as unavailable, so degraded clients never get called from jobs.
8994
"""
9095
name_lower = name.lower()
9196
types_to_search = (
@@ -97,6 +102,8 @@ def get_download_client_by_name(
97102

98103
for download_client in download_clients:
99104
if download_client.name.lower() == name_lower:
105+
if ready_only and not getattr(download_client, "ready", False):
106+
return None, None
100107
return download_client, client_type
101108

102109
return None, None

src/settings/_download_clients_qbit.py

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22
from requests.cookies import RequestsCookieJar
33

44
from src.settings._constants import ApiEndpoints, MinVersions
5-
from src.utils.common import extract_json_from_response, make_request, wait_and_exit
5+
from src.utils.common import (
6+
extract_json_from_response,
7+
is_definitive_setup_error,
8+
make_request,
9+
)
610
from src.utils.log_setup import logger
711

812

913
class QbitError(Exception):
10-
pass
14+
def __init__(self, message, tip="", definitive=False):
15+
super().__init__(message)
16+
self.tip = tip
17+
self.definitive = definitive
1118

1219

1320
class QbitClients(list):
@@ -39,6 +46,10 @@ class QbitClient:
3946
cookie: dict[str, str] = None
4047
version: str = None
4148
bandwidth_usage: int = 0
49+
ready: bool = False
50+
failure_kind: str = None # None | "transient" | "definitive"
51+
last_error: str = None
52+
setup_tip: str = ""
4253

4354
def __init__(
4455
self,
@@ -160,7 +171,7 @@ async def validate_version(self):
160171
f"Please update qBittorrent to at least version {min_version}. Current version: {self.version}",
161172
)
162173
error = f"qBittorrent version {self.version} is too old. Please update."
163-
raise QbitError(error)
174+
raise QbitError(error, definitive=True)
164175
if version.parse(self.version) < version.parse("5.0.0"):
165176
logger.info(
166177
"[Tip!] Consider upgrading to qBittorrent v5.0.0 or newer to reduce network overhead.",
@@ -228,7 +239,7 @@ async def set_unwanted_folder(self):
228239
)
229240

230241
async def check_qbit_reachability(self):
231-
"""Check if the qBittorrent URL is reachable."""
242+
"""Check if the qBittorrent URL is reachable (and the credentials work)."""
232243
try:
233244
logger.debug(
234245
"_download_clients_qBit.py/check_qbit_reachability: Checking if qbit is reachable",
@@ -239,7 +250,7 @@ async def check_qbit_reachability(self):
239250
"password": getattr(self, "password", ""),
240251
}
241252
headers = {"content-type": "application/x-www-form-urlencoded"}
242-
await make_request(
253+
response = await make_request(
243254
"post",
244255
endpoint,
245256
self.settings,
@@ -249,11 +260,21 @@ async def check_qbit_reachability(self):
249260
log_error=False,
250261
ignore_test_run=True,
251262
)
252-
253263
except Exception as e: # noqa: BLE001
254264
tip = "💡 Tip: Did you specify the URL (and username/password if required) correctly?"
255-
logger.error(f"-- | qBittorrent\n❗️ {e}\n{tip}\n")
256-
wait_and_exit()
265+
if str(e) != self.last_error: # Only report new failure modes in full
266+
logger.error(f"-- | qBittorrent\n❗️ {e}\n{tip}\n")
267+
raise QbitError(e, tip=tip) from e
268+
269+
# Bad credentials: qBit answers HTTP 200 with the body "Fails." Treat this
270+
# as a definitive config error so we don't retry-loop every cycle and get
271+
# the source IP banned by qBittorrent's failed-login protection.
272+
if getattr(response, "text", None) == "Fails.":
273+
tip = "💡 Tip: Check the qBittorrent username/password."
274+
error = "qBittorrent login failed (incorrect username/password)."
275+
if error != self.last_error:
276+
logger.error(f"-- | qBittorrent\n❗️ {error}\n{tip}\n")
277+
raise QbitError(error, tip=tip, definitive=True)
257278

258279
async def check_connected(self):
259280
"""Check if the qBittorrent is connected to internet."""
@@ -283,26 +304,38 @@ async def check_connected(self):
283304
return True
284305

285306
async def setup(self):
286-
"""Perform the qBittorrent setup by calling relevant managers."""
287-
# Check reachabilty
288-
await self.check_qbit_reachability()
307+
"""Perform the qBittorrent setup; degrade instead of exiting on failure."""
308+
try:
309+
await self.check_qbit_reachability()
289310

290-
# Refresh the qBittorrent cookie first
291-
await self.refresh_cookie()
311+
# Refresh the qBittorrent cookie first
312+
await self.refresh_cookie()
292313

293-
try:
294314
# Fetch version and validate it
295315
await self.fetch_version()
296316
await self.validate_version()
297-
logger.info(f"OK | qBittorrent ({self.base_url})")
298-
except QbitError as e:
299-
logger.error(f"qBittorrent version check failed: {e}")
300-
wait_and_exit() # Exit if version check fails
301317

302-
# Continue with other setup tasks regardless of version check result
303-
await self.create_required_tags()
304-
await self.set_unwanted_folder()
305-
await self.warn_no_bandwidth_limit_set()
318+
await self.create_required_tags()
319+
await self.set_unwanted_folder()
320+
await self.warn_no_bandwidth_limit_set()
321+
322+
logger.info(f"OK | qBittorrent ({self.base_url})")
323+
self.ready = True
324+
self.failure_kind = None
325+
self.last_error = None
326+
self.setup_tip = ""
327+
except Exception as e: # noqa: BLE001
328+
if not isinstance(e, QbitError) and str(e) != self.last_error:
329+
logger.error(
330+
f"Unhandled error during qBittorrent setup: {e}", exc_info=True
331+
)
332+
self.ready = False
333+
self.failure_kind = (
334+
"definitive" if is_definitive_setup_error(e) else "transient"
335+
)
336+
self.last_error = str(e)
337+
self.setup_tip = getattr(e, "tip", "")
338+
return self.ready
306339

307340
async def get_protected_and_private(self):
308341
"""Fetch torrents from qBittorrent and checks for protected and private status."""
@@ -429,7 +462,6 @@ async def get_torrent_properties(self, qbit_hash):
429462
)
430463
return response.json()
431464

432-
433465
async def get_torrent_files(self, download_id):
434466
# this may not work if the wrong qbit
435467
logger.debug("_download_clients_qBit/get_torrent_files: Getting torrent files")

0 commit comments

Comments
 (0)