refactor: optimize show upgrades by using random page sampling and targeted series filtering

This commit is contained in:
Admin9705
2025-05-11 16:57:12 -04:00
parent a1e157778e
commit e5807f9b59
2 changed files with 193 additions and 33 deletions
+136
View File
@@ -889,6 +889,142 @@ def search_season(api_url: str, api_key: str, api_timeout: int, series_id: int,
sonarr_logger.error(f"An unexpected error occurred while triggering Sonarr season search: {e}")
return None
def get_cutoff_unmet_episodes_for_series(api_url: str, api_key: str, api_timeout: int, series_id: int, monitored_only: bool = True) -> List[Dict[str, Any]]:
"""
Get all cutoff unmet episodes for a specific series, handling pagination.
Args:
api_url: The base URL of the Sonarr API
api_key: The API key for authentication
api_timeout: Timeout for the API request
series_id: The series ID to fetch cutoff unmet episodes for
monitored_only: Whether to include only monitored episodes
Returns:
A list of all cutoff unmet episodes for the specified series
"""
endpoint = "wanted/cutoff"
page = 1
page_size = 1000 # Sonarr's max page size for this endpoint
all_cutoff_unmet = []
retries_per_page = 2
retry_delay = 3
sonarr_logger.debug(f"Fetching cutoff unmet episodes for series ID {series_id} using direct API filter (monitored_only={monitored_only})")
# Use a more targeted approach with a direct endpoint filter
while True:
retry_count = 0
success = False
records = []
while retry_count <= retries_per_page and not success:
# Parameters for the request with series ID as a direct filter
params = {
"page": page,
"pageSize": page_size,
"includeSeries": "true", # Include series info for filtering
"sortKey": "airDateUtc",
"sortDir": "asc",
"seriesId": series_id # Filter by series ID - this limits results to only this series
}
url = f"{api_url}/api/v3/{endpoint}"
sonarr_logger.debug(f"Requesting cutoff unmet page {page} for series {series_id} (attempt {retry_count+1}/{retries_per_page+1})")
try:
response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout)
sonarr_logger.debug(f"Sonarr API response status code for cutoff unmet page {page}: {response.status_code}")
response.raise_for_status() # Check for HTTP errors
if not response.content:
sonarr_logger.warning(f"Empty response for cutoff unmet episodes page {page} (attempt {retry_count+1})")
if retry_count < retries_per_page:
retry_count += 1
time.sleep(retry_delay)
continue
else:
sonarr_logger.error(f"Giving up on empty response after {retries_per_page+1} attempts")
break
try:
data = response.json()
records = data.get('records', [])
total_records_on_page = len(records)
total_records_reported = data.get('totalRecords', 0)
if page == 1:
# Don't log the total_records_reported as it's the global count, not series-specific
sonarr_logger.info(f"Fetching cutoff unmet records for series {series_id}...")
sonarr_logger.debug(f"Parsed {total_records_on_page} cutoff unmet records from page {page}")
if not records: # No more records found
sonarr_logger.debug(f"No more cutoff unmet records found on page {page}. Stopping pagination.")
success = True
break
all_cutoff_unmet.extend(records)
# Check if this was the last page
if total_records_on_page < page_size:
sonarr_logger.debug(f"Received {total_records_on_page} records (less than page size {page_size}). Last page.")
success = True
break
# Success for this page
success = True
break
except json.JSONDecodeError as e:
sonarr_logger.error(f"Failed to decode JSON for cutoff unmet page {page} (attempt {retry_count+1}): {e}")
if retry_count < retries_per_page:
retry_count += 1
time.sleep(retry_delay)
continue
else:
sonarr_logger.error(f"Giving up on JSON decode error after {retries_per_page+1} attempts")
break
except requests.exceptions.RequestException as e:
sonarr_logger.error(f"Request error for cutoff unmet page {page} (attempt {retry_count+1}): {e}")
if retry_count < retries_per_page:
retry_count += 1
time.sleep(retry_delay)
continue
else:
sonarr_logger.error(f"Giving up after unexpected error and {retries_per_page+1} attempts")
break # Exit retry loop
# If we didn't succeed after all retries or there are no more records, stop pagination
if not success or not records:
break
# Prepare for the next page
page += 1
# Double-check that all episodes belong to the requested series
# (sometimes the API can return episodes from other series)
verified_episodes = []
for episode in all_cutoff_unmet:
if episode.get('seriesId') == series_id:
verified_episodes.append(episode)
else:
sonarr_logger.warning(f"Filtered out episode that doesn't belong to series {series_id}")
sonarr_logger.info(f"Found {len(verified_episodes)} cutoff unmet episodes for series {series_id}")
# Apply monitored filter after verifying series
if monitored_only:
original_count = len(verified_episodes)
filtered_episodes = [
ep for ep in verified_episodes
if ep.get('series', {}).get('monitored', False) and ep.get('monitored', False)
]
sonarr_logger.debug(f"Filtered for monitored_only=True: {len(filtered_episodes)} monitored episodes (out of {original_count} total)")
return filtered_episodes
else:
return verified_episodes
def get_series_with_missing_episodes(api_url: str, api_key: str, api_timeout: int, monitored_only: bool = True, limit: int = 50, random_mode: bool = True) -> List[Dict[str, Any]]:
"""
Get a list of series that have missing episodes, along with missing episode counts per season.
+57 -33
View File
@@ -419,24 +419,29 @@ def process_upgrade_shows_mode(
"""Process upgrades in show mode - gets all cutoff unmet episodes for entire shows."""
processed_any = False
# Get all cutoff unmet episodes
cutoff_unmet_episodes = sonarr_api.get_cutoff_unmet_episodes(api_url, api_key, api_timeout, monitored_only)
sonarr_logger.info(f"Received {len(cutoff_unmet_episodes)} cutoff unmet episodes from Sonarr API (before filtering).")
# Use the efficient random page selection method to get a sample of cutoff unmet episodes
sonarr_logger.debug(f"Using random page selection for cutoff unmet episodes in shows mode")
# Request slightly more episodes than needed to ensure we have enough for a few shows
sample_size = hunt_upgrade_items * 20 # Use a larger multiplier for shows mode
cutoff_unmet_sample = sonarr_api.get_cutoff_unmet_episodes_random_page(
api_url, api_key, api_timeout, monitored_only, sample_size)
if not cutoff_unmet_episodes:
sonarr_logger.info(f"Received {len(cutoff_unmet_sample)} cutoff unmet episodes from random page (before filtering).")
if not cutoff_unmet_sample:
sonarr_logger.info("No cutoff unmet episodes found in Sonarr.")
return False
# Filter out future episodes if configured
if skip_series_refresh:
now_unix = time.time()
original_count = len(cutoff_unmet_episodes)
original_count = len(cutoff_unmet_sample)
# Ensure airDateUtc exists and is not None before parsing
cutoff_unmet_episodes = [
ep for ep in cutoff_unmet_episodes
cutoff_unmet_sample = [
ep for ep in cutoff_unmet_sample
if ep.get('airDateUtc') and time.mktime(time.strptime(ep['airDateUtc'], '%Y-%m-%dT%H:%M:%SZ')) < now_unix
]
skipped_count = original_count - len(cutoff_unmet_episodes)
skipped_count = original_count - len(cutoff_unmet_sample)
if skipped_count > 0:
sonarr_logger.info(f"Skipped {skipped_count} future episodes based on air date for upgrades.")
@@ -444,37 +449,37 @@ def process_upgrade_shows_mode(
sonarr_logger.info("Stop requested during upgrade processing.")
return processed_any
# Group episodes by series
series_episodes: Dict[int, List[Dict]] = {}
series_titles: Dict[int, str] = {} # Keep track of series titles
# Group episodes by series to identify candidate shows
series_info: Dict[int, Dict] = {} # Store series ID -> {title, sample_count}
for episode in cutoff_unmet_episodes:
for episode in cutoff_unmet_sample:
series_id = episode.get('seriesId')
if series_id is not None:
if series_id not in series_episodes:
series_episodes[series_id] = []
# Store series title when first encountering the series ID
series_titles[series_id] = episode.get('series', {}).get('title', f"Series ID {series_id}")
series_episodes[series_id].append(episode)
if series_id not in series_info:
series_info[series_id] = {
'title': episode.get('series', {}).get('title', f"Series ID {series_id}"),
'sample_count': 0
}
series_info[series_id]['sample_count'] += 1
# Get list of candidate series from the sample
series_candidates = []
for series_id, info in series_info.items():
series_candidates.append((series_id, info['sample_count'], info['title']))
# Create a list of (series_id, episode_count, series_title) tuples for selection
available_series = [(series_id, len(episodes), series_titles[series_id])
for series_id, episodes in series_episodes.items()]
if not available_series:
sonarr_logger.info("No series with cutoff unmet episodes found.")
if not series_candidates:
sonarr_logger.info("No valid series with cutoff unmet episodes found in sample.")
return False
# Select series to process - always randomly
random.shuffle(available_series)
series_to_process = available_series[:hunt_upgrade_items]
# Randomly select up to hunt_upgrade_items series to process
random.shuffle(series_candidates)
series_to_process = series_candidates[:hunt_upgrade_items]
sonarr_logger.info(f"Selected {len(series_to_process)} series with cutoff unmet episodes to process")
# Log selected series
for idx, (series_id, episode_count, series_title) in enumerate(series_to_process):
sonarr_logger.info(f" {idx+1}. {series_title} - {episode_count} cutoff unmet episodes")
# Log selected series from sample
for idx, (series_id, sample_count, series_title) in enumerate(series_to_process):
sonarr_logger.info(f" {idx+1}. {series_title} - {sample_count} cutoff unmet episodes found in sample")
# Process each selected series
for series_id, _, series_title in series_to_process:
@@ -482,9 +487,28 @@ def process_upgrade_shows_mode(
sonarr_logger.info("Stop requested before processing next series.")
break
episodes = series_episodes[series_id]
episode_ids = [episode["id"] for episode in episodes]
# Get ALL cutoff unmet episodes for this series (not just the ones in the sample)
all_series_episodes = sonarr_api.get_cutoff_unmet_episodes_for_series(
api_url, api_key, api_timeout, series_id, monitored_only)
# Filter future episodes if needed
if skip_series_refresh:
now_unix = time.time()
original_count = len(all_series_episodes)
all_series_episodes = [
ep for ep in all_series_episodes
if ep.get('airDateUtc') and time.mktime(time.strptime(ep['airDateUtc'], '%Y-%m-%dT%H:%M:%SZ')) < now_unix
]
filtered_count = original_count - len(all_series_episodes)
if filtered_count > 0:
sonarr_logger.info(f"Filtered {filtered_count} future episodes from {series_title}")
episode_ids = [episode["id"] for episode in all_series_episodes]
if not episode_ids:
sonarr_logger.warning(f"No valid episodes found for {series_title} after filtering")
continue
sonarr_logger.info(f"Processing {series_title} with {len(episode_ids)} cutoff unmet episodes")
# Refresh series metadata if not skipped