Skip to content

Commit c731d15

Browse files
committed
Add new settings for handling removed downloads and failed imports in Swaparr
- Introduced UI options for users to enable automatic re-search of removed downloads and detection of failed imports. - Implemented backend logic to check for failed imports based on common error patterns and trigger searches for alternatives. - Updated the download deletion process to optionally trigger searches after removals based on user settings. - Enhanced logging for failed import detection and search triggering to improve monitoring and user feedback.
1 parent 49f7fde commit c731d15

File tree

2 files changed

+218
-7
lines changed

2 files changed

+218
-7
lines changed

frontend/static/js/settings_forms.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,34 @@ const SettingsForms = {
14081408
<p class="setting-help">Also remove downloads from the download client (recommended: enabled)</p>
14091409
</div>
14101410
1411+
<div class="setting-item">
1412+
<label for="swaparr_research_removed">
1413+
<a href="https://plexguide.github.io/Huntarr.io/apps/swaparr.html#research-removed" class="info-icon" title="Automatically blocklist and re-search removed downloads" target="_blank" rel="noopener">
1414+
<i class="fas fa-info-circle"></i>
1415+
</a>
1416+
Re-Search Removed Download:
1417+
</label>
1418+
<label class="toggle-switch" style="width:40px; height:20px; display:inline-block; position:relative;">
1419+
<input type="checkbox" id="swaparr_research_removed" ${settings.research_removed === true ? 'checked' : ''}>
1420+
<span class="toggle-slider" style="position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background-color:#3d4353; border-radius:20px; transition:0.4s;"></span>
1421+
</label>
1422+
<p class="setting-help">When a download is removed, blocklist it in the *arr app and automatically search for alternatives (retry once)</p>
1423+
</div>
1424+
1425+
<div class="setting-item">
1426+
<label for="swaparr_failed_import_detection">
1427+
<a href="https://plexguide.github.io/Huntarr.io/apps/swaparr.html#failed-import-detection" class="info-icon" title="Automatically handle failed imports" target="_blank" rel="noopener">
1428+
<i class="fas fa-info-circle"></i>
1429+
</a>
1430+
Handle Failed Imports:
1431+
</label>
1432+
<label class="toggle-switch" style="width:40px; height:20px; display:inline-block; position:relative;">
1433+
<input type="checkbox" id="swaparr_failed_import_detection" ${settings.failed_import_detection === true ? 'checked' : ''}>
1434+
<span class="toggle-slider" style="position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background-color:#3d4353; border-radius:20px; transition:0.4s;"></span>
1435+
</label>
1436+
<p class="setting-help">Automatically detect failed imports, blocklist them, and search for alternatives</p>
1437+
</div>
1438+
14111439
<div class="setting-item">
14121440
<label for="swaparr_dry_run">
14131441
<a href="https://plexguide.github.io/Huntarr.io/apps/swaparr.html#dry-run-mode" class="info-icon" title="Test mode - no actual removals" target="_blank" rel="noopener">
@@ -2196,6 +2224,8 @@ const SettingsForms = {
21962224
settings.max_download_time = getInputValue('#swaparr_max_download_time', '2h');
21972225
settings.ignore_above_size = getInputValue('#swaparr_ignore_above_size', '25GB');
21982226
settings.remove_from_client = getInputValue('#swaparr_remove_from_client', true);
2227+
settings.research_removed = getInputValue('#swaparr_research_removed', false);
2228+
settings.failed_import_detection = getInputValue('#swaparr_failed_import_detection', false);
21992229
settings.dry_run = getInputValue('#swaparr_dry_run', false);
22002230
settings.sleep_duration = getInputValue('#swaparr_sleep_duration', 15) * 60; // Convert minutes to seconds
22012231

src/primary/apps/swaparr/handler.py

Lines changed: 188 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,38 @@ def check_quality_based_removal(item, settings):
184184

185185
return False, None
186186

187+
def check_for_failed_imports(item, settings):
188+
"""Check if download has failed import based on error patterns"""
189+
if not settings.get("failed_import_detection", False):
190+
return False, ""
191+
192+
error_message = item.get("error_message", "").lower()
193+
status = item.get("status", "").lower()
194+
195+
# Common import failure indicators
196+
import_failure_indicators = [
197+
"import failed", "unable to import", "import error",
198+
"no files found", "path not found", "access denied",
199+
"disk full", "permission denied", "invalid path",
200+
"file not found", "directory not found", "cannot import",
201+
"import unsuccessful", "failed to import", "import aborted",
202+
"insufficient space", "read-only", "network error",
203+
"timeout", "connection lost", "corrupted", "invalid format",
204+
"no space left", "operation not permitted", "input/output error"
205+
]
206+
207+
# Check error message and status for failure patterns
208+
for pattern in import_failure_indicators:
209+
if pattern in error_message or pattern in status:
210+
return True, f"Import failure detected: {pattern}"
211+
212+
# Also check for specific status values that indicate import failures
213+
failed_statuses = ["failed", "error", "warning"]
214+
if status in failed_statuses and ("import" in error_message or "file" in error_message or "path" in error_message):
215+
return True, f"Import failure status: {status}"
216+
217+
return False, ""
218+
187219
def parse_time_string_to_seconds(time_string):
188220
"""Parse a time string like '2h', '30m', '1d' to seconds"""
189221
if not time_string:
@@ -349,8 +381,107 @@ def parse_queue_items(records, item_type, app_name):
349381

350382
return queue_items
351383

352-
def delete_download(app_name, api_url, api_key, download_id, remove_from_client=True, api_timeout=120):
353-
"""Delete a download from a Starr app"""
384+
def trigger_search_for_item(app_name, api_url, api_key, item, api_timeout=120):
385+
"""Trigger a search for the item that was removed"""
386+
api_version_map = {
387+
"radarr": "v3",
388+
"sonarr": "v3",
389+
"lidarr": "v1",
390+
"readarr": "v1",
391+
"whisparr": "v3",
392+
"eros": "v3"
393+
}
394+
395+
api_version = api_version_map.get(app_name, "v3")
396+
headers = {'X-Api-Key': api_key, 'Content-Type': 'application/json'}
397+
398+
try:
399+
# Different apps have different search endpoints and payload structures
400+
if app_name == "sonarr":
401+
# For Sonarr, we need the series ID and episode IDs
402+
series_id = item.get("seriesId")
403+
episode_ids = item.get("episodeIds", [])
404+
if series_id and episode_ids:
405+
search_url = f"{api_url.rstrip('/')}/api/{api_version}/command"
406+
payload = {
407+
"name": "EpisodeSearch",
408+
"seriesId": series_id,
409+
"episodeIds": episode_ids
410+
}
411+
else:
412+
swaparr_logger.warning(f"Cannot trigger search for {item.get('name', 'unknown')} - missing series/episode IDs")
413+
return False
414+
415+
elif app_name == "radarr":
416+
# For Radarr, we need the movie ID
417+
movie_id = item.get("movieId")
418+
if movie_id:
419+
search_url = f"{api_url.rstrip('/')}/api/{api_version}/command"
420+
payload = {
421+
"name": "MoviesSearch",
422+
"movieIds": [movie_id]
423+
}
424+
else:
425+
swaparr_logger.warning(f"Cannot trigger search for {item.get('name', 'unknown')} - missing movie ID")
426+
return False
427+
428+
elif app_name == "lidarr":
429+
# For Lidarr, we need the album ID
430+
album_id = item.get("albumId")
431+
if album_id:
432+
search_url = f"{api_url.rstrip('/')}/api/{api_version}/command"
433+
payload = {
434+
"name": "AlbumSearch",
435+
"albumIds": [album_id]
436+
}
437+
else:
438+
swaparr_logger.warning(f"Cannot trigger search for {item.get('name', 'unknown')} - missing album ID")
439+
return False
440+
441+
elif app_name == "readarr":
442+
# For Readarr, we need the book ID
443+
book_id = item.get("bookId")
444+
if book_id:
445+
search_url = f"{api_url.rstrip('/')}/api/{api_version}/command"
446+
payload = {
447+
"name": "BookSearch",
448+
"bookIds": [book_id]
449+
}
450+
else:
451+
swaparr_logger.warning(f"Cannot trigger search for {item.get('name', 'unknown')} - missing book ID")
452+
return False
453+
454+
elif app_name in ["whisparr", "eros"]:
455+
# For Whisparr/Eros, we need the movie ID (same structure as Radarr)
456+
movie_id = item.get("movieId")
457+
if movie_id:
458+
search_url = f"{api_url.rstrip('/')}/api/{api_version}/command"
459+
payload = {
460+
"name": "MoviesSearch",
461+
"movieIds": [movie_id]
462+
}
463+
else:
464+
swaparr_logger.warning(f"Cannot trigger search for {item.get('name', 'unknown')} - missing movie ID")
465+
return False
466+
else:
467+
swaparr_logger.warning(f"Search not supported for app: {app_name}")
468+
return False
469+
470+
# Execute the search command
471+
SWAPARR_STATS['api_calls_made'] += 1
472+
response = requests.post(search_url, headers=headers, json=payload, timeout=api_timeout)
473+
response.raise_for_status()
474+
475+
swaparr_logger.info(f"Successfully triggered search for {item.get('name', 'unknown')} in {app_name}")
476+
return True
477+
478+
except requests.exceptions.RequestException as e:
479+
swaparr_logger.error(f"Error triggering search for {item.get('name', 'unknown')} in {app_name}: {str(e)}")
480+
SWAPARR_STATS['errors_encountered'] += 1
481+
return False
482+
483+
def delete_download(app_name, api_url, api_key, download_id, remove_from_client=True, item=None, trigger_search=False, api_timeout=120):
484+
"""Delete a download from a Starr app and optionally trigger a new search"""
354485
api_version_map = {
355486
"radarr": "v3",
356487
"sonarr": "v3",
@@ -371,6 +502,16 @@ def delete_download(app_name, api_url, api_key, download_id, remove_from_client=
371502
swaparr_logger.info(f"Successfully removed download {download_id} from {app_name}")
372503
SWAPARR_STATS['downloads_removed'] += 1
373504
increment_swaparr_stat("removals", 1) # Track removals in persistent system
505+
506+
# Trigger search if requested and item data is available
507+
if trigger_search and item:
508+
swaparr_logger.info(f"Triggering new search for removed download: {item.get('name', 'unknown')}")
509+
search_success = trigger_search_for_item(app_name, api_url, api_key, item, api_timeout)
510+
if search_success:
511+
swaparr_logger.info(f"Successfully triggered search after removal for: {item.get('name', 'unknown')}")
512+
else:
513+
swaparr_logger.warning(f"Failed to trigger search after removal for: {item.get('name', 'unknown')}")
514+
374515
return True
375516
except requests.exceptions.RequestException as e:
376517
swaparr_logger.error(f"Error removing download {download_id} from {app_name}: {str(e)}")
@@ -436,7 +577,8 @@ def process_stalled_downloads(app_name, instance_name, instance_data, settings):
436577
swaparr_logger.warning(f"Found previously removed download that reappeared: {item['name']} (removed {days_since_removal} days ago)")
437578

438579
if not settings.get("dry_run", False):
439-
if delete_download(app_name, instance_data["api_url"], instance_data["api_key"], item["id"], True):
580+
# Don't trigger search for re-removed items (they were already searched before)
581+
if delete_download(app_name, instance_data["api_url"], instance_data["api_key"], item["id"], True, item, False):
440582
swaparr_logger.info(f"Re-removed previously removed download: {item['name']}")
441583
# Update the removal time
442584
removed_items[item_hash]["removed_time"] = datetime.utcnow().isoformat()
@@ -501,7 +643,9 @@ def process_stalled_downloads(app_name, instance_name, instance_data, settings):
501643
swaparr_logger.error(f"MALICIOUS CONTENT DETECTED: {item['name']} - {malicious_reason}")
502644

503645
if not settings.get("dry_run", False):
504-
if delete_download(app_name, instance_data["api_url"], instance_data["api_key"], item["id"], True):
646+
# Check if re-search is enabled for malicious removals
647+
trigger_search = settings.get("research_removed", False)
648+
if delete_download(app_name, instance_data["api_url"], instance_data["api_key"], item["id"], True, item, trigger_search):
505649
swaparr_logger.info(f"Successfully removed malicious download: {item['name']}")
506650

507651
# Mark as removed to prevent reappearance
@@ -530,7 +674,9 @@ def process_stalled_downloads(app_name, instance_name, instance_data, settings):
530674
swaparr_logger.warning(f"QUALITY-BASED REMOVAL: {item['name']} - {quality_reason}")
531675

532676
if not settings.get("dry_run", False):
533-
if delete_download(app_name, instance_data["api_url"], instance_data["api_key"], item["id"], True):
677+
# Check if re-search is enabled for quality-based removals
678+
trigger_search = settings.get("research_removed", False)
679+
if delete_download(app_name, instance_data["api_url"], instance_data["api_key"], item["id"], True, item, trigger_search):
534680
swaparr_logger.info(f"Successfully removed quality-blocked download: {item['name']}")
535681

536682
# Mark as removed to prevent reappearance
@@ -568,7 +714,9 @@ def process_stalled_downloads(app_name, instance_name, instance_data, settings):
568714
swaparr_logger.warning(f"AGE-BASED REMOVAL: {item['name']} - {age_reason}")
569715

570716
if not settings.get("dry_run", False):
571-
if delete_download(app_name, instance_data["api_url"], instance_data["api_key"], item["id"], True):
717+
# Check if re-search is enabled for age-based removals
718+
trigger_search = settings.get("research_removed", False)
719+
if delete_download(app_name, instance_data["api_url"], instance_data["api_key"], item["id"], True, item, trigger_search):
572720
swaparr_logger.info(f"Successfully removed age-expired download: {item['name']}")
573721

574722
# Mark as removed to prevent reappearance
@@ -595,6 +743,37 @@ def process_stalled_downloads(app_name, instance_name, instance_data, settings):
595743

596744
continue # Skip to next item - don't process further
597745

746+
# Check for failed imports FOURTH - immediate removal and re-search
747+
is_import_failed, import_reason = check_for_failed_imports(item, settings)
748+
if is_import_failed:
749+
swaparr_logger.warning(f"FAILED IMPORT DETECTED: {item['name']} - {import_reason}")
750+
751+
if not settings.get("dry_run", False):
752+
# Always trigger search for failed imports (this is the main purpose)
753+
trigger_search = True
754+
if delete_download(app_name, instance_data["api_url"], instance_data["api_key"], item["id"], True, item, trigger_search):
755+
swaparr_logger.info(f"Successfully removed failed import: {item['name']}")
756+
757+
# Mark as removed to prevent reappearance
758+
removed_items[item_hash] = {
759+
"name": item["name"],
760+
"removed_time": datetime.utcnow().isoformat(),
761+
"reason": f"Failed Import: {import_reason}",
762+
"size": item["size"]
763+
}
764+
save_removed_items(app_name, removed_items)
765+
766+
item_state = f"REMOVED (Failed Import: {import_reason})"
767+
768+
# Track failed import removal statistics
769+
SWAPARR_STATS['import_failed_removed'] = SWAPARR_STATS.get('import_failed_removed', 0) + 1
770+
increment_swaparr_stat("import_failed_removals", 1)
771+
else:
772+
swaparr_logger.info(f"DRY RUN: Would remove failed import: {item['name']} - {import_reason}")
773+
item_state = f"Would Remove (Failed Import: {import_reason})"
774+
775+
continue # Skip to next item - don't process further
776+
598777
# Check if download should be striked
599778
should_strike = False
600779
strike_reason = ""
@@ -629,7 +808,9 @@ def process_stalled_downloads(app_name, instance_name, instance_data, settings):
629808
swaparr_logger.warning(f"Max strikes reached for {item['name']}, removing download")
630809

631810
if not settings.get("dry_run", False):
632-
if delete_download(app_name, instance_data["api_url"], instance_data["api_key"], item["id"], True):
811+
# Check if re-search is enabled for strike-based removals
812+
trigger_search = settings.get("research_removed", False)
813+
if delete_download(app_name, instance_data["api_url"], instance_data["api_key"], item["id"], True, item, trigger_search):
633814
swaparr_logger.info(f"Successfully removed {item['name']} after {settings.get('max_strikes', 3)} strikes")
634815

635816
# Keep the item in strike data for reference but mark as removed

0 commit comments

Comments
 (0)