Skip to content

Commit 33579fd

Browse files
committed
Fix: Prevent automatic full library scans on every Radarr/Sonarr download
- Updated scan_plex_library to require explicit permission to scan all libraries - Webhook handlers now only scan if media path is available or library_name is configured - Added allow_scan_all parameter to control when full library scans are allowed - API endpoint still allows scanning all libraries when explicitly requested - Prevents unnecessary full library scans on every download notification
1 parent 638162e commit 33579fd

3 files changed

Lines changed: 89 additions & 46 deletions

File tree

arr_queue_utils.py

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ def get_queue(arr_url: str, arr_api_key: str, arr_type: str = "sonarr") -> List[
3131
# Queue response has a 'records' field containing the actual queue items
3232
return queue_data.get('records', [])
3333
except Exception as e:
34-
logger.error("Error fetching queue from %s at %s: %s", arr_type, arr_url, e)
34+
logger.error("Error fetching queue from %s at %s: %s",
35+
arr_type, arr_url, e)
3536
return []
3637

3738

@@ -53,7 +54,7 @@ def get_stuck_items(arr_url: str, arr_api_key: str, arr_type: str = "sonarr") ->
5354
for item in queue:
5455
status = item.get('status', '').lower()
5556
status_messages = item.get('statusMessages', [])
56-
57+
5758
# Check if status is "downloadClientUnavailable" or if statusMessages indicate it
5859
is_stuck = False
5960
if status == 'downloadclientunavailable':
@@ -103,12 +104,14 @@ def remove_from_queue(arr_url: str, arr_api_key: str, queue_id: int, arr_type: s
103104
'blocklist': blocklist
104105
}
105106

106-
response = requests.delete(api_url, headers=headers, params=params, timeout=10)
107+
response = requests.delete(
108+
api_url, headers=headers, params=params, timeout=10)
107109
response.raise_for_status()
108110

109111
return {"success": True, "message": f"Removed queue item {queue_id}"}
110112
except Exception as e:
111-
logger.error("Error removing queue item %s from %s: %s", queue_id, arr_type, e)
113+
logger.error("Error removing queue item %s from %s: %s",
114+
queue_id, arr_type, e)
112115
return {"success": False, "message": f"Error: {str(e)}"}
113116

114117

@@ -133,22 +136,24 @@ def grab_release(arr_url: str, arr_api_key: str, release: Dict[str, Any], arr_ty
133136

134137
# Use the command API to grab the release
135138
api_url = f"{arr_url.rstrip('/')}/api/v3/command"
136-
headers = {'X-Api-Key': arr_api_key, 'Content-Type': 'application/json'}
139+
headers = {'X-Api-Key': arr_api_key,
140+
'Content-Type': 'application/json'}
137141

138142
# For Sonarr, we need episode IDs; for Radarr, we need movie ID
139143
if arr_type.lower() == 'sonarr':
140144
episode_data = release.get('episode')
141145
episode_ids = []
142-
146+
143147
if isinstance(episode_data, dict):
144148
# Single episode object
145149
ep_id = episode_data.get('id')
146150
if ep_id:
147151
episode_ids = [ep_id]
148152
elif isinstance(episode_data, list):
149153
# Array of episodes
150-
episode_ids = [ep.get('id') for ep in episode_data if ep.get('id')]
151-
154+
episode_ids = [ep.get('id')
155+
for ep in episode_data if ep.get('id')]
156+
152157
if not episode_ids:
153158
# Fallback: try to get series ID and trigger series search
154159
series_id = release.get('series', {}).get('id')
@@ -167,10 +172,10 @@ def grab_release(arr_url: str, arr_api_key: str, release: Dict[str, Any], arr_ty
167172
else: # Radarr
168173
movie_data = release.get('movie')
169174
movie_id = None
170-
175+
171176
if isinstance(movie_data, dict):
172177
movie_id = movie_data.get('id')
173-
178+
174179
if not movie_id:
175180
return {"success": False, "message": "No movie ID found in release"}
176181

@@ -179,7 +184,8 @@ def grab_release(arr_url: str, arr_api_key: str, release: Dict[str, Any], arr_ty
179184
'movieIds': [movie_id]
180185
}
181186

182-
response = requests.post(api_url, headers=headers, json=command_data, timeout=10)
187+
response = requests.post(
188+
api_url, headers=headers, json=command_data, timeout=10)
183189
response.raise_for_status()
184190

185191
return {"success": True, "message": f"Triggered search for release"}
@@ -206,15 +212,15 @@ def retry_stuck_item(arr_url: str, arr_api_key: str, queue_item: Dict[str, Any],
206212
return {"success": False, "message": "No queue ID found in item"}
207213

208214
# First, remove from queue (don't blocklist, don't remove from client)
209-
remove_result = remove_from_queue(arr_url, arr_api_key, queue_id, arr_type,
210-
remove_from_client=False, blocklist=False)
211-
215+
remove_result = remove_from_queue(arr_url, arr_api_key, queue_id, arr_type,
216+
remove_from_client=False, blocklist=False)
217+
212218
if not remove_result.get('success'):
213219
return remove_result
214220

215221
# Then trigger a new search
216222
grab_result = grab_release(arr_url, arr_api_key, queue_item, arr_type)
217-
223+
218224
if not grab_result.get('success'):
219225
return {"success": False, "message": f"Removed from queue but failed to trigger search: {grab_result.get('message')}"}
220226

@@ -235,7 +241,7 @@ def monitor_and_retry_stuck_items(arr_url: str, arr_api_key: str, arr_name: str,
235241
Dict with summary of actions taken
236242
"""
237243
stuck_items = get_stuck_items(arr_url, arr_api_key, arr_type)
238-
244+
239245
if not stuck_items:
240246
return {
241247
"success": True,
@@ -251,7 +257,7 @@ def monitor_and_retry_stuck_items(arr_url: str, arr_api_key: str, arr_name: str,
251257
for item in stuck_items:
252258
queue_id = item.get('id')
253259
title = "Unknown"
254-
260+
255261
if arr_type.lower() == 'sonarr':
256262
series = item.get('series', {})
257263
episode = item.get('episode', {})
@@ -260,17 +266,20 @@ def monitor_and_retry_stuck_items(arr_url: str, arr_api_key: str, arr_name: str,
260266
movie = item.get('movie', {})
261267
title = movie.get('title', 'Unknown')
262268

263-
logger.info("Found stuck item in %s: %s (Queue ID: %s)", arr_name, title, queue_id)
264-
269+
logger.info("Found stuck item in %s: %s (Queue ID: %s)",
270+
arr_name, title, queue_id)
271+
265272
retry_result = retry_stuck_item(arr_url, arr_api_key, item, arr_type)
266-
273+
267274
if retry_result.get('success'):
268275
retried_count += 1
269-
logger.info("Successfully retried stuck item in %s: %s", arr_name, title)
276+
logger.info("Successfully retried stuck item in %s: %s",
277+
arr_name, title)
270278
else:
271279
error_msg = f"{title}: {retry_result.get('message', 'Unknown error')}"
272280
errors.append(error_msg)
273-
logger.error("Failed to retry stuck item in %s: %s", arr_name, error_msg)
281+
logger.error("Failed to retry stuck item in %s: %s",
282+
arr_name, error_msg)
274283

275284
return {
276285
"success": True,
@@ -279,4 +288,3 @@ def monitor_and_retry_stuck_items(arr_url: str, arr_api_key: str, arr_name: str,
279288
"retried_count": retried_count,
280289
"errors": errors
281290
}
282-

media_watcher_service.py

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -429,11 +429,22 @@ async def _process_and_send_buffered_notifications(series_id: str, bot_instance:
429429

430430
# Trigger Plex scan if enabled
431431
if bot_instance.config.plex.scan_on_notification and bot_instance.config.plex.enabled:
432-
logger.info(
433-
"Triggering Plex library scan after Sonarr notification")
434432
# Use series path to find and scan only the relevant library
435-
library_name = bot_instance.config.plex.library_name if bot_instance.config.plex.library_name else None
436-
await scan_plex_library_async(library_name, series_path)
433+
if series_path:
434+
logger.info(
435+
f"Triggering Plex library scan after Sonarr notification for path: {series_path}")
436+
await scan_plex_library_async(None, series_path)
437+
else:
438+
# If no path available, check if we should scan a specific library or skip
439+
library_name = bot_instance.config.plex.library_name if bot_instance.config.plex.library_name else None
440+
if library_name:
441+
logger.info(
442+
f"Triggering Plex library scan after Sonarr notification for library: {library_name}")
443+
await scan_plex_library_async(library_name, None)
444+
else:
445+
logger.warning(
446+
"Skipping Plex scan: No series path found and no specific library configured. "
447+
"To avoid scanning all libraries, configure a library_name in settings or ensure Sonarr provides series paths.")
437448
except Exception as e:
438449
logger.critical(
439450
f"CRITICAL ERROR in _process_and_send_buffered_notifications: {e}", exc_info=True)
@@ -503,8 +514,9 @@ async def radarr_webhook_detailed():
503514
movie_data = payload.get('movie', {})
504515
movie_file_data = payload.get('movieFile', {})
505516
remote_movie_data = payload.get('remoteMovie', {})
506-
# Extract movie path for Plex scanning
507-
movie_path = movie_data.get('path')
517+
# Extract movie path for Plex scanning - try multiple sources
518+
# movie.path is the movie folder, movieFile.path is the actual file path
519+
movie_path = movie_data.get('path') or movie_file_data.get('path')
508520

509521
# Deduplication
510522
unique_key = (movie_data.get('tmdbId'), movie_file_data.get(
@@ -632,12 +644,24 @@ async def radarr_webhook_detailed():
632644

633645
# Trigger Plex scan if enabled
634646
if config.plex.scan_on_notification and config.plex.enabled:
635-
logger.info(
636-
"Triggering Plex library scan after Radarr notification")
637647
# Use movie path to find and scan only the relevant library
638-
library_name = config.plex.library_name if config.plex.library_name else None
639-
scan_coro = scan_plex_library_async(library_name, movie_path)
640-
asyncio.run_coroutine_threadsafe(scan_coro, bot_instance.loop)
648+
if movie_path:
649+
logger.info(
650+
f"Triggering Plex library scan after Radarr notification for path: {movie_path}")
651+
scan_coro = scan_plex_library_async(None, movie_path)
652+
asyncio.run_coroutine_threadsafe(scan_coro, bot_instance.loop)
653+
else:
654+
# If no path available, check if we should scan a specific library or skip
655+
library_name = config.plex.library_name if config.plex.library_name else None
656+
if library_name:
657+
logger.info(
658+
f"Triggering Plex library scan after Radarr notification for library: {library_name}")
659+
scan_coro = scan_plex_library_async(library_name, None)
660+
asyncio.run_coroutine_threadsafe(scan_coro, bot_instance.loop)
661+
else:
662+
logger.warning(
663+
"Skipping Plex scan: No movie path found and no specific library configured. "
664+
"To avoid scanning all libraries, configure a library_name in settings or ensure Radarr provides movie paths.")
641665

642666
return jsonify({"status": "success"}), 200
643667
except Exception as e:
@@ -1067,15 +1091,17 @@ def api_plex_scan():
10671091
return jsonify({"success": False, "message": "Bot instance not available"}), 500
10681092

10691093
# Run scan in async context
1094+
# Allow scanning all libraries if library_name is None (explicit API request)
1095+
allow_scan_all = library_name is None
10701096
loop = bot_instance.loop
10711097
if loop.is_running():
10721098
future = asyncio.run_coroutine_threadsafe(
1073-
scan_plex_library_async(library_name),
1099+
scan_plex_library_async(library_name, None, allow_scan_all=allow_scan_all),
10741100
loop
10751101
)
10761102
result = future.result(timeout=10)
10771103
else:
1078-
result = asyncio.run(scan_plex_library_async(library_name))
1104+
result = asyncio.run(scan_plex_library_async(library_name, None, allow_scan_all=allow_scan_all))
10791105

10801106
if result:
10811107
lib_text = library_name if library_name else "all libraries"

plex_utils.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def find_plex_library_for_path(media_path: str):
7979
return None, None
8080

8181

82-
def scan_plex_library(library_name: Optional[str] = None, media_path: Optional[str] = None) -> bool:
82+
def scan_plex_library(library_name: Optional[str] = None, media_path: Optional[str] = None, allow_scan_all: bool = False) -> bool:
8383
"""
8484
Scans a Plex library. If media_path is provided, performs a partial scan of just that folder.
8585
Otherwise, uses library_name or scans all libraries.
@@ -130,31 +130,40 @@ def scan_plex_library(library_name: Optional[str] = None, media_path: Optional[s
130130
f"Successfully triggered Plex scan for library: {library_name}")
131131
return True
132132
else:
133-
# Scan all libraries (fallback)
134-
sections = plex.library.sections()
135-
for section in sections:
136-
section.update()
137-
logger.info(
138-
f"Successfully triggered Plex scan for all libraries ({len(sections)} sections)")
139-
return True
133+
# Only scan all libraries if explicitly allowed (e.g., from API endpoint)
134+
if allow_scan_all:
135+
sections = plex.library.sections()
136+
for section in sections:
137+
section.update()
138+
logger.info(
139+
f"Successfully triggered Plex scan for all libraries ({len(sections)} sections)")
140+
return True
141+
else:
142+
# Don't scan all libraries as fallback - this is too aggressive
143+
# Log a warning instead
144+
logger.warning(
145+
"Skipping Plex scan: No media_path or library_name provided. "
146+
"To scan, either provide a media_path or configure a library_name.")
147+
return False
140148
except Exception as e:
141149
logger.error(f"Failed to scan Plex library: {e}", exc_info=True)
142150
return False
143151

144152

145-
async def scan_plex_library_async(library_name: Optional[str] = None, media_path: Optional[str] = None) -> bool:
153+
async def scan_plex_library_async(library_name: Optional[str] = None, media_path: Optional[str] = None, allow_scan_all: bool = False) -> bool:
146154
"""
147155
Async wrapper for scanning a Plex library.
148156
149157
Args:
150158
library_name: Optional name of the library to scan. Ignored if media_path is provided.
151159
media_path: Optional path to media file/folder. If provided, finds the matching library.
160+
allow_scan_all: If True, allows scanning all libraries when no path/library_name provided.
152161
153162
Returns:
154163
True if scan was successful, False otherwise.
155164
"""
156165
import asyncio
157-
return await asyncio.to_thread(scan_plex_library, library_name, media_path)
166+
return await asyncio.to_thread(scan_plex_library, library_name, media_path, allow_scan_all)
158167

159168

160169
def get_plex_activities(filter_scans_only: bool = True) -> list:

0 commit comments

Comments
 (0)