@@ -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+
187219def 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