From 9cb44f2b14dfbaef09f575ab92c3005c22323346 Mon Sep 17 00:00:00 2001 From: Benjamin Harder Date: Sat, 14 Sep 2024 13:32:54 +0200 Subject: [PATCH] Periodic Rescans Added (New Feature) --- README.md | 101 ++++++++------ config/config.conf-Example | 3 + config/definitions.py | 227 ++++++++++++++++++++++--------- config/parser.py | 38 ++---- src/decluttarr.py | 205 +++++++++++++++------------- src/jobs/run_periodic_rescans.py | 102 ++++++++++++++ 6 files changed, 445 insertions(+), 231 deletions(-) create mode 100644 src/jobs/run_periodic_rescans.py diff --git a/README.md b/README.md index b702fd6..94d4e73 100644 --- a/README.md +++ b/README.md @@ -55,47 +55,66 @@ services: container_name: decluttarr restart: always environment: - - TZ=Europe/Zurich - - PUID=1000 - - PGID=1000 - ## General - - LOG_LEVEL=INFO - #- TEST_RUN=True - #- SSL_VERIFICATION=False - ## Features - - REMOVE_TIMER=10 - - REMOVE_FAILED=True - - REMOVE_FAILED_IMPORTS=True - - REMOVE_METADATA_MISSING=True - - REMOVE_MISSING_FILES=True - - REMOVE_ORPHANS=True - - REMOVE_SLOW=True - - REMOVE_STALLED=True - - REMOVE_UNMONITORED=True - - MIN_DOWNLOAD_SPEED=100 - - PERMITTED_ATTEMPTS=3 - - NO_STALLED_REMOVAL_QBIT_TAG=Don't Kill - - IGNORE_PRIVATE_TRACKERS=True - - FAILED_IMPORT_MESSAGE_PATTERNS=["Not an upgrade for existing", "Not a Custom Format upgrade for existing"] - ## Radarr - - RADARR_URL=http://radarr:7878 - - RADARR_KEY=$RADARR_API_KEY - ## Sonarr - - SONARR_URL=http://sonarr:8989 - - SONARR_KEY=$SONARR_API_KEY - ## Lidarr - - LIDARR_URL=http://lidarr:8686 - - LIDARR_KEY=$LIDARR_API_KEY - ## Readarr - - READARR_URL=http://readarr:8787 - - READARR_KEY=$READARR_API_KEY - ## Whisparr - - WHISPARR_URL=http://whisparr:6969 - - WHISPARR_KEY=$WHISPARR_API_KEY - ## qBittorrent - - QBITTORRENT_URL=http://qbittorrent:8080 - #- QBITTORRENT_USERNAME=Your name - #- QBITTORRENT_PASSWORD=Your password + TZ=Europe/Zurich + PUID=1000 + PGID=1000 + + ## General + # TEST_RUN=True + # SSL_VERIFICATION=False + LOG_LEVEL: INFO + + ## Features + REMOVE_TIMER: 10 + REMOVE_FAILED: True + REMOVE_FAILED_IMPORTS: True + REMOVE_METADATA_MISSING: True + REMOVE_MISSING_FILES: True + REMOVE_ORPHANS: True + REMOVE_SLOW: True + REMOVE_STALLED: True + REMOVE_UNMONITORED: True + RUN_PERIODIC_RESCANS: ' + { + "SONARR": {"MISSING": true, "CUTOFF_UNMET": true, "MAX_CONCURRENT_SCANS": 3, "MIN_DAYS_BEFORE_RESCAN": 7}, + "RADARR": {"MISSING": true, "CUTOFF_UNMET": true, "MAX_CONCURRENT_SCANS": 3, "MIN_DAYS_BEFORE_RESCAN": 7} + }' + + # Feature Settings + PERMITTED_ATTEMPTS: 3 + NO_STALLED_REMOVAL_QBIT_TAG: Don't Kill + REMOVE_SLOW: True + MIN_DOWNLOAD_SPEED: 100 + FAILED_IMPORT_MESSAGE_PATTERNS: ' + [ + "Not a Custom Format upgrade for existing", + "Not an upgrade for existing" + ]' + + ## Radarr + RADARR_URL: http://radarr:7878 + RADARR_KEY: $RADARR_API_KEY + + ## Sonarr + SONARR_URL: http://sonarr:8989 + SONARR_KEY: $SONARR_API_KEY + + ## Lidarr + LIDARR_URL=http://lidarr:8686 + LIDARR_KEY=$LIDARR_API_KEY + + ## Readarr + READARR_URL=http://readarr:8787 + READARR_KEY=$READARR_API_KEY + + ## Whisparr + WHISPARR_URL=http://whisparr:6969 + WHISPARR_KEY=$WHISPARR_API_KEY + + ## qBitorrent + QBITTORRENT_URL: http://qbittorrent:8080 + # QBITTORRENT_USERNAME=Your name + # QBITTORRENT_PASSWORD=Your password ``` 3) Run `docker-compose up -d` in the directory where the file is located to create the docker container Note: Always pull the "**latest**" version. The "dev" version is for testing only, and should only be pulled when contributing code or supporting with bug fixes diff --git a/config/config.conf-Example b/config/config.conf-Example index 80a2296..2112ea0 100644 --- a/config/config.conf-Example +++ b/config/config.conf-Example @@ -12,6 +12,9 @@ REMOVE_ORPHANS = True REMOVE_SLOW = True REMOVE_STALLED = True REMOVE_UNMONITORED = True +RUN_PERIODIC_RESCANS = {"SONARR": {"MISSING": true, CUTOFF_UNMET": true, "MAX_CONCURRENT_SCANS": 3, "MIN_DAYS_BEFORE_RESCAN": 7}, "RADARR": {"MISSING": true, "CUTOFF_UNMET": true, "MAX_CONCURRENT_SCANS": 3, "MIN_DAYS_BEFORE_RESCAN": 7}} + +[feature_settings] MIN_DOWNLOAD_SPEED = 100 PERMITTED_ATTEMPTS = 3 NO_STALLED_REMOVAL_QBIT_TAG = Don't Kill diff --git a/config/definitions.py b/config/definitions.py index 3c54354..9c2b6a8 100644 --- a/config/definitions.py +++ b/config/definitions.py @@ -1,85 +1,178 @@ #!/usr/bin/env python from config.parser import get_config_value from config.env_vars import * + # Define data types and default values for settingsDict variables -# General -LOG_LEVEL = get_config_value('LOG_LEVEL', 'general', False, str, 'INFO') -TEST_RUN = get_config_value('TEST_RUN', 'general', False, bool, False) -SSL_VERIFICATION = get_config_value('SSL_VERIFICATION', 'general', False, bool, True) - -# Features -REMOVE_TIMER = get_config_value('REMOVE_TIMER', 'features', False, float, 10) -REMOVE_FAILED = get_config_value('REMOVE_FAILED', 'features', False, bool, False) -REMOVE_FAILED_IMPORTS = get_config_value('REMOVE_FAILED_IMPORTS' , 'features', False, bool, False) -REMOVE_METADATA_MISSING = get_config_value('REMOVE_METADATA_MISSING', 'features', False, bool, False) -REMOVE_MISSING_FILES = get_config_value('REMOVE_MISSING_FILES' , 'features', False, bool, False) -REMOVE_NO_FORMAT_UPGRADE = get_config_value('REMOVE_NO_FORMAT_UPGRADE' , 'features', False, bool, False) # OUTDATED - WILL RETURN WARNING -REMOVE_ORPHANS = get_config_value('REMOVE_ORPHANS' , 'features', False, bool, False) -REMOVE_SLOW = get_config_value('REMOVE_SLOW' , 'features', False, bool, False) -REMOVE_STALLED = get_config_value('REMOVE_STALLED', 'features', False, bool, False) -REMOVE_UNMONITORED = get_config_value('REMOVE_UNMONITORED' , 'features', False, bool, False) -MIN_DOWNLOAD_SPEED = get_config_value('MIN_DOWNLOAD_SPEED', 'features', False, int, 0) -PERMITTED_ATTEMPTS = get_config_value('PERMITTED_ATTEMPTS', 'features', False, int, 3) -NO_STALLED_REMOVAL_QBIT_TAG = get_config_value('NO_STALLED_REMOVAL_QBIT_TAG', 'features', False, str, 'Don\'t Kill') -IGNORE_PRIVATE_TRACKERS = get_config_value('IGNORE_PRIVATE_TRACKERS', 'features', False, bool, True) -FAILED_IMPORT_MESSAGE_PATTERNS = get_config_value('FAILED_IMPORT_MESSAGE_PATTERNS','features', False, list, []) +# General +LOG_LEVEL = get_config_value("LOG_LEVEL", "general", False, str, "INFO") +TEST_RUN = get_config_value("TEST_RUN", "general", False, bool, False) +SSL_VERIFICATION = get_config_value("SSL_VERIFICATION", "general", False, bool, True) + +# Features +REMOVE_TIMER = get_config_value("REMOVE_TIMER", "features", False, float, 10) +REMOVE_FAILED = get_config_value("REMOVE_FAILED", "features", False, bool, False) +REMOVE_FAILED_IMPORTS = get_config_value( + "REMOVE_FAILED_IMPORTS", "features", False, bool, False +) +REMOVE_METADATA_MISSING = get_config_value( + "REMOVE_METADATA_MISSING", "features", False, bool, False +) +REMOVE_MISSING_FILES = get_config_value( + "REMOVE_MISSING_FILES", "features", False, bool, False +) +REMOVE_NO_FORMAT_UPGRADE = get_config_value( + "REMOVE_NO_FORMAT_UPGRADE", "features", False, bool, False +) # OUTDATED - WILL RETURN WARNING +REMOVE_ORPHANS = get_config_value("REMOVE_ORPHANS", "features", False, bool, False) +REMOVE_SLOW = get_config_value("REMOVE_SLOW", "features", False, bool, False) +REMOVE_STALLED = get_config_value("REMOVE_STALLED", "features", False, bool, False) +REMOVE_UNMONITORED = get_config_value( + "REMOVE_UNMONITORED", "features", False, bool, False +) +RUN_PERIODIC_RESCANS = get_config_value( + "RUN_PERIODIC_RESCANS", "features", False, dict, {} +) + +# Feature Settings +MIN_DOWNLOAD_SPEED = get_config_value( + "MIN_DOWNLOAD_SPEED", "feature_settings", False, int, 0 +) +PERMITTED_ATTEMPTS = get_config_value( + "PERMITTED_ATTEMPTS", "feature_settings", False, int, 3 +) +NO_STALLED_REMOVAL_QBIT_TAG = get_config_value( + "NO_STALLED_REMOVAL_QBIT_TAG", "feature_settings", False, str, "Don't Kill" +) +IGNORE_PRIVATE_TRACKERS = get_config_value( + "IGNORE_PRIVATE_TRACKERS", "feature_settings", False, bool, True +) +FAILED_IMPORT_MESSAGE_PATTERNS = get_config_value( + "FAILED_IMPORT_MESSAGE_PATTERNS", "feature_settings", False, list, [] +) # Radarr -RADARR_URL = get_config_value('RADARR_URL', 'radarr', False, str) -RADARR_KEY = None if RADARR_URL == None else \ - get_config_value('RADARR_KEY', 'radarr', True, str) - -# Sonarr -SONARR_URL = get_config_value('SONARR_URL', 'sonarr', False, str) -SONARR_KEY = None if SONARR_URL == None else \ - get_config_value('SONARR_KEY', 'sonarr', True, str) - -# Lidarr -LIDARR_URL = get_config_value('LIDARR_URL', 'lidarr', False, str) -LIDARR_KEY = None if LIDARR_URL == None else \ - get_config_value('LIDARR_KEY', 'lidarr', True, str) - -# Readarr -READARR_URL = get_config_value('READARR_URL', 'readarr', False, str) -READARR_KEY = None if READARR_URL == None else \ - get_config_value('READARR_KEY', 'readarr', True, str) - -# Whisparr -WHISPARR_URL = get_config_value('WHISPARR_URL', 'whisparr', False, str) -WHISPARR_KEY = None if WHISPARR_URL == None else \ - get_config_value('WHISPARR_KEY', 'whisparr', True, str) - -# qBittorrent -QBITTORRENT_URL = get_config_value('QBITTORRENT_URL', 'qbittorrent', False, str, '') -QBITTORRENT_USERNAME = get_config_value('QBITTORRENT_USERNAME', 'qbittorrent', False, str, '') -QBITTORRENT_PASSWORD = get_config_value('QBITTORRENT_PASSWORD', 'qbittorrent', False, str, '') +RADARR_URL = get_config_value("RADARR_URL", "radarr", False, str) +RADARR_KEY = ( + None if RADARR_URL == None else get_config_value("RADARR_KEY", "radarr", True, str) +) + +# Sonarr +SONARR_URL = get_config_value("SONARR_URL", "sonarr", False, str) +SONARR_KEY = ( + None if SONARR_URL == None else get_config_value("SONARR_KEY", "sonarr", True, str) +) + +# Lidarr +LIDARR_URL = get_config_value("LIDARR_URL", "lidarr", False, str) +LIDARR_KEY = ( + None if LIDARR_URL == None else get_config_value("LIDARR_KEY", "lidarr", True, str) +) + +# Readarr +READARR_URL = get_config_value("READARR_URL", "readarr", False, str) +READARR_KEY = ( + None + if READARR_URL == None + else get_config_value("READARR_KEY", "readarr", True, str) +) + +# Whisparr +WHISPARR_URL = get_config_value("WHISPARR_URL", "whisparr", False, str) +WHISPARR_KEY = ( + None + if WHISPARR_URL == None + else get_config_value("WHISPARR_KEY", "whisparr", True, str) +) + +# qBittorrent +QBITTORRENT_URL = get_config_value("QBITTORRENT_URL", "qbittorrent", False, str, "") +QBITTORRENT_USERNAME = get_config_value( + "QBITTORRENT_USERNAME", "qbittorrent", False, str, "" +) +QBITTORRENT_PASSWORD = get_config_value( + "QBITTORRENT_PASSWORD", "qbittorrent", False, str, "" +) ######################################################################################################################## ########### Validate settings -if not (IS_IN_PYTEST or RADARR_URL or SONARR_URL or LIDARR_URL or READARR_URL or WHISPARR_URL): - print(f'[ ERROR ]: No Radarr/Sonarr/Lidarr/Readarr/Whisparr URLs specified (nothing to monitor)') +if not ( + IS_IN_PYTEST + or RADARR_URL + or SONARR_URL + or LIDARR_URL + or READARR_URL + or WHISPARR_URL +): + print( + f"[ ERROR ]: No Radarr/Sonarr/Lidarr/Readarr/Whisparr URLs specified (nothing to monitor)" + ) exit() +#### Validate rescan settings +PERIODIC_RESCANS = get_config_value("PERIODIC_RESCANS", "features", False, dict, {}) + +rescan_supported_apps = ["SONARR", "RADARR"] +rescan_default_values = { + "MISSING": (True, bool), + "CUTOFF_UNMET": (True, bool), + "MAX_CONCURRENT_SCANS": (3, int), + "MIN_DAYS_BEFORE_RESCAN": (7, int), +} + + +# Remove rescan apps that are not supported +for key in list(RUN_PERIODIC_RESCANS.keys()): + if key not in rescan_supported_apps: + print(f"[ WARNING ]: Removed '{key}' from RUN_PERIODIC_RESCANS since only {rescan_supported_apps} are supported.") + RUN_PERIODIC_RESCANS.pop(key) + +# Ensure SONARR and RADARR have the required parameters with default values if they are present +for app in rescan_supported_apps: + if app in RUN_PERIODIC_RESCANS: + for param, (default, expected_type) in rescan_default_values.items(): + if param not in RUN_PERIODIC_RESCANS[app]: + print(f"[ INFO ]: Adding missing parameter '{param}' to '{app}' with default value '{default}'.") + RUN_PERIODIC_RESCANS[app][param] = default + else: + # Check the type and correct if necessary + current_value = RUN_PERIODIC_RESCANS[app][param] + if not isinstance(current_value, expected_type): + print( + f"[ INFO ]: Parameter '{param}' for '{app}' must be of type {expected_type.__name__} and found value '{current_value}' (type '{type(current_value).__name__}'). Defaulting to '{default}'." + ) + RUN_PERIODIC_RESCANS[app][param] = default + ########### Enrich setting variables -if RADARR_URL: RADARR_URL = RADARR_URL.rstrip('/') + '/api/v3' -if SONARR_URL: SONARR_URL = SONARR_URL.rstrip('/') + '/api/v3' -if LIDARR_URL: LIDARR_URL = LIDARR_URL.rstrip('/') + '/api/v1' -if READARR_URL: READARR_URL = READARR_URL.rstrip('/') + '/api/v1' -if WHISPARR_URL: WHISPARR_URL = WHISPARR_URL.rstrip('/') + '/api/v3' -if QBITTORRENT_URL: QBITTORRENT_URL = QBITTORRENT_URL.rstrip('/') + '/api/v2' - -RADARR_MIN_VERSION = '5.3.6.8608' -SONARR_MIN_VERSION = '4.0.1.1131' -LIDARR_MIN_VERSION = None -READARR_MIN_VERSION = None -WHISPARR_MIN_VERSION = '2.0.0.548' -QBITTORRENT_MIN_VERSION = '4.3.0' - -SUPPORTED_ARR_APPS = ['RADARR', 'SONARR', 'LIDARR', 'READARR', 'WHISPARR'] +if RADARR_URL: + RADARR_URL = RADARR_URL.rstrip("/") + "/api/v3" +if SONARR_URL: + SONARR_URL = SONARR_URL.rstrip("/") + "/api/v3" +if LIDARR_URL: + LIDARR_URL = LIDARR_URL.rstrip("/") + "/api/v1" +if READARR_URL: + READARR_URL = READARR_URL.rstrip("/") + "/api/v1" +if WHISPARR_URL: + WHISPARR_URL = WHISPARR_URL.rstrip("/") + "/api/v3" +if QBITTORRENT_URL: + QBITTORRENT_URL = QBITTORRENT_URL.rstrip("/") + "/api/v2" + +RADARR_MIN_VERSION = "5.3.6.8608" +if "RADARR" in PERIODIC_RESCANS: + RADARR_MIN_VERSION = "5.10.3.9171" + +SONARR_MIN_VERSION = "4.0.1.1131" +if "SONARR" in PERIODIC_RESCANS: + SONARR_MIN_VERSION = "4.0.9.2332" + +LIDARR_MIN_VERSION = None +READARR_MIN_VERSION = None +WHISPARR_MIN_VERSION = "2.0.0.548" +QBITTORRENT_MIN_VERSION = "4.3.0" + +SUPPORTED_ARR_APPS = ["RADARR", "SONARR", "LIDARR", "READARR", "WHISPARR"] ########### Add Variables to Dictionary settingsDict = {} for var_name in dir(): if var_name.isupper(): settingsDict[var_name] = locals()[var_name] - diff --git a/config/parser.py b/config/parser.py index 940266d..b3b6919 100644 --- a/config/parser.py +++ b/config/parser.py @@ -15,74 +15,62 @@ config.optionxform = str # maintain capitalization of config keys config.read(config_file_full_path) - def config_section_map(section): "Load the config file into a dictionary" dict1 = {} options = config.options(section) for option in options: try: - dict1[option] = config.get(section, option) - except: - print("exception on %s!" % option) + value = config.get(section, option) + # Attempt to parse JSON for dictionary-like values + try: + dict1[option] = json.loads(value) + except json.JSONDecodeError: + dict1[option] = value + except Exception as e: + print(f"Exception on {option}: {e}") dict1[option] = None return dict1 - def cast(value, type_): return type_(value) - def get_config_value(key, config_section, is_mandatory, datatype, default_value=None): "Return for each key the corresponding value from the Docker Environment or the Config File" if IS_IN_DOCKER: config_value = os.environ.get(key) if config_value is not None: - # print(f'The value retrieved for [{config_section}]: {key} is "{config_value}"') config_value = config_value - # return config_value elif is_mandatory: print(f"[ ERROR ]: Variable not specified in Docker environment: {key}") sys.exit(0) else: - # return default_value - # print(f'The default value used for [{config_section}]: {key} is "{default_value}" (data type: {type(default_value).__name__})') config_value = default_value - else: try: config_value = config_section_map(config_section).get(key) except configparser.NoSectionError: config_value = None if config_value is not None: - # print(f'The value retrieved for [{config_section}]: {key} is "{config_value}"') config_value = config_value - # return config_value elif is_mandatory: - print( - f"[ ERROR ]: Mandatory variable not specified in config file, section [{config_section}]: {key} (data type: {datatype.__name__})" - ) + print(f"[ ERROR ]: Mandatory variable not specified in config file, section [{config_section}]: {key} (data type: {datatype.__name__})") sys.exit(0) else: - # return default_value - # print(f'The default value used for [{config_section}]: {key} is "{default_value}" (data type: {type(default_value).__name__})') config_value = default_value # Apply data type try: if datatype == bool: config_value = eval(str(config_value).capitalize()) - elif datatype == list: - if ( - type(config_value) != list - ): # Default value is already a list, doesn't need to be pushed through json.loads + elif datatype == list or datatype == dict: + if not isinstance(config_value, datatype): config_value = json.loads(config_value) elif config_value is not None: config_value = cast(config_value, datatype) except Exception as e: - print( - f'[ ERROR ]: The value retrieved for [{config_section}]: {key} is "{config_value}" and cannot be converted to data type {datatype}' - ) + print(f'[ ERROR ]: The value retrieved for [{config_section}]: {key} is "{config_value}" and cannot be converted to data type {datatype}') print(e) sys.exit(0) return config_value + diff --git a/src/decluttarr.py b/src/decluttarr.py index 90934ce..5f53784 100644 --- a/src/decluttarr.py +++ b/src/decluttarr.py @@ -11,6 +11,7 @@ from src.jobs.remove_slow import remove_slow from src.jobs.remove_stalled import remove_stalled from src.jobs.remove_unmonitored import remove_unmonitored +from src.jobs.run_periodic_rescans import run_periodic_rescans from src.utils.trackers import Deleted_Downloads @@ -56,119 +57,127 @@ async def queueCleaner( # Cleans up the downloads queue logger.verbose("Cleaning queue on %s:", NAME) # Refresh queue: + try: + full_queue = await get_queue(BASE_URL, API_KEY, params={full_queue_param: True}) + if full_queue: + logger.debug("queueCleaner/full_queue at start:") + logger.debug(full_queue) - full_queue = await get_queue(BASE_URL, API_KEY, params={full_queue_param: True}) - if not full_queue: - logger.verbose(">>> Queue is empty.") - return - else: - logger.debug("queueCleaner/full_queue at start:") - logger.debug(full_queue) + deleted_downloads = Deleted_Downloads([]) + items_detected = 0 - deleted_downloads = Deleted_Downloads([]) - items_detected = 0 - try: - if settingsDict["REMOVE_FAILED"]: - items_detected += await remove_failed( - settingsDict, - BASE_URL, - API_KEY, - NAME, - deleted_downloads, - defective_tracker, - protectedDownloadIDs, - privateDowloadIDs, - ) + if settingsDict["REMOVE_FAILED"]: + items_detected += await remove_failed( + settingsDict, + BASE_URL, + API_KEY, + NAME, + deleted_downloads, + defective_tracker, + protectedDownloadIDs, + privateDowloadIDs, + ) - if settingsDict["REMOVE_FAILED_IMPORTS"]: - items_detected += await remove_failed_imports( - settingsDict, - BASE_URL, - API_KEY, - NAME, - deleted_downloads, - defective_tracker, - protectedDownloadIDs, - privateDowloadIDs, - ) + if settingsDict["REMOVE_FAILED_IMPORTS"]: + items_detected += await remove_failed_imports( + settingsDict, + BASE_URL, + API_KEY, + NAME, + deleted_downloads, + defective_tracker, + protectedDownloadIDs, + privateDowloadIDs, + ) - if settingsDict["REMOVE_METADATA_MISSING"]: - items_detected += await remove_metadata_missing( - settingsDict, - BASE_URL, - API_KEY, - NAME, - deleted_downloads, - defective_tracker, - protectedDownloadIDs, - privateDowloadIDs, - ) + if settingsDict["REMOVE_METADATA_MISSING"]: + items_detected += await remove_metadata_missing( + settingsDict, + BASE_URL, + API_KEY, + NAME, + deleted_downloads, + defective_tracker, + protectedDownloadIDs, + privateDowloadIDs, + ) - if settingsDict["REMOVE_MISSING_FILES"]: - items_detected += await remove_missing_files( - settingsDict, - BASE_URL, - API_KEY, - NAME, - deleted_downloads, - defective_tracker, - protectedDownloadIDs, - privateDowloadIDs, - ) + if settingsDict["REMOVE_MISSING_FILES"]: + items_detected += await remove_missing_files( + settingsDict, + BASE_URL, + API_KEY, + NAME, + deleted_downloads, + defective_tracker, + protectedDownloadIDs, + privateDowloadIDs, + ) - if settingsDict["REMOVE_ORPHANS"]: - items_detected += await remove_orphans( - settingsDict, - BASE_URL, - API_KEY, - NAME, - deleted_downloads, - defective_tracker, - protectedDownloadIDs, - privateDowloadIDs, - full_queue_param, - ) + if settingsDict["REMOVE_ORPHANS"]: + items_detected += await remove_orphans( + settingsDict, + BASE_URL, + API_KEY, + NAME, + deleted_downloads, + defective_tracker, + protectedDownloadIDs, + privateDowloadIDs, + full_queue_param, + ) - if settingsDict["REMOVE_SLOW"]: - items_detected += await remove_slow( - settingsDict, - BASE_URL, - API_KEY, - NAME, - deleted_downloads, - defective_tracker, - protectedDownloadIDs, - privateDowloadIDs, - download_sizes_tracker, - ) + if settingsDict["REMOVE_SLOW"]: + items_detected += await remove_slow( + settingsDict, + BASE_URL, + API_KEY, + NAME, + deleted_downloads, + defective_tracker, + protectedDownloadIDs, + privateDowloadIDs, + download_sizes_tracker, + ) - if settingsDict["REMOVE_STALLED"]: - items_detected += await remove_stalled( - settingsDict, - BASE_URL, - API_KEY, - NAME, - deleted_downloads, - defective_tracker, - protectedDownloadIDs, - privateDowloadIDs, - ) + if settingsDict["REMOVE_STALLED"]: + items_detected += await remove_stalled( + settingsDict, + BASE_URL, + API_KEY, + NAME, + deleted_downloads, + defective_tracker, + protectedDownloadIDs, + privateDowloadIDs, + ) - if settingsDict["REMOVE_UNMONITORED"]: - items_detected += await remove_unmonitored( + if settingsDict["REMOVE_UNMONITORED"]: + items_detected += await remove_unmonitored( + settingsDict, + BASE_URL, + API_KEY, + NAME, + deleted_downloads, + defective_tracker, + protectedDownloadIDs, + privateDowloadIDs, + arr_type, + ) + if items_detected == 0: + logger.verbose(">>> Queue is clean.") + else: + logger.verbose(">>> Queue is empty.") + + if settingsDict["RUN_PERIODIC_RESCANS"]: + await run_periodic_rescans( settingsDict, BASE_URL, API_KEY, NAME, - deleted_downloads, - defective_tracker, - protectedDownloadIDs, - privateDowloadIDs, arr_type, ) - - if items_detected == 0: - logger.verbose(">>> Queue is clean.") + except Exception as error: errorDetails(NAME, error) return diff --git a/src/jobs/run_periodic_rescans.py b/src/jobs/run_periodic_rescans.py new file mode 100644 index 0000000..3ace029 --- /dev/null +++ b/src/jobs/run_periodic_rescans.py @@ -0,0 +1,102 @@ +from src.utils.shared import errorDetails, rest_get, rest_post +import logging, verboselogs +from datetime import datetime, timedelta, timezone +import dateutil.parser + +logger = verboselogs.VerboseLogger(__name__) + + +async def run_periodic_rescans( + settingsDict, + BASE_URL, + API_KEY, + NAME, + arr_type, +): + # Checks the wanted items and runs scans + if not arr_type in settingsDict["RUN_PERIODIC_RESCANS"]: + return + try: + check_on_endpoint = [] + RESCAN_SETTINGS = settingsDict["RUN_PERIODIC_RESCANS"][arr_type] + if RESCAN_SETTINGS["MISSING"]: + check_on_endpoint.append("missing") + if RESCAN_SETTINGS["CUTOFF_UNMET"]: + check_on_endpoint.append("cutoff") + + params = { + "sortDirection": "ascending", + "pageSize": RESCAN_SETTINGS["MAX_CONCURRENT_SCANS"], + } + if arr_type == "SONARR": + params["sortKey"] = "episodes.lastSearchTime" + params["includeSeries"] = True + elif arr_type == "RADARR": + params["sortKey"] = "movies.lastSearchTime" + + for end_point in check_on_endpoint: + records = ( + await rest_get(f"{BASE_URL}/wanted/{end_point}", API_KEY, params) + )["records"] + if records is None: + logger.verbose( + f">>> Rescan: No {end_point} items, thus nothing to rescan." + ) + return + + # Remove records that have recently been searched already + for record in reversed(records): + lastSearchTime_str = record.get("lastSearchTime") + if lastSearchTime_str: + lastSearchTime = dateutil.parser.isoparse(lastSearchTime_str) + else: + lastSearchTime = None + if not ( + not lastSearchTime + or ( + lastSearchTime + + timedelta(days=RESCAN_SETTINGS["MIN_DAYS_BEFORE_RESCAN"]) + ) + < datetime.now(timezone.utc) + ): + records.remove(record) + + if records: + + # import json + # print(json.dumps(record, indent=3)) + if arr_type == "SONARR": + logger.verbose( + f"Running a scan for {len(records)} {end_point} items:\n" + + "\n".join( + [ + f"{record['series']['title']} (Season {record['seasonNumber']} / Episode {record['episodeNumber']}): {record['title']}" + for record in records + ] + ) + ) + episodeIds = [record["id"] for record in records] + json = {"name": "EpisodeSearch", "episodeIds": episodeIds} + elif arr_type == "RADARR": + logger.verbose( + f"Running a scan for {len(records)} {end_point} items:\n" + + "\n".join( + [ + f"{record['title']} ({record['year']})" + for record in records + ] + ) + ) + movieIds = [record["id"] for record in records] + json = {"name": "MovieSearch", "movieIds": movieIds} + + if not settingsDict["TEST_RUN"]: + await rest_post( + url=BASE_URL + "/command", + json=json, + headers={"X-Api-Key": API_KEY}, + ) + + except Exception as error: + errorDetails(NAME, error) + return 0