Chryses

old

Mar 27th, 2025 (edited)
21
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 44.88 KB | None | 0 0
  1. #!/usr/bin/env python3
  2.  
  3. import time
  4. import requests
  5. from requests.adapters import HTTPAdapter
  6. from requests import Session
  7. import os
  8. from urllib3.util.retry import Retry
  9. from datetime import datetime, timedelta
  10. from typing import Union, List, Dict, Any, Tuple
  11. import logging
  12. from logging.handlers import RotatingFileHandler
  13. import sys
  14. # import json
  15.  
  16. # Sets the logging level based on the environment variable LOG_LEVEL
  17. log_level = os.environ.get('LOG_LEVEL', 'INFO').upper()
  18. numeric_level = getattr(logging, log_level, None)
  19. if not isinstance(numeric_level, int):
  20.     raise ValueError(f'Invalid log level: {log_level}')
  21.  
  22. # Get the environment variable for the log file directly
  23. log_to_file_path = os.getenv("LOG_TO_FILE", "")
  24.  
  25. # Configure the logger
  26. logger = logging.getLogger(__name__)
  27. logger.setLevel(numeric_level)
  28.  
  29. # Make sure there are no duplicate handlers
  30. for handler in logger.handlers[:]:
  31.     logger.removeHandler(handler)
  32.  
  33. # Log format
  34. log_format = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
  35.  
  36. # Handler for the console
  37. console_handler = logging.StreamHandler(sys.stdout)
  38. console_handler.setFormatter(log_format)
  39. logger.addHandler(console_handler)
  40.  
  41. # Handler for the file if specified
  42. if log_to_file_path:
  43.     try:
  44.         # Make sure the directory exists
  45.         os.makedirs(log_to_file_path, exist_ok=True)
  46.        
  47.         # Create the full path to the log file inside that directory
  48.         log_file = os.path.join(log_to_file_path, "emulerr_stalled_checker.log")
  49.        
  50.         # Use this full path to the log file
  51.         file_handler = RotatingFileHandler(
  52.             log_file,
  53.             maxBytes=2 * 1024 * 1024,  # 2 MB
  54.             backupCount=6,
  55.             encoding="utf-8"
  56.         )
  57.         file_handler.setFormatter(log_format)
  58.         logger.addHandler(file_handler)
  59.         logger.info(f"Log file configured in: {log_file}")
  60.     except Exception as e:
  61.         print(f"Log file configuration error: {e}")
  62.  
  63. class Config:
  64.     # All environment variables must be provided by docker-compose.yml
  65.     DRY_RUN = os.environ.get('DRY_RUN', 'false').lower() == 'true'  # flags for dry running
  66.  
  67.     EMULERR_ENDPOING = '/download-client?_data=routes%2F_shell.download-client'
  68.     EMULERR_HOST = f"{os.environ.get('EMULERR_HOST', '')}"
  69.  
  70.     CHECK_INTERVAL = int(os.environ.get('CHECK_INTERVAL'))  # in minutes
  71.     STALL_CHECKS = int(os.environ.get('STALL_CHECKS'))  # number of checks before considering stall
  72.     STALL_DAYS = int(os.environ.get('STALL_DAYS'))  # days after which a complete visa file is considered stalled
  73.     RECENT_DOWNLOAD_GRACE_PERIOD = int(os.environ.get('RECENT_DOWNLOAD_GRACE_PERIOD', '30'))  # in minutes
  74.  
  75.     # New configuration options for monitoring checks
  76.     DELETE_IF_UNMONITORED_SERIE = os.environ.get('DELETE_IF_UNMONITORED_SERIE', 'false').lower() == 'true'
  77.     DELETE_IF_UNMONITORED_SEASON = os.environ.get('DELETE_IF_UNMONITORED_SEASON', 'false').lower() == 'true'
  78.     DELETE_IF_UNMONITORED_EPISODE = os.environ.get('DELETE_IF_UNMONITORED_EPISODE', 'false').lower() == 'true'
  79.     DELETE_IF_ONLY_ON_EMULERR = os.environ.get('DELETE_IF_ONLY_ON_EMULERR', 'false').lower() == 'true'
  80.  
  81.     # Download client name
  82.     DOWNLOAD_CLIENT = os.environ.get('DOWNLOAD_CLIENT', '')  # download client name in Sonarr/Radarr
  83.  
  84.     # Radarr config (optional)
  85.     RADARR_HOST = os.environ.get('RADARR_HOST', None)
  86.     RADARR_API_KEY = os.environ.get('RADARR_API_KEY', None)
  87.     RADARR_CATEGORY = os.environ.get('RADARR_CATEGORY', None)  # category for Radarr downloads
  88.    
  89.     # Sonarr config (optional)
  90.     SONARR_HOST = os.environ.get('SONARR_HOST', None)
  91.     SONARR_API_KEY = os.environ.get('SONARR_API_KEY', None)
  92.     SONARR_CATEGORY = os.environ.get('SONARR_CATEGORY', None)  # category for Sonarr downloads
  93.  
  94.     # Pushover configuration
  95.     PUSHOVER_APP_TOKEN = os.environ.get('PUSHOVER_APP_TOKEN', '')
  96.     PUSHOVER_USER_KEY = os.environ.get('PUSHOVER_USER_KEY', '')
  97.  
  98.     # Assigns API_URL directly in the body of the class
  99.     API_URL = f"{os.environ.get('EMULERR_HOST', '')}{EMULERR_ENDPOING}"
  100.  
  101.     @staticmethod
  102.     def validate():
  103.         mandatory_fields = [
  104.             'CHECK_INTERVAL', 'API_URL', 'STALL_CHECKS', 'STALL_DAYS', 'DOWNLOAD_CLIENT', 'EMULERR_HOST'
  105.         ]
  106.  
  107.         for field in mandatory_fields:
  108.             value = getattr(Config, field)
  109.             if value is None or value == '':
  110.                 logger.error(f"Environment variable {field} must be set.")
  111.                 exit(1)
  112.  
  113.         radarr_used = Config.RADARR_HOST is not None
  114.         sonarr_used = Config.SONARR_HOST is not None
  115.  
  116.         if not radarr_used and not sonarr_used:
  117.             logger.error("At least one of RADARR_HOST or SONARR_HOST must be set.")
  118.             exit(1)
  119.  
  120.         if radarr_used and not sonarr_used:
  121.             if Config.RADARR_API_KEY is None or Config.RADARR_CATEGORY is None:
  122.                 logger.error("When using Radarr, RADARR_API_KEY and RADARR_CATEGORY must be set.")
  123.                 exit(1)
  124.  
  125.             Config.SONARR_HOST = None
  126.             Config.SONARR_API_KEY = None
  127.             Config.SONARR_CATEGORY = None
  128.  
  129.         if sonarr_used and not radarr_used:
  130.             if Config.SONARR_API_KEY is None or Config.SONARR_CATEGORY is None:
  131.                 logger.error("When using Sonarr, SONARR_API_KEY and SONARR_CATEGORY must be set.")
  132.                 exit(1)
  133.  
  134.             Config.RADARR_HOST = None
  135.             Config.RADARR_API_KEY = None
  136.             Config.RADARR_CATEGORY = None
  137.  
  138.         # New validation for *_HOST variables
  139.         host_variables = ['RADARR_HOST', 'SONARR_HOST', 'EMULERR_HOST']
  140.        
  141.         for host_var in host_variables:
  142.             host_value = os.environ.get(host_var)
  143.             if host_value and not host_value.startswith(('http://', 'https://')):
  144.                 logger.error(f"Environment variable {host_var} must start with 'http://' or 'https://'.")
  145.                 exit(1)
  146.  
  147. class EmulerrDownload:
  148.     def __init__(self, file_data: dict):
  149.         self.name = file_data.get('name', '')
  150.         self.hash = file_data.get('hash', '')
  151.         self.size = file_data.get('size', 0)
  152.         self.size_done = file_data.get('size_done', 0)
  153.         self.progress = file_data.get('progress', 0) * 100
  154.         self.status = file_data.get('status_str', '')
  155.         self.src_count = file_data.get('src_count', 0)
  156.         self.src_count_a4af = file_data.get('src_count_a4af', 0)
  157.         self.last_seen_complete = file_data.get('last_seen_complete', 0)
  158.         self.category = file_data.get('meta', {}).get('category', 'unknown')
  159.         self.addedOn = file_data.get('meta', {}).get('addedOn', 0)
  160.  
  161. class SonarrDownload:
  162.     def __init__(self, record_data: dict):
  163.         self.title = record_data.get('title', '')
  164.         self.downloadId = record_data.get('downloadId', '')
  165.         self.download_client = record_data.get('downloadClient', '')
  166.         self.id = record_data.get('id', '')
  167.         self.size = record_data.get('size', 0)
  168.         self.sizeleft = record_data.get('sizeleft', 0)
  169.         self.progress = (self.size - self.sizeleft) / self.size * 100 if self.size > 0 else 0
  170.         self.series_id = record_data.get('seriesId', None)
  171.         self.season_number = record_data.get('seasonNumber', None)
  172.         self.episode_id = record_data.get('episodeId', None)
  173.  
  174. class RadarrDownload:
  175.     def __init__(self, record_data: dict):
  176.         self.title = record_data.get('title', '')
  177.         self.downloadId = record_data.get('downloadId', '')
  178.         self.download_client = record_data.get('downloadClient', '')
  179.         self.id = record_data.get('id', '')
  180.         self.size = record_data.get('size', 0)
  181.         self.sizeleft = record_data.get('sizeleft', 0)
  182.         self.progress = (self.size - self.sizeleft) / self.size * 100 if self.size > 0 else 0
  183.         self.movie_id = record_data.get('movieId', None)
  184.  
  185. def check_special_cases(downloads, sonarr_queue, radarr_queue):
  186.     """Check special cases for Sonarr and Radarr, printing alerts for downloads to be removed."""
  187.     emulerr_downloads_to_remove = []
  188.     sonarr_radarr_downloads_to_remove = []
  189.  
  190.     def find_queue_item_by_hash(hash_value, queue_data):
  191.         """Find the queue element based on the hash."""
  192.         logger.debug(f"Searching for hash {hash_value} in queue of length {len(queue_data)}")
  193.         for item in queue_data:
  194.             logger.debug(f"Checking item with downloadId: {item.downloadId}")
  195.             if item.downloadId == hash_value:
  196.                 logger.debug(f"Found item with hash {hash_value}")
  197.                 return item
  198.         logger.debug(f"No item found with hash {hash_value}")
  199.         return None
  200.  
  201.     def get_series_monitor_status(host, api_key, series_id):
  202.         """Gets series monitoring status."""
  203.         logger.debug(f"Getting series monitor status for series_id: {series_id}")
  204.         url = f"{host}/api/v3/series/{series_id}"
  205.         headers = {"X-Api-Key": api_key}
  206.         try:
  207.             response = requests.get(url, headers=headers)
  208.             logger.debug(f"Series API response status code: {response.status_code}")
  209.             if response.status_code == 200:
  210.                 series = response.json()
  211.                 logger.debug(f"Series monitored status: {series.get('monitored')}")
  212.                 logger.debug(f"Number of seasons: {len(series.get('seasons', []))}")
  213.                 return series.get('monitored', False), series.get('seasons', [])
  214.             else:
  215.                 logger.error(f"Error in retrieving series information. Status code: {response.status_code}")
  216.                 return False, []
  217.         except Exception as e:
  218.             logger.error(f"Error in retrieving series information: {e}")
  219.             return False, []
  220.  
  221.     def get_season_monitor_status(seasons, season_number):
  222.         """Gets the status of monitoring the season."""
  223.         logger.debug(f"Getting season monitor status for season_number: {season_number}")
  224.         for season in seasons:
  225.             logger.debug(f"Checking season {season.get('seasonNumber')}")
  226.             if season.get('seasonNumber') == season_number:
  227.                 logger.debug(f"Season monitored status: {season.get('monitored')}")
  228.                 return season.get('monitored', False)
  229.         logger.debug(f"No season found with number {season_number}")
  230.         return False
  231.  
  232.     def get_episode_monitor_status(host, api_key, episode_id):
  233.         """Gets the status of episode monitoring."""
  234.         logger.debug(f"Getting episode monitor status for episode_id: {episode_id}")
  235.         url = f"{host}/api/v3/episode/{episode_id}"
  236.         headers = {"X-Api-Key": api_key}
  237.         try:
  238.             response = requests.get(url, headers=headers)
  239.             logger.debug(f"Episode API response status code: {response.status_code}")
  240.             if response.status_code == 200:
  241.                 episode = response.json()
  242.                 logger.debug(f"Episode monitored status: {episode.get('monitored')}")
  243.                 return episode.get('monitored', False)
  244.             else:
  245.                 logger.error(f"Error in retrieving episode information. Status code: {response.status_code}")
  246.                 return False
  247.         except Exception as e:
  248.             logger.error(f"Error in retrieving episode information: {e}")
  249.             return False
  250.  
  251.     def is_movie_monitored(host, api_key, movie_id):
  252.         """Check if the film is monitored."""
  253.         logger.debug(f"Checking movie monitor status for movie_id: {movie_id}")
  254.         url = f"{host}/api/v3/movie/{movie_id}"
  255.         headers = {"X-Api-Key": api_key}
  256.         try:
  257.             response = requests.get(url, headers=headers)
  258.             logger.debug(f"Movie API response status code: {response.status_code}")
  259.             if response.status_code == 200:
  260.                 movie = response.json()
  261.                 logger.debug(f"Movie monitored status: {movie.get('monitored')}")
  262.                 return movie.get('monitored', False)
  263.             else:
  264.                 logger.error(f"Error in retrieving film information. Status Code: {response.status_code}")
  265.                 return False
  266.         except Exception as e:
  267.             logger.error(f"Error in retrieving film information: {e}")
  268.             return False
  269.  
  270.     for download in downloads:
  271.         logger.debug(f"Checking download: {download.name} with hash: {download.hash}")
  272.         if download.category == Config.SONARR_CATEGORY:
  273.             logger.debug(f"Processing Sonarr category for {download.name}")
  274.             queue_item = find_queue_item_by_hash(download.hash, sonarr_queue)
  275.             logger.debug(f"Found Sonarr queue item for {download.name}: {queue_item.__dict__}")
  276.             if queue_item is None and Config.DELETE_IF_ONLY_ON_EMULERR:
  277.                 logger.warning(f"[SONARR] Download {download.name} is only in Emulerr. It will be removed from Emulerr.")
  278.                 send_pushover_notification(f"[SONARR] Download {download.name} is only in Emulerr. It will be removed from Emulerr.", dry_run=Config.DRY_RUN)
  279.                 emulerr_downloads_to_remove.append(download)
  280.             else:
  281.                 # Check the monitoring status of the series
  282.                 if Config.DELETE_IF_UNMONITORED_SERIE and queue_item.series_id is not None:
  283.                     logger.debug(f"Checking series monitoring status for {download.name} with series_id: {queue_item.series_id}")
  284.                     series_monitored, seasons = get_series_monitor_status(Config.SONARR_HOST, Config.SONARR_API_KEY, queue_item.series_id)
  285.                     logger.debug(f"Series monitoring status for {download.name}: {series_monitored}")
  286.                     if not series_monitored:
  287.                         logger.warning(f"[SONARR] Series for {download.name} is not monitored. It will be removed from Sonarr queue.")
  288.                         send_pushover_notification(f"[SONARR] Series for {download.name} is not monitored. It will be removed from Sonarr queue.", dry_run=Config.DRY_RUN)
  289.                         sonarr_radarr_downloads_to_remove.append(download)
  290.                         continue
  291.  
  292.                 # Check the monitoring status of the season
  293.                 if Config.DELETE_IF_UNMONITORED_SEASON and queue_item.season_number is not None:
  294.                     logger.debug(f"Checking season monitoring status for {download.name} with season_number: {queue_item.season_number}")
  295.                     season_monitored = get_season_monitor_status(seasons, queue_item.season_number)
  296.                     logger.debug(f"Season monitoring status for {download.name}: {season_monitored}")
  297.                     if not season_monitored:
  298.                         logger.warning(f"[SONARR] Season for {download.name} is not monitored. It will be removed from Sonarr queue.")
  299.                         send_pushover_notification(f"[SONARR] Season for {download.name} is not monitored. It will be removed from Sonarr queue.", dry_run=Config.DRY_RUN)
  300.                         sonarr_radarr_downloads_to_remove.append(download)
  301.                         continue
  302.  
  303.                 # Check the monitoring status of the episode
  304.                 if Config.DELETE_IF_UNMONITORED_EPISODE and queue_item.episode_id is not None:
  305.                     logger.debug(f"Checking episode monitoring status for {download.name} with episode_id: {queue_item.episode_id}")
  306.                     episode_monitored = get_episode_monitor_status(Config.SONARR_HOST, Config.SONARR_API_KEY, queue_item.episode_id)
  307.                     logger.debug(f"Episode monitoring status for {download.name}: {episode_monitored}")
  308.                     if not episode_monitored:
  309.                         logger.warning(f"[SONARR] Episode for {download.name} is not monitored. It will be removed from Sonarr queue.")
  310.                         send_pushover_notification(f"[SONARR] Episode for {download.name} is not monitored. It will be removed from Sonarr queue.", dry_run=Config.DRY_RUN)
  311.                         sonarr_radarr_downloads_to_remove.append(download)
  312.                         continue
  313.  
  314.         elif download.category == Config.RADARR_CATEGORY:
  315.             logger.debug(f"Processing Radarr category for {download.name}")
  316.             queue_item = find_queue_item_by_hash(download.hash, radarr_queue)
  317.             logger.debug(f"Found Radarr queue item for {download.name}: {queue_item.__dict__}")
  318.             if queue_item is None and Config.DELETE_IF_ONLY_ON_EMULERR:
  319.                 logger.warning(f"[RADARR] Download {download.name} is only in Emulerr. It will be removed from Emulerr.")
  320.                 send_pushover_notification(f"[RADARR] Download {download.name} is only in Emulerr. It will be removed from Emulerr.", dry_run=Config.DRY_RUN)
  321.                 emulerr_downloads_to_remove.append(download)
  322.             elif queue_item is not None:
  323.                 logger.debug(f"[RADARR] Download {download.name} found in Radarr queue.")
  324.  
  325.                 # Check the monitoring status of the film
  326.                 logger.debug(f"Checking movie monitoring status for {download.name}")
  327.                 logger.debug(f"Queue item for {download.name}: {queue_item.__dict__}")
  328.                 if queue_item.movie_id is not None:
  329.                     logger.debug(f"Checking movie monitoring status for {download.name} with movie_id: {queue_item.movie_id}")
  330.                     movie_monitored = is_movie_monitored(Config.RADARR_HOST, Config.RADARR_API_KEY, queue_item.movie_id)
  331.                     logger.debug(f"Movie monitoring status for {download.name}: {movie_monitored}")
  332.                     if not movie_monitored:
  333.                         logger.warning(f"[RADARR] Movie {download.name} is not monitored. It will be removed from Radarr queue.")
  334.                         send_pushover_notification(f"[RADARR] Movie {download.name} is not monitored. It will be removed from Radarr queue.", dry_run=Config.DRY_RUN)
  335.                         sonarr_radarr_downloads_to_remove.append(download)
  336.                 else:
  337.                     logger.warning(f"[RADARR] Movie ID for {download.name} is None. Skipping movie check.")
  338.             else:
  339.                 logger.error(f"[RADARR] Queue item for {download.name} is Unknown. Cannot check movie monitoring status.")
  340.  
  341.     return emulerr_downloads_to_remove, sonarr_radarr_downloads_to_remove
  342.  
  343. def emulerr_remove_download(hash_32: str, dry_run: bool = False) -> Dict[str, Any]:
  344.     url = f"{Config.EMULERR_HOST}/api/v2/torrents/delete?_data=routes%2Fapi.v2.torrents.delete"
  345.     headers = {
  346.         'Content-Type': 'application/x-www-form-urlencoded'
  347.     }
  348.     data = {
  349.         '_data': 'routes/api.v2.torrents.delete',
  350.         'hashes': hash_32.upper()
  351.     }
  352.  
  353.     if not dry_run:
  354.         try:
  355.             response = requests.post(url, headers=headers, data=data)
  356.             response.raise_for_status()
  357.             result = response.json()
  358.             result['status_code'] = response.status_code
  359.             return result
  360.         except requests.exceptions.RequestException as e:
  361.             logger.error(f"Error removing download: {e}")
  362.             return {"error": str(e), "status_code": e.response.status_code if e.response else None}
  363.     else:
  364.         logger.debug(f"DRY_RUN: Would remove download with hash: {hash_32}")
  365.         return {"dry_run": True, "message": "Download removal simulated", "status_code": 200}
  366.  
  367. def fetch_history(host: str, api_key: str, target_hash: str):
  368.     headers = {'X-Api-Key': api_key}
  369.     page = 1
  370.  
  371.     while True:
  372.         url = f"{host}/api/v3/history?page={page}"
  373.         try:
  374.             response = requests.get(url, headers=headers)
  375.             response.raise_for_status()
  376.             data = response.json()
  377.  
  378.             records = data.get('records', [])
  379.             if not records:
  380.                 break
  381.  
  382.             for record in records:
  383.                 if record.get('downloadId') == target_hash:
  384.                     return [record]
  385.  
  386.             page += 1
  387.  
  388.         except requests.exceptions.RequestException as e:
  389.             logger.error(f"Error retrieving history page {page}: {e}")
  390.             break
  391.  
  392.     return []
  393.  
  394. def find_grab_id_by_hash(hash_32: str, host: str, api_key: str) -> int:
  395.     hash_40 = hash_32.ljust(40, '0')
  396.     history_records = fetch_history(host, api_key, hash_40)
  397.  
  398.     if history_records:
  399.         record = history_records[0]
  400.         logger.debug(f"Matching grab found. Grab ID: {record['id']}")
  401.         return record['id']
  402.  
  403.     logger.debug(f"No matching grab found for hash: {hash_40}")
  404.     return None
  405.  
  406. def mark_as_failed(download: EmulerrDownload, host: str, api_key: str, dry_run: bool = True) -> bool:
  407.  
  408.     if dry_run:
  409.         logger.debug(f"[DRY RUN] Would mark download {download.name} as failed")
  410.         return True
  411.  
  412.     grab_id = find_grab_id_by_hash(download.hash, host, api_key)
  413.    
  414.     if grab_id is None:
  415.         return False
  416.  
  417.     url = f"{host}/api/v3/history/failed/{grab_id}"
  418.     headers = {'X-Api-Key': api_key}
  419.  
  420.     try:
  421.         response = requests.post(url, headers=headers)
  422.  
  423.         if response.status_code == 200:
  424.             logger.debug(f"Download {download.name} has been marked as failed.")
  425.             return True
  426.         else:
  427.             logger.error(f"Failed to mark download {download.name} as failed. Status: {response.status_code}")
  428.             return False
  429.     except Exception as e:
  430.         logger.error(f"Error marking download as failed: {e}")
  431.         return False
  432.  
  433. def remove_download(download: Union[SonarrDownload, RadarrDownload, EmulerrDownload], queue_id: int, host: str, api_key: str, dry_run: bool = True) -> bool:
  434.     if dry_run:
  435.         logger.debug(f"[DRY RUN] Would remove download {download.name} from queue")
  436.         return True
  437.  
  438.     url = f"{host}/api/v3/queue/{queue_id}"
  439.     headers = {'X-Api-Key': api_key}
  440.     params = {
  441.         'removeFromClient': 'true',
  442.         'blocklist': 'false'
  443.     }
  444.  
  445.     try:
  446.         response = requests.delete(url, headers=headers, params=params)
  447.         response.raise_for_status()
  448.         logger.debug(f"Successfully removed download {download.name} from queue")
  449.         return True
  450.     except requests.exceptions.RequestException as e:
  451.         logger.error(f"Error removing download: {e}")
  452.         return False
  453.  
  454. def handle_stalled_download(download: EmulerrDownload, sonarr_queue, radarr_queue, dry_run: bool = True) -> None:
  455.     """Handle a stalled download"""
  456.  
  457.     # Determine which host and api_key to use based on the category
  458.     if Config.SONARR_CATEGORY is not None and download.category == Config.SONARR_CATEGORY:
  459.         host = Config.SONARR_HOST
  460.         api_key = Config.SONARR_API_KEY
  461.         queue = sonarr_queue
  462.     elif Config.RADARR_CATEGORY is not None and download.category == Config.RADARR_CATEGORY:
  463.         host = Config.RADARR_HOST
  464.         api_key = Config.RADARR_API_KEY
  465.         queue = radarr_queue
  466.     else:
  467.         logger.debug(f"Unknown category: {download.category}")
  468.         return
  469.  
  470.     # First mark as failed
  471.     if mark_as_failed(download, host, api_key, dry_run):
  472.         logger.debug(f"{'[DRY RUN] ' if dry_run else ''}Successfully marked {download.name} as failed")
  473.  
  474.         # Find the corresponding queue item
  475.         queue_item = next((item for item in queue if item.downloadId == download.hash), None)
  476.         if queue_item:
  477.             # Then remove from queue
  478.             if remove_download(download, queue_item.id, host, api_key, dry_run):
  479.                 logger.debug(f"{'[DRY RUN] ' if dry_run else ''}Successfully removed {download.name} from queue")
  480.             else:
  481.                 logger.error(f"Failed to remove download {download.name} from queue")
  482.         else:
  483.             logger.error(f"Could not find queue item for download: {download.name}")
  484.     else:
  485.         logger.error(f"Failed to handle stalled download {download.name}")
  486.  
  487.     time.sleep(5)
  488.  
  489. def send_pushover_notification(message: str, dry_run: bool = False):
  490.     if dry_run:
  491.         logger.debug(f"Dry run is active. Pushover notification not sent: {message}")
  492.         return
  493.  
  494.     if Config.PUSHOVER_APP_TOKEN and Config.PUSHOVER_USER_KEY:
  495.         try:
  496.             response = requests.post("https://api.pushover.net/1/messages.json", data={
  497.                 "token": Config.PUSHOVER_APP_TOKEN,
  498.                 "user": Config.PUSHOVER_USER_KEY,
  499.                 "message": message
  500.             })
  501.             response.raise_for_status()
  502.             logger.debug(f"Pushover notification sent successfully: {message}")
  503.         except requests.RequestException as e:
  504.             logger.error(f"Failed to send Pushover notification: {str(e)}")
  505.     else:
  506.         logger.warning("Pushover notification not sent because PUSHOVER_APP_TOKEN or PUSHOVER_USER_KEY is not set.")
  507.  
  508. class StallChecker:
  509.     def __init__(self):
  510.         self.warnings = {}
  511.  
  512.     def check_status(self, download: EmulerrDownload) -> tuple[bool, str, int]:
  513.         current_hash = download.hash
  514.  
  515.         added_on = download.addedOn / 1000  # Convert to seconds
  516.         recent_download_threshold = time.time() - (Config.RECENT_DOWNLOAD_GRACE_PERIOD * 60)
  517.         if added_on > recent_download_threshold:
  518.             if current_hash in self.warnings:
  519.                 del self.warnings[current_hash]
  520.             return False, "", 0
  521.  
  522.         # Check if src_count_a4af > 0
  523.         if download.src_count_a4af > 0:
  524.             if current_hash in self.warnings:
  525.                 del self.warnings[current_hash]
  526.             return False, "", 0
  527.  
  528.         # Check if download is 100% complete
  529.         if download.progress >= 100:
  530.             if current_hash in self.warnings:
  531.                 del self.warnings[current_hash]
  532.             return False, "", 0
  533.  
  534.         # Check if size_done has changed
  535.         if current_hash in self.warnings and download.size_done != self.warnings[current_hash]['last_size']:
  536.             del self.warnings[current_hash]
  537.             return False, "", 0
  538.  
  539.         if download.last_seen_complete == 0:
  540.             reason = "Never seen complete"
  541.             if current_hash in self.warnings:
  542.                 # Increment check_count if size_done hasn't changed
  543.                 self.warnings[current_hash]['count'] += 1
  544.                 self.warnings[current_hash]['last_size'] = download.size_done
  545.                 count = self.warnings[current_hash]['count']
  546.  
  547.                 if count > Config.STALL_CHECKS:
  548.                     return True, reason, count
  549.                 else:
  550.                     return False, reason, count
  551.             else:
  552.                 # Add to warnings if not previously warned
  553.                 self.warnings[current_hash] = {'count': 1, 'last_size': download.size_done}
  554.                 return False, reason, 1
  555.  
  556.         # Rule 3: If last_seen_complete > STALL_DAYS
  557.         if download.last_seen_complete > 0:
  558.             stall_time = time.time() - (Config.STALL_DAYS * 24 * 60 * 60)
  559.             if download.last_seen_complete < stall_time:
  560.                 reason = f"Last seen complete > {Config.STALL_DAYS} days ago"
  561.                 if current_hash in self.warnings:
  562.                     # Increment check_count if size_done hasn't changed
  563.                     self.warnings[current_hash]['count'] += 1
  564.                     self.warnings[current_hash]['last_size'] = download.size_done
  565.                     count = self.warnings[current_hash]['count']
  566.  
  567.                     if count > Config.STALL_CHECKS:
  568.                         return True, reason, count
  569.                     else:
  570.                         return False, reason, count
  571.                 else:
  572.                     # Add to warnings if not previously warned
  573.                     self.warnings[current_hash] = {'count': 1, 'last_size': download.size_done}
  574.                     return False, reason, 1
  575.             else:
  576.                 if current_hash in self.warnings:
  577.                     del self.warnings[current_hash]
  578.                 return False, "", 0
  579.  
  580.         return False, "", 0
  581.  
  582.     def cleanup_warnings(self, current_hashes: set[str]):
  583.         to_remove = [h for h in self.warnings.keys() if h not in current_hashes]
  584.  
  585.         for h in to_remove:
  586.             del self.warnings[h]
  587.  
  588. def fetch_emulerr_data() -> List[EmulerrDownload]:
  589.     """Retrieve active downloads from server with retry mechanism, filtering by SONARR_CATEGORY or RADARR_CATEGORY"""
  590.     session = Session()
  591.     retry_strategy = Retry(
  592.         total=10,  # Maximum number of attempts
  593.         backoff_factor=30,  # Interval of 30 second between attempts
  594.         status_forcelist=[429, 500, 502, 503, 504],
  595.         allowed_methods=["GET"]
  596.     )
  597.     adapter = HTTPAdapter(max_retries=retry_strategy)
  598.     session.mount("http://", adapter)
  599.     session.mount("https://", adapter)
  600.  
  601.     try:
  602.         response = session.get(Config.API_URL)
  603.         response.raise_for_status()  # This throws an exception for incorrect HTTP status codes
  604.        
  605.         # Log the entire response content
  606.         # logger.debug(f"Full JSON response: {response.text}")
  607.        
  608.         data = response.json()
  609.         files = data.get('files', [])
  610.         # logger.debug(f"Retrieved {len(files)} total file{'s' if len(files) != 1 else ''}")
  611.  
  612.         # Log categories of all files
  613.         for file in files:
  614.             meta = file.get('meta', {})
  615.             category = meta.get('category', 'Category not found')
  616.             # logger.debug(f"File category: {category}")
  617.  
  618.         # Filter downloads based on category
  619.         filtered_downloads = [
  620.             EmulerrDownload(file) for file in files
  621.             if file.get('meta', {}).get('category') in [Config.SONARR_CATEGORY, Config.RADARR_CATEGORY]
  622.         ]
  623.        
  624.         # logger.debug(f"Filtered to {len(filtered_downloads)} file{'s' if len(filtered_downloads) != 1 else ''} matching SONARR_CATEGORY or RADARR_CATEGORY")
  625.        
  626.         # Log each filtered download
  627.         # for download in filtered_downloads:
  628.             # logger.debug(f"Filtered download: {download.__dict__}")
  629.        
  630.         return filtered_downloads
  631.     except requests.exceptions.RequestException as e:
  632.         logger.error(f"Error retrieving downloads: {e}")
  633.         return []
  634.  
  635. def fetch_sonarr_queue() -> List[SonarrDownload]:
  636.     """Retrieve active downloads from Sonarr, handling pagination with the new URL parameters.
  637.    Filter results based on the download_client matching Config.DOWNLOAD_CLIENT."""
  638.     page = 1
  639.     all_downloads = []
  640.     previous_page_data = None
  641.  
  642.     # Prepare headers for the request
  643.     headers = {
  644.         "accept": "application/json",
  645.         "X-Api-Key": Config.SONARR_API_KEY,
  646.     }
  647.  
  648.     while True:
  649.         try:
  650.             # Dynamic construction of the URL with the required parameters
  651.             url = (
  652.                 f"{Config.SONARR_HOST}/api/v3/queue?"
  653.                 f"page={page}"
  654.                 f"&pageSize=20"
  655.                 f"&includeUnknownMovieItems=true"
  656.                 f"&includeMovie=true"
  657.                 f"&status=unknown"
  658.                 f"&status=queued"
  659.                 f"&status=paused"
  660.                 f"&status=downloading"
  661.                 f"&status=completed"
  662.                 f"&status=failed"
  663.                 f"&status=warning"
  664.                 f"&status=delay"
  665.                 f"&status=fallback"
  666.             )
  667.             logger.debug(f"Sonarr - Request URL: {url}")
  668.  
  669.             response = requests.get(url, headers=headers)
  670.             logger.debug(f"Sonarr - Status code received: {response.status_code}")
  671.  
  672.             if response.status_code == 200:
  673.                 data = response.json()
  674.                 records = data.get('records', [])
  675.                 logger.debug(f"Sonarr - Number of records on the page {page}: {len(records)}")
  676.  
  677.                 # Compares the downloadIds of the current page with those of the previous page to avoid endless loops
  678.                 current_page_data = [record.get('downloadId') for record in records]
  679.                 logger.debug(f"Sonarr - downloadId on the page {page}: {current_page_data}")
  680.  
  681.                 if current_page_data == previous_page_data:
  682.                     logger.debug("Sonarr - The downloadIds are the same as the previous page, I break the cycle.")
  683.                     break
  684.  
  685.                 # Creates SonarrDownload objects for each record received, only if the download_client matches Config.DOWNLOAD_CLIENT
  686.                 downloads = [
  687.                     SonarrDownload(record) for record in records
  688.                     if record.get('downloadClient') == Config.DOWNLOAD_CLIENT
  689.                 ]
  690.                 all_downloads.extend(downloads)
  691.  
  692.                 # If the page contains less than 20 records, it is assumed to have reached the last page
  693.                 if len(records) < 20:
  694.                     logger.debug("Sonarr - Less than 20 records, probably the last page. I interrupt the cycle.")
  695.                     break
  696.  
  697.                 previous_page_data = current_page_data
  698.                 page += 1
  699.             else:
  700.                 logger.error(f"Failed to retrieve queue from Sonarr. Status code: {response.status_code}")
  701.                 break
  702.         except Exception as e:
  703.             logger.error(f"Error retrieving queue from Sonarr: {e}")
  704.             break
  705.  
  706.     logger.debug(f"Sonarr - Total downloads retrieved matching this download client -> '{Config.DOWNLOAD_CLIENT}': {len(all_downloads)}")
  707.     return all_downloads
  708.  
  709. def fetch_radarr_queue() -> List[RadarrDownload]:
  710.     """Retrieve active downloads from Radarr, handling pagination with the new URL parameters"""
  711.     page = 1
  712.     all_downloads = []
  713.     previous_page_data = None
  714.  
  715.     # Prepare headers for the request
  716.     headers = {
  717.         "accept": "application/json",
  718.         "X-Api-Key": Config.RADARR_API_KEY,
  719.     }
  720.  
  721.     while True:
  722.         try:
  723.             # Construct the URL with the required parameters
  724.             url = (
  725.                 f"{Config.RADARR_HOST}/api/v3/queue?"
  726.                 f"page={page}"
  727.                 f"&pageSize=20"
  728.                 f"&includeUnknownMovieItems=true"
  729.                 f"&includeMovie=true"
  730.                 f"&status=unknown"
  731.                 f"&status=queued"
  732.                 f"&status=paused"
  733.                 f"&status=downloading"
  734.                 f"&status=completed"
  735.                 f"&status=failed"
  736.                 f"&status=warning"
  737.                 f"&status=delay"
  738.                 f"&status=fallback"
  739.             )
  740.             logger.debug(f"URL request: {url}")
  741.  
  742.             response = requests.get(url, headers=headers)
  743.             logger.debug(f"Radarr - Status code received: {response.status_code}")
  744.  
  745.             if response.status_code == 200:
  746.                 data = response.json()
  747.                 records = data.get('records', [])
  748.                 logger.debug(f"Radarr - Number of records on the page {page}: {len(records)}")
  749.  
  750.                 # We compare the downloadId of the current page with those of the previous page
  751.                 current_page_data = [record.get('downloadId') for record in records]
  752.                 logger.debug(f"Radarr - downloadId in the page {page}: {current_page_data}")
  753.  
  754.                 if current_page_data == previous_page_data:
  755.                     logger.debug("Radarr - The downloadIds are the same as the previous page, I break the cycle.")
  756.                     break  # Exit the loop if the data is the same as the previous page
  757.  
  758.                 # Creates RadarrDownload objects for each record received, only if the download_client matches Config.DOWNLOAD_CLIENT
  759.                 downloads = [
  760.                     RadarrDownload(record) for record in records
  761.                     if record.get('downloadClient') == Config.DOWNLOAD_CLIENT
  762.                 ]
  763.                 all_downloads.extend(downloads)
  764.  
  765.                 if len(records) < 20:  # If the page contains less than 20 records, I consider the last page
  766.                     logger.debug("Radarr - Less than 20 records, probably the last page. I interrupt the cycle.")
  767.                     break
  768.  
  769.                 previous_page_data = current_page_data
  770.                 page += 1
  771.             else:
  772.                 logger.error(f"Failed to retrieve queue from Radarr. Status code: {response.status_code}")
  773.                 break
  774.         except Exception as e:
  775.             logger.error(f"Error retrieving queue from Radarr: {e}")
  776.             break
  777.  
  778.     logger.debug(f"Radarr - Total downloads retrieved matching this download client -> '{Config.DOWNLOAD_CLIENT}': {len(all_downloads)}")
  779.     return all_downloads
  780.  
  781.  
  782.  
  783.  
  784.  
  785.  
  786.  
  787.  
  788.  
  789.  
  790. def initialize_data():
  791.     emulerr_data = fetch_emulerr_data()
  792.     sonarr_queue = fetch_sonarr_queue()
  793.     radarr_queue = fetch_radarr_queue()
  794.     return emulerr_data, sonarr_queue, radarr_queue
  795.  
  796. def main():
  797.     stall_checker = StallChecker()
  798.  
  799.     logger.info("=== Configuration Summary ===")
  800.     for attr, value in Config.__dict__.items():
  801.         if not callable(value) and not attr.startswith("__"):
  802.             logger.info(f"{attr}: {value}")
  803.     logger.info("=== Configuration Summary ===")
  804.  
  805.  
  806.  
  807.  
  808.  
  809. ################## TEST ##################
  810.     import json
  811.  
  812.     print("Result of fetch_emulerr_data():")
  813.     downloads = fetch_emulerr_data()
  814.     for download in downloads:
  815.         # Convert the object to a dictionary and then to a formatted JSON string
  816.         json_string = json.dumps(download.__dict__, indent=2, default=str)
  817.         print(json_string)
  818.  
  819.     print("\nResult of fetch_sonarr_queue():")
  820.     sonarr_queue = fetch_sonarr_queue()
  821.     for item in sonarr_queue:
  822.         # Convert the object to a dictionary and then to a formatted JSON string
  823.         json_string = json.dumps(item.__dict__, indent=2, default=str)
  824.         print(json_string)
  825.  
  826.     print("\nResult of fetch_radarr_queue():")
  827.     radarr_queue = fetch_radarr_queue()
  828.     for item in radarr_queue:
  829.         # Convert the object to a dictionary and then to a formatted JSON string
  830.         json_string = json.dumps(item.__dict__, indent=2, default=str)
  831.         print(json_string)
  832.  
  833.     # exit()
  834. ################## TEST ##################
  835.  
  836.  
  837.     while True:
  838.         try:
  839.             emulerr_data, sonarr_queue, radarr_queue = initialize_data()
  840.  
  841.             # Split emulerr_data into completed and incomplete
  842.             incomplete_downloads = [d for d in emulerr_data if d.progress < 100]
  843.             completed_downloads = [d for d in emulerr_data if d.progress == 100]
  844.  
  845.             # Handle incomplete downloads
  846.             if incomplete_downloads:
  847.                 logger.debug(f"\nChecking {len(incomplete_downloads)} incomplete file{'s' if len(incomplete_downloads) != 1 else ''}")
  848.  
  849.                 current_hashes = {d.hash for d in incomplete_downloads}
  850.                 stall_checker.cleanup_warnings(current_hashes)
  851.  
  852.                 # Apply special case checks
  853.                 emulerr_downloads_to_remove, sonarr_radarr_downloads_to_remove = check_special_cases(incomplete_downloads, sonarr_queue, radarr_queue)
  854.  
  855.                 for download in emulerr_downloads_to_remove:
  856.                     logger.debug(f"\nDownload only on eMulerr to be removed: {download.name}, with hash: {download.hash}")
  857.                     logger.debug(f"Download details: {download.__dict__}")
  858.                     result = emulerr_remove_download(download.hash, Config.DRY_RUN)
  859.                     if not Config.DRY_RUN:  # If it is not a simulation
  860.                         if isinstance(result, dict) and 'error' in result:
  861.                             logger.error(f"Error removing download: {result['error']}\n")
  862.                         else:
  863.                             logger.debug(f"Successfully removed download {download.name}. Response: {result}\n")
  864.                     else:
  865.                         logger.debug(f"DRY_RUN: Simulated removal of download {download.name}. Response: {result}\n")
  866.                     incomplete_downloads.remove(download)  # Removes the download from the list of downloads
  867.                     logger.debug("\n")
  868.  
  869.                 # Removes downloads from Sonarr or Radarr
  870.                 for download in sonarr_radarr_downloads_to_remove:
  871.                     logger.debug(f"\nDownload on Sonarr or Radarr to be removed: {download.name}, with hash: {download.hash}")
  872.                     queue_item = next((item for item in sonarr_queue + radarr_queue if item.downloadId == download.hash), None)
  873.                     logger.debug(f"Download details: {download.__dict__}")
  874.                     if queue_item:
  875.                         logger.debug(f"Queue item details: {queue_item.__dict__}")
  876.                         if queue_item in sonarr_queue:
  877.                             remove_download(download, queue_item.id, Config.SONARR_HOST, Config.SONARR_API_KEY, Config.DRY_RUN)
  878.                         elif queue_item in radarr_queue:
  879.                             remove_download(download, queue_item.id, Config.RADARR_HOST, Config.RADARR_API_KEY, Config.DRY_RUN)
  880.                     else:
  881.                         logger.error(f"Could not find queue item for download on Sonarr or Radarr: {download.name}\n")
  882.                     incomplete_downloads.remove(download)  # Removes the download from the list of downloads
  883.                     logger.debug("\n")
  884.  
  885.                 # Check the status once for each download
  886.                 download_states = {}
  887.                 for download in incomplete_downloads:
  888.                     is_stalled, stall_reason, check_count = stall_checker.check_status(download)
  889.                     download_states[download.hash] = (is_stalled, stall_reason, check_count)
  890.  
  891.                 stalled_downloads = []
  892.                 warning_downloads = []
  893.  
  894.                 # Debug output
  895.                 if logger.getEffectiveLevel() == logging.DEBUG:
  896.                     for download in incomplete_downloads:
  897.                         is_stalled, stall_reason, check_count = download_states[download.hash]
  898.                         status = f"STALLED: {stall_reason}" if is_stalled else "Active"
  899.  
  900.                         last_seen = "Never" if download.last_seen_complete == 0 else \
  901.                             datetime.fromtimestamp(download.last_seen_complete).strftime('%Y-%m-%d %H:%M:%S')
  902.  
  903.                 # Process each download
  904.                 for download in incomplete_downloads:
  905.                     is_stalled, stall_reason, check_count = download_states[download.hash]
  906.  
  907.                     # If it's not a special case, add to stalled or warning lists
  908.                     if is_stalled or check_count > Config.STALL_CHECKS:
  909.                         stalled_downloads.append((download, check_count, stall_reason or "Max checks reached"))
  910.                     elif check_count > 0:
  911.                         warning_downloads.append((download, check_count, stall_reason or "Approaching stall threshold"))
  912.  
  913.                 # Show warning downloads
  914.                 if warning_downloads:
  915.                     logger.debug(f"Warning downloads ({len(warning_downloads)}/{len(incomplete_downloads)}):")
  916.                     for download, count, warning_reason in warning_downloads:
  917.                         logger.debug(f"{download.name} -> Warning ({count}/{Config.STALL_CHECKS}) - {warning_reason}")
  918.                 else:  # If warning is empty
  919.                     logger.debug("No warning downloads")
  920.  
  921.                 # Show stalled downloads
  922.                 if stalled_downloads:
  923.                     logger.debug(f"Stalled downloads ({len(stalled_downloads)}/{len(incomplete_downloads)}):")
  924.                     for download, check_count, stall_reason in stalled_downloads:
  925.                         logger.info(f"{download.name} -> Warning ({check_count}/{Config.STALL_CHECKS}) - STALLED ({stall_reason})")
  926.                         # Handle the stalled download
  927.                         send_pushover_notification(f"Download {download.name} marked as stalled: {stall_reason}. Will be removed", dry_run=Config.DRY_RUN)
  928.                         handle_stalled_download(download, sonarr_queue, radarr_queue, Config.DRY_RUN)
  929.                 else:  # If stalled is empty
  930.                     logger.debug("No stalled downloads")
  931.  
  932.             else:  # If incomplete_downloads is empty
  933.                 logger.debug("No incomplete downloads to check.")
  934.  
  935.             # Handle completed downloads
  936.             if completed_downloads:
  937.                 logger.debug(f"Checking {len(completed_downloads)} completed file{'s' if len(completed_downloads) != 1 else ''}")
  938.                 for download in completed_downloads:
  939.                     logger.debug(f"Completed download: {download.name}")
  940.                     # Handle completed download - you can add your logic here
  941.                     # handle_completed_download(download, sonarr_queue, radarr_queue)
  942.             else:
  943.                 logger.debug("No completed downloads to check.")
  944.  
  945.             logger.debug(f"Waiting {Config.CHECK_INTERVAL} minute(s) before next check...")
  946.             time.sleep(Config.CHECK_INTERVAL * 60)
  947.  
  948.         except KeyboardInterrupt:
  949.             logger.debug("Interrupted by user")
  950.             break
  951.         except Exception as e:
  952.             logger.error(f"Error in main loop: {e}")
  953.             time.sleep(Config.CHECK_INTERVAL * 60)
  954.  
  955. # Call the validation function at the beginning of your program
  956. if __name__ == "__main__":
  957.     try:
  958.         Config.validate()
  959.         main()
  960.     except ValueError as e:
  961.         logger.error(f"Configuration error: {e}")
  962.         exit(1)
Add Comment
Please, Sign In to add comment