diff --git a/.dockerignore b/.dockerignore index 19179ac..6da8f43 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ __pycache__/ +.pytest_cache/ config/config.conf test*.py ToDo diff --git a/.gitignore b/.gitignore index b85f8df..f516ab9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__/ +.pytest_cache/ .vscode/ config/config.conf test*.py diff --git a/README.md b/README.md index 911f5ab..90924b4 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ If you want to run locally: 5) Enjoy ## Known Limitations: -- The connection to qBittorrent only works if no login is required (I use the setting "Bypass authentication for clients on localhost") +- None :-) ## Credits - Script for detecting stalled downloads expanded on code by MattDGTL/sonarr-radarr-queue-cleaner diff --git a/config/config.conf-Example b/config/config.conf-Example index 8025800..effd452 100644 --- a/config/config.conf-Example +++ b/config/config.conf-Example @@ -13,12 +13,14 @@ PERMITTED_ATTEMPTS = 3 NO_STALLED_REMOVAL_QBIT_TAG = Don't Kill If Stalled [radarr] -RADARR_URL = http://radarr:7878 -RADARR_KEY = $RADARR_KEY +RADARR_URL = http://radarr:7878 +RADARR_KEY = $RADARR_KEY [sonarr] -SONARR_URL = http://sonarr:8989 -SONARR_KEY = $SONARR_KEY +SONARR_URL = http://sonarr:8989 +SONARR_KEY = $SONARR_KEY [qbittorrent] -QBITTORRENT_URL = http://qbittorrent:8080 \ No newline at end of file +QBITTORRENT_URL = http://qbittorrent:8080 +QBITTORRENT_USERNAME = YourName or Empty +QBITTORRENT_PASSWORD = YourPassword or Empty \ No newline at end of file diff --git a/config/config.conf-Explained b/config/config.conf-Explained index 7888365..10c3e18 100644 --- a/config/config.conf-Explained +++ b/config/config.conf-Explained @@ -113,7 +113,10 @@ SONARR_KEY = XXXXX ################################# QBITTORRENT SECTION ################################# [qbittorrent] -# Defines URL of qBittorrent -# LIMITATION: The connection to qBittorrent only works if no login is required (I use the setting "Bypass authentication for clients on localhost") -# QBITTORRENT_URL : URL under which the instance can be reached. If not defined, the NO_STALLED_REMOVAL_QBIT_TAG takes no effect -QBITTORRENT_URL = http://qbittorrent:8080 +# Defines settings to connect with qBittorrent +# QBITTORRENT_URL : URL under which the instance can be reached. If not defined, the NO_STALLED_REMOVAL_QBIT_TAG takes no effect +# QBITTORRENT_USERNAME: Optional; particularly not needed if authentication bypassing on qBittorrent is enabled (for instance for local connections) +# QBITTORRENT_PASSWORD: Optional; same reason as above +QBITTORRENT_URL = http://qbittorrent:8080 +QBITTORRENT_USERNAME = Your Name (or empty) +QBITTORRENT_PASSWORD = Your Password (or empty) \ No newline at end of file diff --git a/config/config.py b/config/config.py index 33152d9..a016703 100644 --- a/config/config.py +++ b/config/config.py @@ -8,7 +8,7 @@ import configparser IS_IN_DOCKER = os.environ.get('IS_IN_DOCKER') ######################################################################################################################## -def ConfigSectionMap(section): +def config_section_map(section): 'Load the config file into a dictionary' dict1 = {} options = config.options(section) @@ -40,7 +40,7 @@ def get_config_value(key, config_section, is_mandatory, datatype, default_value else: try: - config_value = ConfigSectionMap(config_section).get(key) + config_value = config_section_map(config_section).get(key) except configparser.NoSectionError: config_value = None if config_value is not None: @@ -66,12 +66,12 @@ def get_config_value(key, config_section, is_mandatory, datatype, default_value ######################################################################################################################## # Load Config File -Config_FileName = 'config.conf' -Config_FileFullPath = os.path.join(os.path.abspath(os.path.dirname(__file__)), Config_FileName) +config_file_name = 'config.conf' +config_file_full_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), config_file_name) sys.tracebacklimit = 0 # dont show stack traces in prod mode config = configparser.ConfigParser() config.optionxform = str # maintain capitalization of config keys -config.read(Config_FileFullPath) +config.read(config_file_full_path) ######################################################################################################################## # Load Config @@ -101,14 +101,23 @@ SONARR_KEY = None if SONARR_URL == None else \ # 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 (RADARR_URL or SONARR_URL): print(f'[ ERROR ]: No Radarr/Sonarr URLs specified (nothing to monitor)') sys.exit(0) -########### Add API to URLs +########### Enrich setting variables if RADARR_URL: RADARR_URL += '/api/v3' if SONARR_URL: SONARR_URL += '/api/v3' if QBITTORRENT_URL: QBITTORRENT_URL += '/api/v2' +########### Add Variables to Dictionary +settings_dict = {} +for var_name in dir(): + if var_name.isupper(): + settings_dict[var_name] = locals()[var_name] + diff --git a/docker/Sample docker-compose.yml b/docker/Sample docker-compose.yml index b5d6c42..36072c7 100644 --- a/docker/Sample docker-compose.yml +++ b/docker/Sample docker-compose.yml @@ -6,10 +6,8 @@ TZ: Europe/Zurich PUID: 1000 PGID: 1000 - # General LOG_LEVEL: INFO - # Features REMOVE_TIMER: 10 REMOVE_FAILED: True @@ -19,15 +17,13 @@ REMOVE_UNMONITORED: True PERMITTED_ATTEMPTS: 3 NO_STALLED_REMOVAL_QBIT_TAG: Don't Kill If Stalled - # Radarr RADARR_URL: http://radarr:7878 RADARR_KEY: $RADARR_API_KEY - # Sonarr SONARR_URL: http://sonarr:8989 SONARR_KEY: $SONARR_API_KEY - # qBitorrent QBITTORRENT_URL: http://qbittorrent:8080 - \ No newline at end of file + #QBITTORRENT_USERNAME: Your Name + #QBITTORRENT_PASSWORD: Your Password \ No newline at end of file diff --git a/main.py b/main.py index 95c4839..f350f70 100644 --- a/main.py +++ b/main.py @@ -1,26 +1,20 @@ ########### Import Libraries import asyncio import logging, verboselogs -from src.utils.rest import (rest_get, rest_post) +from src.utils.rest import rest_get, rest_post from requests.exceptions import RequestException import json from dateutil.relativedelta import relativedelta as rd -from config.config import ( - IS_IN_DOCKER, - LOG_LEVEL, TEST_RUN, - REMOVE_TIMER, REMOVE_FAILED, REMOVE_STALLED, REMOVE_METADATA_MISSING, REMOVE_ORPHANS, REMOVE_UNMONITORED, PERMITTED_ATTEMPTS, NO_STALLED_REMOVAL_QBIT_TAG, - RADARR_URL, RADARR_KEY, - SONARR_URL, SONARR_KEY, - QBITTORRENT_URL -) -from src.queue_cleaner import (queue_cleaner) - +from config.config import settings_dict +from src.queue_cleaner import queue_cleaner +#print(json.dumps(settings_dict,indent=4)) +import requests ########### Enabling Logging # Set up logging -log_level_num=logging.getLevelName(LOG_LEVEL) +log_level_num=logging.getLevelName(settings_dict['LOG_LEVEL']) logger = verboselogs.VerboseLogger(__name__) logging.basicConfig( - format=('' if IS_IN_DOCKER else '%(asctime)s ') + ('[%(levelname)-7s]' if LOG_LEVEL=='VERBOSE' else '[%(levelname)s]') + ': %(message)s', + format=('' if settings_dict['IS_IN_DOCKER'] else '%(asctime)s ') + ('[%(levelname)-7s]' if settings_dict['LOG_LEVEL']=='VERBOSE' else '[%(levelname)s]') + ': %(message)s', level=log_level_num ) @@ -32,38 +26,84 @@ class Defective_Tracker: # Main function async def main(): # Get name of Radarr / Sonarr instances - if RADARR_URL: - RADARR_NAME = (await rest_get(RADARR_URL+'/system/status', RADARR_KEY))['instanceName'] - if SONARR_URL: - SONARR_NAME = (await rest_get(SONARR_URL+'/system/status', SONARR_KEY))['instanceName'] - + try: + if settings_dict['RADARR_URL']: + settings_dict['RADARR_NAME'] = (await rest_get(settings_dict['RADARR_URL']+'/system/status', settings_dict['RADARR_KEY']))['instanceName'] + except: + settings_dict['RADARR_NAME'] = 'Radarr' + try: + if settings_dict['SONARR_URL']: + settings_dict['SONARR_NAME'] = (await rest_get(settings_dict['SONARR_URL']+'/system/status', settings_dict['SONARR_KEY']))['instanceName'] + except: + settings_dict['SONARR_NAME'] = 'Sonarr' + # Print Settings fmt = '{0.days} days {0.hours} hours {0.minutes} minutes' logger.info('#' * 50) logger.info('Application Started!') + logger.info('') logger.info('*** Current Settings ***') - logger.info('%s | Removing failed downloads', str(REMOVE_FAILED)) - logger.info('%s | Removing stalled downloads', str(REMOVE_STALLED)) - logger.info('%s | Removing downloads missing metadata', str(REMOVE_METADATA_MISSING)) - logger.info('%s | Removing orphan downloads', str(REMOVE_ORPHANS)) - logger.info('%s | Removing downloads belonging to unmonitored TV shows/movies', str(REMOVE_UNMONITORED)) + logger.info('%s | Removing failed downloads', str(settings_dict['REMOVE_FAILED'])) + logger.info('%s | Removing stalled downloads', str(settings_dict['REMOVE_STALLED'])) + logger.info('%s | Removing downloads missing metadata', str(settings_dict['REMOVE_METADATA_MISSING'])) + logger.info('%s | Removing orphan downloads', str(settings_dict['REMOVE_ORPHANS'])) + logger.info('%s | Removing downloads belonging to unmonitored TV shows/movies', str(settings_dict['REMOVE_UNMONITORED'])) + logger.info('') - logger.info('Running every: %s', fmt.format(rd(minutes=REMOVE_TIMER))) - logger.info('Permitted number of times before stalled/missing metadata downloads are removed: %s', str(PERMITTED_ATTEMPTS)) - logger.info('Downloads with this tag will be skipped: %s', NO_STALLED_REMOVAL_QBIT_TAG) + logger.info('Running every: %s', fmt.format(rd(minutes=settings_dict['REMOVE_TIMER']))) + logger.info('Permitted number of times before stalled/missing metadata downloads are removed: %s', str(settings_dict['PERMITTED_ATTEMPTS'])) + logger.info('Downloads with this tag will be skipped: \"%s\"', settings_dict['NO_STALLED_REMOVAL_QBIT_TAG']) + logger.info('') logger.info('*** Configured Instances ***') - if RADARR_URL: logger.info('%s: %s', RADARR_NAME, RADARR_URL) - if SONARR_URL: logger.info('%s: %s', SONARR_NAME, SONARR_URL) - if QBITTORRENT_URL: logger.info('qBittorrent: %s', QBITTORRENT_URL) + if settings_dict['RADARR_URL']: logger.info('%s: %s', settings_dict['RADARR_NAME'], settings_dict['RADARR_URL']) + if settings_dict['SONARR_URL']: logger.info('%s: %s', settings_dict['SONARR_NAME'], settings_dict['SONARR_URL']) + if settings_dict['QBITTORRENT_URL']: logger.info('qBittorrent: %s', settings_dict['QBITTORRENT_URL']) + + logger.info('') + logger.info('*** Check Instances ***') + if settings_dict['RADARR_URL']: + error_occured = False + try: + await asyncio.get_event_loop().run_in_executor(None, lambda: requests.get(settings_dict['RADARR_URL']+'/system/status', params=None, headers={'X-Api-Key': settings_dict['RADARR_KEY']})) + logger.info('OK | %s', settings_dict['RADARR_NAME']) + except Exception as error: + error_occured = True + logger.error('-- | %s *** Error: %s ***', settings_dict['RADARR_NAME'], error) + + if settings_dict['SONARR_URL']: + try: + await asyncio.get_event_loop().run_in_executor(None, lambda: requests.get(settings_dict['SONARR_URL']+'/system/status', params=None, headers={'X-Api-Key': settings_dict['SONARR_KEY']})) + logger.info('OK | %s', settings_dict['SONARR_NAME']) + except Exception as error: + error_occured = True + logger.error('-- | %s *** Error: %s ***', settings_dict['SONARR_NAME'], error) + + if settings_dict['QBITTORRENT_URL']: + try: + response = await asyncio.get_event_loop().run_in_executor(None, lambda: requests.post(settings_dict['QBITTORRENT_URL']+'/auth/login', data={'username': settings_dict['QBITTORRENT_USERNAME'], 'password': settings_dict['QBITTORRENT_PASSWORD']}, headers={'content-type': 'application/x-www-form-urlencoded'})) + if response.text == 'Fails.': + raise ConnectionError('Login failed.') + response.raise_for_status() + settings_dict['QBIT_COOKIE'] = {'SID': response.cookies['SID']} + logger.info('OK | %s', 'qBittorrent') + except Exception as error: + error_occured = True + logger.error('-- | %s *** Error: %s / Reponse: %s ***', 'qBittorrent', error, response.text) + + if error_occured: + logger.warning('At least one instance was not reachable. Waiting for 60 seconds, then exiting Decluttarr.') + await asyncio.sleep(60) + exit() + logger.info('') logger.info('#' * 50) - if LOG_LEVEL == 'INFO': - logger.info('[LOG_LEVEL = INFO]: Only logging changes (switch to VERBOSE for more info)') + if settings_dict['LOG_LEVEL'] == 'INFO': + logger.info('LOG_LEVEL = INFO: Only logging changes (switch to VERBOSE for more info)') else: logger.info(f'') - if TEST_RUN: + if settings_dict['TEST_RUN']: logger.info(f'*'* 50) logger.info(f'*'* 50) logger.info(f'') @@ -72,29 +112,29 @@ async def main(): logger.info(f'') logger.info(f'*'* 50) logger.info(f'*'* 50) - + # Check if Qbit Tag exists: - if QBITTORRENT_URL: - current_tags = await rest_get(QBITTORRENT_URL+'/torrents/tags','') - if not NO_STALLED_REMOVAL_QBIT_TAG in current_tags: - if QBITTORRENT_URL: - logger.info('Creating tag in qBittorrent: %s', NO_STALLED_REMOVAL_QBIT_TAG) - if not TEST_RUN: - await rest_post(url=QBITTORRENT_URL+'/torrents/createTags', data={'tags': NO_STALLED_REMOVAL_QBIT_TAG}, headers={'content-type': 'application/x-www-form-urlencoded'}) + if settings_dict['QBITTORRENT_URL']: + current_tags = await rest_get(settings_dict['QBITTORRENT_URL']+'/torrents/tags',cookies=settings_dict['QBIT_COOKIE']) + if not settings_dict['NO_STALLED_REMOVAL_QBIT_TAG'] in current_tags: + if settings_dict['QBITTORRENT_URL']: + logger.info('Creating tag in qBittorrent: %s', settings_dict['NO_STALLED_REMOVAL_QBIT_TAG']) + if not settings_dict['TEST_RUN']: + await rest_post(url=settings_dict['QBITTORRENT_URL']+'/torrents/createTags', data={'tags': settings_dict['NO_STALLED_REMOVAL_QBIT_TAG']}, headers={'content-type': 'application/x-www-form-urlencoded'}, cookies=settings_dict['QBIT_COOKIE']) # Start application while True: logger.verbose('-' * 50) - if RADARR_URL: await queue_cleaner('radarr', RADARR_URL, RADARR_KEY, RADARR_NAME, REMOVE_FAILED, REMOVE_STALLED, REMOVE_METADATA_MISSING, REMOVE_ORPHANS, REMOVE_UNMONITORED, PERMITTED_ATTEMPTS, NO_STALLED_REMOVAL_QBIT_TAG, QBITTORRENT_URL, defective_tracker, TEST_RUN) - if SONARR_URL: await queue_cleaner('sonarr', SONARR_URL, SONARR_KEY, SONARR_NAME, REMOVE_FAILED, REMOVE_STALLED, REMOVE_METADATA_MISSING, REMOVE_ORPHANS, REMOVE_UNMONITORED, PERMITTED_ATTEMPTS, NO_STALLED_REMOVAL_QBIT_TAG, QBITTORRENT_URL, defective_tracker, TEST_RUN) + if settings_dict['RADARR_URL']: await queue_cleaner(settings_dict, 'radarr', defective_tracker) + if settings_dict['SONARR_URL']: await queue_cleaner(settings_dict, 'sonarr', defective_tracker) logger.verbose('') logger.verbose('Queue clean-up complete!') - await asyncio.sleep(REMOVE_TIMER*60) + await asyncio.sleep(settings_dict['REMOVE_TIMER']*60) return if __name__ == '__main__': - instances = {RADARR_URL: {}} if RADARR_URL else {} + \ - {SONARR_URL: {}} if SONARR_URL else {} + instances = {settings_dict['RADARR_URL']: {}} if settings_dict['RADARR_URL'] else {} + \ + {settings_dict['SONARR_URL']: {}} if settings_dict['SONARR_URL'] else {} defective_tracker = Defective_Tracker(instances) asyncio.run(main()) diff --git a/src/queue_cleaner.py b/src/queue_cleaner.py index c2ed2c5..9fbebe9 100644 --- a/src/queue_cleaner.py +++ b/src/queue_cleaner.py @@ -4,6 +4,8 @@ logger = verboselogs.VerboseLogger(__name__) from src.utils.rest import (rest_get, rest_delete) import json from src.utils.nest_functions import (add_keys_nested_dict, nested_get) +import sys, os + class Deleted_Downloads: # Keeps track of which downloads have already been deleted (to not double-delete) def __init__(self, dict): @@ -16,21 +18,25 @@ async def get_queue(BASE_URL, API_KEY, params = {}): queue = await rest_get(f'{BASE_URL}/queue', API_KEY, {'page': '1', 'pageSize': totalRecords}|params) return queue -async def remove_failed(radarr_or_sonarr, BASE_URL, API_KEY, PERMITTED_ATTEMPTS, queue, deleted_downloads, TEST_RUN): +async def remove_failed(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads): # Detects failed and triggers delete. Does not add to blocklist + queue = await get_queue(BASE_URL, API_KEY) + if not queue: return 0 failedItems = [] for queueItem in queue['records']: if 'errorMessage' in queueItem and 'status' in queueItem: - if queueItem['status'] == 'failed' or \ + if queueItem['status'] == 'failed' or \ (queueItem['status'] == 'warning' and queueItem['errorMessage'] == 'The download is missing files'): - await remove_download(BASE_URL, API_KEY, queueItem['id'], queueItem['title'], queueItem['downloadId'], 'failed', False, deleted_downloads, TEST_RUN) + await remove_download(BASE_URL, API_KEY, queueItem['id'], queueItem['title'], queueItem['downloadId'], 'failed', False, deleted_downloads, settings_dict['TEST_RUN']) failedItems.append(queueItem) return len(failedItems) -async def remove_stalled(radarr_or_sonarr, BASE_URL, API_KEY, PERMITTED_ATTEMPTS, defective_tracker, queue, deleted_downloads, NO_STALLED_REMOVAL_QBIT_TAG, QBITTORRENT_URL, TEST_RUN): +async def remove_stalled(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads, defective_tracker): # Detects stalled and triggers repeat check and subsequent delete. Adds to blocklist - if QBITTORRENT_URL: - protected_dowloadItems = await rest_get(QBITTORRENT_URL+'/torrents/info','',{'tag': NO_STALLED_REMOVAL_QBIT_TAG}) + queue = await get_queue(BASE_URL, API_KEY) + if not queue: return 0 + if settings_dict['QBITTORRENT_URL']: + protected_dowloadItems = await rest_get(settings_dict['QBITTORRENT_URL']+'/torrents/info',params={'tag': settings_dict['NO_STALLED_REMOVAL_QBIT_TAG']}, cookies=settings_dict['QBIT_COOKIE'] ) protected_downloadIDs = [str.upper(item['hash']) for item in protected_dowloadItems] else: protected_downloadIDs = [] @@ -43,33 +49,41 @@ async def remove_stalled(radarr_or_sonarr, BASE_URL, API_KEY, PERMITTED_ATTEMPTS logger.verbose('>>> Detected stalled download, tagged not to be killed: %s',queueItem['title']) else: stalledItems.append(queueItem) - await check_permitted_attempts(stalledItems, 'stalled', True, deleted_downloads, BASE_URL, API_KEY, PERMITTED_ATTEMPTS, defective_tracker, TEST_RUN) + await check_permitted_attempts(settings_dict, stalledItems, 'stalled', True, deleted_downloads, BASE_URL, API_KEY, defective_tracker) return len(stalledItems) -async def remove_metadata_missing(radarr_or_sonarr, BASE_URL, API_KEY, PERMITTED_ATTEMPTS, defective_tracker, queue, deleted_downloads, TEST_RUN): +async def remove_metadata_missing(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads, defective_tracker): # Detects downloads stuck downloading meta data and triggers repeat check and subsequent delete. Adds to blocklist + queue = await get_queue(BASE_URL, API_KEY) + if not queue: return 0 missing_metadataItems = [] for queueItem in queue['records']: if 'errorMessage' in queueItem and 'status' in queueItem: if queueItem['status'] == 'queued' and \ queueItem['errorMessage'] == 'qBittorrent is downloading metadata': missing_metadataItems.append(queueItem) - await check_permitted_attempts(missing_metadataItems, 'missing metadata', True, deleted_downloads, BASE_URL, API_KEY, PERMITTED_ATTEMPTS, defective_tracker, TEST_RUN) + await check_permitted_attempts(settings_dict, missing_metadataItems, 'missing metadata', True, deleted_downloads, BASE_URL, API_KEY, defective_tracker) return len(missing_metadataItems) -async def remove_orphans(radarr_or_sonarr, BASE_URL, API_KEY, PERMITTED_ATTEMPTS, queue, deleted_downloads, TEST_RUN): +async def remove_orphans(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads): # Removes downloads belonging to movies/tv shows that have been deleted in the meantime full_queue = await get_queue(BASE_URL, API_KEY, params = {'includeUnknownMovieItems' if radarr_or_sonarr == 'radarr' else 'includeUnknownSeriesItems': 'true'}) if not full_queue: return 0 # By now the queue may be empty - full_queue_items = [{'id': queueItem['id'], 'title': queueItem['title']} for queueItem in full_queue['records']] - queue_ids = [queueItem['id'] for queueItem in queue['records']] - orphanItems = [{'id': queueItem['id'], 'title': queueItem['title']} for queueItem in full_queue_items if queueItem['id'] not in queue_ids] + queue = await get_queue(BASE_URL, API_KEY) + full_queue_items = [{'id': queueItem['id'], 'title': queueItem['title'], 'downloadId': queueItem['downloadId']} for queueItem in full_queue['records']] + if queue: + queue_ids = [queueItem['id'] for queueItem in queue['records']] + else: + queue_ids = [] + orphanItems = [{'id': queueItem['id'], 'title': queueItem['title'], 'downloadId': queueItem['downloadId']} for queueItem in full_queue_items if queueItem['id'] not in queue_ids] for queueItem in orphanItems: - await remove_download(BASE_URL, API_KEY, queueItem['id'], queueItem['title'], queueItem['downloadId'], 'orphan', False, deleted_downloads, TEST_RUN) + await remove_download(settings_dict, BASE_URL, API_KEY, queueItem['id'], queueItem['title'], queueItem['downloadId'], 'orphan', False, deleted_downloads) return len(orphanItems) -async def remove_unmonitored(radarr_or_sonarr, BASE_URL, API_KEY, PERMITTED_ATTEMPTS, queue, deleted_downloads, TEST_RUN): +async def remove_unmonitored(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads): # Removes downloads belonging to movies/tv shows that are not monitored + queue = await get_queue(BASE_URL, API_KEY) + if not queue: return 0 unmonitoredItems= [] downloadItems = [] for queueItem in queue['records']: @@ -81,10 +95,10 @@ async def remove_unmonitored(radarr_or_sonarr, BASE_URL, API_KEY, PERMITTED_ATTE monitored_downloadIds = [downloadItem['downloadId'] for downloadItem in downloadItems if downloadItem['monitored']] unmonitoredItems = [downloadItem for downloadItem in downloadItems if downloadItem['downloadId'] not in monitored_downloadIds] for unmonitoredItem in unmonitoredItems: - await remove_download(BASE_URL, API_KEY, queueItem['id'], queueItem['title'], queueItem['downloadId'], 'unmonitored', False, deleted_downloads, TEST_RUN) + await remove_download(settings_dict, BASE_URL, API_KEY, queueItem['id'], queueItem['title'], queueItem['downloadId'], 'unmonitored', False, deleted_downloads) return len(unmonitoredItems) -async def check_permitted_attempts(current_defective_items, failType, blocklist, deleted_downloads, BASE_URL, API_KEY, PERMITTED_ATTEMPTS, defective_tracker, TEST_RUN): +async def check_permitted_attempts(settings_dict, current_defective_items, failType, blocklist, deleted_downloads, BASE_URL, API_KEY, defective_tracker): # Checks if downloads are repeatedly found as stalled / stuck in metadata and if yes, deletes them # 1. Create list of currently defective current_defective = {} @@ -107,50 +121,63 @@ async def check_permitted_attempts(current_defective_items, failType, blocklist, await add_keys_nested_dict(defective_tracker.dict,[BASE_URL, failType, queueId], {'title': current_defective[queueId]['title'], 'downloadId': current_defective[queueId]['downloadId'], 'Attempts': 1}) if current_defective[queueId]['downloadId'] not in download_ids_stuck: download_ids_stuck.append(current_defective[queueId]['downloadId']) - logger.info('>>> Detected %s download (%s out of %s permitted times): %s', failType, str(defective_tracker.dict[BASE_URL][failType][queueId]['Attempts']), str(PERMITTED_ATTEMPTS), defective_tracker.dict[BASE_URL][failType][queueId]['title']) - if defective_tracker.dict[BASE_URL][failType][queueId]['Attempts'] > PERMITTED_ATTEMPTS: - await remove_download(BASE_URL, API_KEY, queueId, current_defective[queueId]['title'], current_defective[queueId]['downloadId'], failType, blocklist, deleted_downloads, TEST_RUN) + logger.info('>>> Detected %s download (%s out of %s permitted times): %s', failType, str(defective_tracker.dict[BASE_URL][failType][queueId]['Attempts']), str(settings_dict['PERMITTED_ATTEMPTS']), defective_tracker.dict[BASE_URL][failType][queueId]['title']) + if defective_tracker.dict[BASE_URL][failType][queueId]['Attempts'] > settings_dict['PERMITTED_ATTEMPTS']: + await remove_download(settings_dict, BASE_URL, API_KEY, queueId, current_defective[queueId]['title'], current_defective[queueId]['downloadId'], failType, blocklist, deleted_downloads) return -async def remove_download(BASE_URL, API_KEY, queueId, queueTitle, downloadId, failType, blocklist, deleted_downloads, TEST_RUN): +async def remove_download(settings_dict, BASE_URL, API_KEY, queueId, queueTitle, downloadId, failType, blocklist, deleted_downloads): # Removes downloads and creates log entry if downloadId not in deleted_downloads.dict: logger.info('>>> Removing %s download: %s', failType, queueTitle) - if not TEST_RUN: await rest_delete(f'{BASE_URL}/queue/{queueId}', API_KEY, {'removeFromClient': 'true', 'blocklist': blocklist}) + #if not settings_dict['TEST_RUN']: await rest_delete(f'{BASE_URL}/queue/{queueId}', API_KEY, {'removeFromClient': 'true', 'blocklist': blocklist}) deleted_downloads.dict.append(downloadId) return ########### MAIN FUNCTION ########### -async def queue_cleaner(radarr_or_sonarr, BASE_URL, API_KEY, NAME, REMOVE_FAILED, REMOVE_STALLED, REMOVE_METADATA_MISSING, REMOVE_ORPHANS, REMOVE_UNMONITORED, PERMITTED_ATTEMPTS, NO_STALLED_REMOVAL_QBIT_TAG, QBITTORRENT_URL, defective_tracker, TEST_RUN): +async def queue_cleaner(settings_dict, radarr_or_sonarr, defective_tracker): + # Read out correct instance depending on radarr/sonarr flag + run_dict = {} + if radarr_or_sonarr == 'radarr': + BASE_URL = settings_dict['RADARR_URL'] + API_KEY = settings_dict['RADARR_KEY'] + NAME = settings_dict['RADARR_NAME'] + else: + BASE_URL = settings_dict['SONARR_URL'] + API_KEY = settings_dict['SONARR_KEY'] + NAME = settings_dict['SONARR_NAME'] + # Cleans up the downloads queue logger.verbose('Cleaning queue on %s:', NAME) try: - queue = await get_queue(BASE_URL, API_KEY) - if not queue: + full_queue = await get_queue(BASE_URL, API_KEY, params = {'includeUnknownMovieItems' if radarr_or_sonarr == 'radarr' else 'includeUnknownSeriesItems': 'true'}) + if not full_queue: logger.verbose('>>> Queue is empty.') return - + deleted_downloads = Deleted_Downloads([]) items_detected = 0 - if REMOVE_FAILED: - items_detected += await remove_failed(radarr_or_sonarr, BASE_URL, API_KEY, PERMITTED_ATTEMPTS, queue, deleted_downloads, TEST_RUN) + if settings_dict['REMOVE_FAILED']: + items_detected += await remove_failed( settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads) - if REMOVE_STALLED: - items_detected += await remove_stalled(radarr_or_sonarr, BASE_URL, API_KEY, PERMITTED_ATTEMPTS, defective_tracker, queue, deleted_downloads, NO_STALLED_REMOVAL_QBIT_TAG, QBITTORRENT_URL, TEST_RUN) + if settings_dict['REMOVE_STALLED']: + items_detected += await remove_stalled( settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads, defective_tracker) - if REMOVE_METADATA_MISSING: - items_detected += await remove_metadata_missing(radarr_or_sonarr, BASE_URL, API_KEY, PERMITTED_ATTEMPTS, defective_tracker, queue, deleted_downloads, TEST_RUN) + if settings_dict['REMOVE_METADATA_MISSING']: + items_detected += await remove_metadata_missing( settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads, defective_tracker) - if REMOVE_ORPHANS: - items_detected += await remove_orphans(radarr_or_sonarr, BASE_URL, API_KEY, PERMITTED_ATTEMPTS, queue, deleted_downloads, TEST_RUN) + if settings_dict['REMOVE_ORPHANS']: + items_detected += await remove_orphans( settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads) - if REMOVE_UNMONITORED: - items_detected += await remove_unmonitored(radarr_or_sonarr, BASE_URL, API_KEY, PERMITTED_ATTEMPTS, queue, deleted_downloads, TEST_RUN) + if settings_dict['REMOVE_UNMONITORED']: + items_detected += await remove_unmonitored( settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads) if items_detected == 0: logger.verbose('>>> Queue is clean.') - except: - logger.warning('>>> Queue cleaning failed on %s.', NAME) + except Exception as error: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + logger.warning('>>> Queue cleaning failed on %s. (File: %s / Line: %s / Error Message: %s / Error Type: %s)', NAME, fname, exc_tb.tb_lineno, error, exc_type) diff --git a/src/utils/rest.py b/src/utils/rest.py index 993729c..561d62c 100644 --- a/src/utils/rest.py +++ b/src/utils/rest.py @@ -4,13 +4,13 @@ import asyncio import requests from requests.exceptions import RequestException import json -from config.config import (TEST_RUN) +from config.config import settings_dict # GET -async def rest_get(url, api_key, params=None): +async def rest_get(url, api_key=None, params=None, cookies=None): try: - headers = {'X-Api-Key': api_key} # | {'accept': 'application/json'} - response = await asyncio.get_event_loop().run_in_executor(None, lambda: requests.get(url, params=params, headers=headers)) + headers = {'X-Api-Key': api_key} if api_key else None + response = await asyncio.get_event_loop().run_in_executor(None, lambda: requests.get(url, params=params, headers=headers, cookies=cookies)) response.raise_for_status() return response.json() except RequestException as e: @@ -22,7 +22,7 @@ async def rest_get(url, api_key, params=None): # DELETE async def rest_delete(url, api_key, params=None): - if TEST_RUN: return + if settings_dict['TEST_RUN']: return try: headers = {'X-Api-Key': api_key} response = await asyncio.get_event_loop().run_in_executor(None, lambda: requests.delete(url, params=params, headers=headers)) @@ -39,7 +39,7 @@ async def rest_delete(url, api_key, params=None): # POST async def rest_post(url, data, headers): - if TEST_RUN: return + if settings_dict['TEST_RUN']: return try: response = await asyncio.get_event_loop().run_in_executor(None, lambda: requests.post(url, data=data, headers=headers)) response.raise_for_status() @@ -56,7 +56,7 @@ async def rest_post(url, data, headers): # PUT async def rest_put(url, api_key, data): - if TEST_RUN: return + if settings_dict['TEST_RUN']: return try: headers = {'X-Api-Key': api_key} | {"content-type": "application/json"} response = await asyncio.get_event_loop().run_in_executor(None, lambda: requests.put(url, data=data, headers=headers))