diff --git a/README.md b/README.md index 90924b4..4061ea9 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # **Decluttarr** ## Overview -Decluttarr keeps the radarr & sonarr queue free of stalled / redundant downloads. +Decluttarr keeps the radarr & sonarr & lidarr queue free of stalled / redundant downloads. Feature overview: - Automatically delete downloads that are stuck downloading metadata (& trigger download from another source) - Automatically delete failed downloads (& trigger download from another source) -- Automatically delete downloads belonging to Movies/TV shows that have been deleted in the meantime ('Orphan downloads') +- Automatically delete downloads belonging to Movies/TV shows/Music requests that have been deleted in the meantime ('Orphan downloads') - Automatically delete stalled downloads, after they have been found to be stalled multiple times in a row -- Automatically delete downloads belonging to Movies/TV shows that are unmonitored +- Automatically delete downloads belonging to Movies/TV/Music requests shows that are unmonitored You may run this locally by launch main.py, or by mounting it inside a docker container. A sample docker-compose.yml is included. @@ -26,9 +26,6 @@ If you want to run locally: 4) run main.py 5) Enjoy -## Known Limitations: -- None :-) - ## Credits - Script for detecting stalled downloads expanded on code by MattDGTL/sonarr-radarr-queue-cleaner - Script to read out config expanded on code by syncarr/syncarr diff --git a/config/config.conf-Example b/config/config.conf-Example index effd452..bd324aa 100644 --- a/config/config.conf-Example +++ b/config/config.conf-Example @@ -20,6 +20,10 @@ RADARR_KEY = $RADARR_KEY SONARR_URL = http://sonarr:8989 SONARR_KEY = $SONARR_KEY +[lidarr] +LIDARR_URL = http://lidarr:8686 +LIDARR_KEY = $LIDARR_KEY + [qbittorrent] QBITTORRENT_URL = http://qbittorrent:8080 QBITTORRENT_USERNAME = YourName or Empty diff --git a/config/config.conf-Explained b/config/config.conf-Explained index 10c3e18..e66fb2b 100644 --- a/config/config.conf-Explained +++ b/config/config.conf-Explained @@ -102,15 +102,21 @@ RADARR_KEY = XXXXX ################################# SONARR SECTION ################################# [sonarr] # Please see the documentation under the RADARR section - the explanations the same. -SONARR_URL = http://sonarrA:8989 +SONARR_URL = http://sonarr:8989 SONARR_KEY = XXXXX ################################# SONARR SECTION ################################# [sonarr] # Please see the documentation under the RADARR section - the explanations the same. -SONARR_URL = http://sonarrA:8989 +SONARR_URL = http://sonarr:8989 SONARR_KEY = XXXXX +################################# SONARR SECTION ################################# +[lidarr] +# Please see the documentation under the RADARR section - the explanations the same. +LIDARR_URL = http://lidarr:8686 +LIDARR_KEY = XXXXX + ################################# QBITTORRENT SECTION ################################# [qbittorrent] # Defines settings to connect with qBittorrent diff --git a/config/config.py b/config/config.py index a016703..01feeba 100644 --- a/config/config.py +++ b/config/config.py @@ -99,6 +99,11 @@ SONARR_URL = get_config_value('SONARR_URL', 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) + # qBittorrent QBITTORRENT_URL = get_config_value('QBITTORRENT_URL', 'qbittorrent', False, str, '') QBITTORRENT_USERNAME = get_config_value('QBITTORRENT_USERNAME', 'qbittorrent', False, str, '') @@ -106,13 +111,14 @@ QBITTORRENT_PASSWORD = get_config_value('QBITTORRENT_PASSWORD', ######################################################################################################################## ########### Validate settings -if not (RADARR_URL or SONARR_URL): - print(f'[ ERROR ]: No Radarr/Sonarr URLs specified (nothing to monitor)') +if not (RADARR_URL or SONARR_URL or LIDARR_URL): + print(f'[ ERROR ]: No Radarr/Sonarr/Lidarr URLs specified (nothing to monitor)') sys.exit(0) ########### Enrich setting variables if RADARR_URL: RADARR_URL += '/api/v3' if SONARR_URL: SONARR_URL += '/api/v3' +if LIDARR_URL: LIDARR_URL += '/api/v1' if QBITTORRENT_URL: QBITTORRENT_URL += '/api/v2' ########### Add Variables to Dictionary diff --git a/main.py b/main.py index 5ede4d5..a2cb61d 100644 --- a/main.py +++ b/main.py @@ -37,6 +37,12 @@ async def main(): except: settings_dict['SONARR_NAME'] = 'Sonarr' + try: + if settings_dict['LIDARR_URL']: + settings_dict['LIDARR_NAME'] = (await rest_get(settings_dict['LIDARR_URL']+'/system/status', settings_dict['LIDARR_KEY']))['instanceName'] + except: + settings_dict['LIDARR_NAME'] = 'Lidarr' + # Print Settings fmt = '{0.days} days {0.hours} hours {0.minutes} minutes' logger.info('#' * 50) @@ -58,7 +64,8 @@ async def main(): logger.info('') logger.info('*** Configured Instances ***') 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['SONARR_URL']: logger.info('%s: %s', settings_dict['SONARR_NAME'], settings_dict['SONARR_URL']) + if settings_dict['LIDARR_URL']: logger.info('%s: %s', settings_dict['LIDARR_NAME'], settings_dict['LIDARR_URL']) if settings_dict['QBITTORRENT_URL']: logger.info('qBittorrent: %s', settings_dict['QBITTORRENT_URL']) logger.info('') @@ -80,6 +87,14 @@ async def main(): error_occured = True logger.error('-- | %s *** Error: %s ***', settings_dict['SONARR_NAME'], error) + if settings_dict['LIDARR_URL']: + try: + await asyncio.get_event_loop().run_in_executor(None, lambda: requests.get(settings_dict['LIDARR_URL']+'/system/status', params=None, headers={'X-Api-Key': settings_dict['LIDARR_KEY']})) + logger.info('OK | %s', settings_dict['LIDARR_NAME']) + except Exception as error: + error_occured = True + logger.error('-- | %s *** Error: %s ***', settings_dict['LIDARR_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'})) @@ -127,6 +142,7 @@ async def main(): logger.verbose('-' * 50) 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) + if settings_dict['LIDARR_URL']: await queue_cleaner(settings_dict, 'lidarr', defective_tracker) logger.verbose('') logger.verbose('Queue clean-up complete!') await asyncio.sleep(settings_dict['REMOVE_TIMER']*60) @@ -134,7 +150,8 @@ async def main(): if __name__ == '__main__': instances = {settings_dict['RADARR_URL']: {}} if settings_dict['RADARR_URL'] else {} + \ - {settings_dict['SONARR_URL']: {}} if settings_dict['SONARR_URL'] else {} + {settings_dict['SONARR_URL']: {}} if settings_dict['SONARR_URL'] else {} + \ + {settings_dict['LIDARR_URL']: {}} if settings_dict['LIDARR_URL'] else {} defective_tracker = Defective_Tracker(instances) asyncio.run(main()) diff --git a/src/queue_cleaner.py b/src/queue_cleaner.py index 846ab64..bf237d4 100644 --- a/src/queue_cleaner.py +++ b/src/queue_cleaner.py @@ -19,7 +19,7 @@ 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(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads): +async def remove_failed(settings_dict, arr_type, 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 @@ -32,7 +32,7 @@ async def remove_failed(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, dele failedItems.append(queueItem) return len(failedItems) -async def remove_stalled(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads, defective_tracker): +async def remove_stalled(settings_dict, arr_type, BASE_URL, API_KEY, deleted_downloads, defective_tracker): # Detects stalled and triggers repeat check and subsequent delete. Adds to blocklist queue = await get_queue(BASE_URL, API_KEY) if not queue: return 0 @@ -56,7 +56,7 @@ async def remove_stalled(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, del logger.debug('remove_stalled/queue OUT: %s', str(queue)) return len(stalledItems) -async def test_remove_ALL(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads, defective_tracker): +async def test_remove_ALL(settings_dict, arr_type, BASE_URL, API_KEY, deleted_downloads, defective_tracker): # Detects stalled and triggers repeat check and subsequent delete. Adds to blocklist queue = await get_queue(BASE_URL, API_KEY) if not queue: return 0 @@ -75,7 +75,7 @@ async def test_remove_ALL(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, de return len(stalledItems) -async def remove_metadata_missing(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads, defective_tracker): +async def remove_metadata_missing(settings_dict, arr_type, 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 @@ -91,9 +91,9 @@ async def remove_metadata_missing(settings_dict, radarr_or_sonarr, BASE_URL, API logger.debug('remove_metadata_missing/queue OUT: %s', str(queue)) return len(missing_metadataItems) -async def remove_orphans(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads): +async def remove_orphans(settings_dict, arr_type, BASE_URL, API_KEY, deleted_downloads, full_queue_param): # 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'}) + full_queue = await get_queue(BASE_URL, API_KEY, params = {full_queue_param: True}) if not full_queue: return 0 # By now the queue may be empty 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']] @@ -106,17 +106,19 @@ async def remove_orphans(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, del 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(settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads): +async def remove_unmonitored(settings_dict, arr_type, 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']: - if radarr_or_sonarr == 'sonarr': + if arr_type == 'sonarr': monitored = (await rest_get(f'{BASE_URL}/episode/{str(queueItem["episodeId"])}', API_KEY))['monitored'] - else: + elif arr_type == 'radarr': monitored = (await rest_get(f'{BASE_URL}/movie/{str(queueItem["movieId"])}', API_KEY))['monitored'] + elif arr_type == 'lidarr': + monitored = (await rest_get(f'{BASE_URL}/album/{str(queueItem["albumId"])}', API_KEY))['monitored'] downloadItems.append({'downloadId': queueItem['downloadId'], 'id': queueItem['id'], 'monitored': monitored}) monitored_downloadIds = [downloadItem['downloadId'] for downloadItem in downloadItems if downloadItem['monitored']] unmonitoredItems = [downloadItem for downloadItem in downloadItems if downloadItem['downloadId'] not in monitored_downloadIds] @@ -167,22 +169,33 @@ async def remove_download(settings_dict, BASE_URL, API_KEY, queueId, queueTitle, return ########### MAIN FUNCTION ########### -async def queue_cleaner(settings_dict, radarr_or_sonarr, defective_tracker): +async def queue_cleaner(settings_dict, arr_type, defective_tracker): # Read out correct instance depending on radarr/sonarr flag run_dict = {} - if radarr_or_sonarr == 'radarr': + if arr_type == 'radarr': BASE_URL = settings_dict['RADARR_URL'] API_KEY = settings_dict['RADARR_KEY'] NAME = settings_dict['RADARR_NAME'] - else: + full_queue_param = 'includeUnknownMovieItems' + elif arr_type == 'sonarr': BASE_URL = settings_dict['SONARR_URL'] API_KEY = settings_dict['SONARR_KEY'] NAME = settings_dict['SONARR_NAME'] - + full_queue_param = 'includeUnknownSeriesItems' + elif arr_type == 'lidarr': + BASE_URL = settings_dict['LIDARR_URL'] + API_KEY = settings_dict['LIDARR_KEY'] + NAME = settings_dict['LIDARR_NAME'] + full_queue_param = 'includeUnknownArtistItems' + else: + logger.error('Unknown arr_type specified, exiting: %s', str(arr_type)) + sys.exit() + # Cleans up the downloads queue logger.verbose('Cleaning queue on %s:', NAME) try: - full_queue = await get_queue(BASE_URL, API_KEY, params = {'includeUnknownMovieItems' if radarr_or_sonarr == 'radarr' else 'includeUnknownSeriesItems': 'true'}) + + full_queue = await get_queue(BASE_URL, API_KEY, params = {full_queue_param: True}) if not full_queue: logger.verbose('>>> Queue is empty.') return @@ -190,22 +203,22 @@ async def queue_cleaner(settings_dict, radarr_or_sonarr, defective_tracker): deleted_downloads = Deleted_Downloads([]) items_detected = 0 - #items_detected += await test_remove_ALL( settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads, defective_tracker) + #items_detected += await test_remove_ALL( settings_dict, arr_type, BASE_URL, API_KEY, deleted_downloads, defective_tracker) if settings_dict['REMOVE_FAILED']: - items_detected += await remove_failed( settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads) + items_detected += await remove_failed( settings_dict, arr_type, BASE_URL, API_KEY, deleted_downloads) if settings_dict['REMOVE_STALLED']: - items_detected += await remove_stalled( settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads, defective_tracker) + items_detected += await remove_stalled( settings_dict, arr_type, BASE_URL, API_KEY, deleted_downloads, defective_tracker) 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) + items_detected += await remove_metadata_missing( settings_dict, arr_type, BASE_URL, API_KEY, deleted_downloads, defective_tracker) if settings_dict['REMOVE_ORPHANS']: - items_detected += await remove_orphans( settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads) + items_detected += await remove_orphans( settings_dict, arr_type, BASE_URL, API_KEY, deleted_downloads, full_queue_param) if settings_dict['REMOVE_UNMONITORED']: - items_detected += await remove_unmonitored( settings_dict, radarr_or_sonarr, BASE_URL, API_KEY, deleted_downloads) + items_detected += await remove_unmonitored( settings_dict, arr_type, BASE_URL, API_KEY, deleted_downloads) if items_detected == 0: logger.verbose('>>> Queue is clean.')