diff --git a/README.md b/README.md index eb9c143..4a54e2a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Syncarr -Syncs two Radarr/Sonarr/Lidarr servers through the web API. Useful for syncing a 4k radarr/sonarr instance to a 1080p radarr/sonarr instance. +Syncs two Radarr/Sonarr/Lidarr servers through the web API. Useful for syncing a 4k Radarr/Sonarr instance to a 1080p Radarr/Sonarr instance. * Supports Radarr/Sonarr version 2 and 3. * Can sync by `profile` name or `profile_id` @@ -8,11 +8,12 @@ Syncs two Radarr/Sonarr/Lidarr servers through the web API. Useful for syncing a * Can set interval for syncing * Support two way sync (one way by default) * Set language profiles (Sonarr v3 only) +* Filter syncing by content file quality (Radarr only) ## Configuration 1. Edit the config.conf file and enter your servers URLs and API keys for each server. - 2. Add the profile name (case insensitive) and movie path for the radarr instance the movies will be synced to: + 2. Add the profile name (case insensitive) and movie path for the Radarr instance the movies will be synced to: ```ini [radarrA] url = https://4k.example.com:443 @@ -24,7 +25,7 @@ Syncs two Radarr/Sonarr/Lidarr servers through the web API. Useful for syncing a profile = 1080p path = /data/Movies ``` - 3. Or if you want to sync two sonarr instances: + 3. Or if you want to sync two Sonarr instances: ```ini [sonarrA] url = https://4k.example.com:443 @@ -36,7 +37,7 @@ Syncs two Radarr/Sonarr/Lidarr servers through the web API. Useful for syncing a profile = 1080p path = /data/Shows - 4. Or if you want to sync two lidarr instances: + 4. Or if you want to sync two Lidarr instances: ```ini [lidarrA] url = https://lossless.example.com:443 @@ -49,17 +50,19 @@ Syncs two Radarr/Sonarr/Lidarr servers through the web API. Useful for syncing a path = /data/Music ``` - **Note** you cannot have a mix of radarr, lidarr, or sonarr config setups at the same time. + **Note** you cannot have a mix of Radarr, Lidarr, or Sonarr config setups at the same time. 5. Optional Configuration ```ini - [sonarrA] + [*arrA] url = http://127.0.0.1:8080 key = XXXXX profile_filter = 1080p # add a filter to only sync contents belonging to this profile (can set by profile_filter_id as well) - quality_match = HD- # (radarr only) regex match to only sync content that matches the set quality (ie if set to 1080p then only movies with matching downloaded quality of 1080p will be synced) + quality_match = HD- # (Radarr only) regex match to only sync content that matches the set quality (ie if set to 1080p then only movies with matching downloaded quality of 1080p will be synced) + tag_filter = Horror # (Sonarr only) sync movies by tag name (seperate multiple tags by comma (no spaces) ie horror,comedy,action) + tag_filter_id = 2 # (Sonarr only) sync movies by tag id (seperate multiple tags by comma (no spaces) ie 2,3,4) - [sonarrB] + [*arrB] url = http://127.0.0.1:8080 key = XXXXX profile_id = 1 # Syncarr will try to find id from name but you can specify the id directly if you want @@ -78,7 +81,7 @@ Syncs two Radarr/Sonarr/Lidarr servers through the web API. Useful for syncing a ## Requirements * Python 3.6 or greater - * 2x Radarr, Sonarr, or Lidarr servers + * 2 Radarr, Sonarr, or Lidarr servers --- diff --git a/config.py b/config.py index 531887e..fef46fa 100644 --- a/config.py +++ b/config.py @@ -100,6 +100,8 @@ sonarrA_profile = get_config_value('SONARR_A_PROFILE', 'profile', 'sonarrA') sonarrA_profile_id = get_config_value('SONARR_A_PROFILE_ID', 'profile_id', 'sonarrA') sonarrA_profile_filter = get_config_value('SONARR_A_PROFILE_FILTER', 'profile_filter', 'sonarrA') sonarrA_profile_filter_id = get_config_value('SONARR_A_PROFILE_FILTER_ID', 'profile_filter_id', 'sonarrA') +sonarrA_tag_filter = get_config_value('SONARR_A_TAG_FILTER', 'tag_filter', 'sonarrA') +sonarrA_tag_filter_id = get_config_value('SONARR_A_TAG_FILTER_ID', 'tag_filter_id', 'sonarrA') sonarrA_language = get_config_value('SONARR_A_LANGUAGE', 'language', 'sonarrA') sonarrA_language_id = get_config_value('SONARR_A_LANGUAGE_ID', 'language_id', 'sonarrA') sonarrA_quality_match = get_config_value('SONARR_A_QUALITY_MATCH', 'quality_match', 'sonarrA') @@ -111,6 +113,8 @@ sonarrB_profile = get_config_value('SONARR_B_PROFILE', 'profile', 'sonarrB') sonarrB_profile_id = get_config_value('SONARR_B_PROFILE_ID', 'profile_id', 'sonarrB') sonarrB_profile_filter = get_config_value('SONARR_B_PROFILE_FILTER', 'profile_filter', 'sonarrB') sonarrB_profile_filter_id = get_config_value('SONARR_B_PROFILE_FILTER_ID', 'profile_filter_id', 'sonarrB') +sonarrB_tag_filter = get_config_value('SONARR_B_TAG_FILTER', 'tag_filter', 'sonarrB') +sonarrB_tag_filter_id = get_config_value('SONARR_B_TAG_FILTER_ID', 'tag_filter_id', 'sonarrB') sonarrB_language = get_config_value('SONARR_B_LANGUAGE', 'language', 'sonarrB') sonarrB_language_id = get_config_value('SONARR_B_LANGUAGE_ID', 'language_id', 'sonarrB') sonarrB_quality_match = get_config_value('SONARR_B_QUALITY_MATCH', 'quality_match', 'sonarrB') @@ -228,6 +232,8 @@ instanceA_profile_filter_id = '' instanceA_language = '' instanceA_language_id = '' instanceA_quality_match = '' +instanceA_tag_filter = '' +instanceA_tag_filter_id = '' instanceB_url = '' instanceB_key = '' @@ -239,16 +245,18 @@ instanceB_profile_filter_id = '' instanceB_language = '' instanceB_language_id = '' instanceB_quality_match = '' +instanceB_tag_filter = '' +instanceB_tag_filter_id = '' api_version = '' # we are going to detect what API version we are on tested_api_version = False # only get api version once - api_content_path = '' # url path to add content api_profile_path = '' # url path to get quality profiles api_status_path = '' # url path to check on server status api_language_path = '' # url to get lanaguge profiles +api_tag_path = '' # url to get tag profiles is_radarr = False is_sonarr = False @@ -328,6 +336,8 @@ elif sonarrA_url and sonarrB_url: instanceA_profile_filter_id = sonarrA_profile_filter_id instanceA_language = sonarrA_language instanceA_language_id = sonarrA_language_id + instanceA_tag_filter = sonarrA_tag_filter and sonarrA_tag_filter.split(',') + instanceA_tag_filter_id = sonarrA_tag_filter_id and sonarrA_tag_filter_id.split(',') instanceA_quality_match = sonarrA_quality_match instanceB_url = sonarrB_url @@ -339,6 +349,8 @@ elif sonarrA_url and sonarrB_url: instanceB_profile_filter_id = sonarrB_profile_filter_id instanceB_language = sonarrB_language instanceB_language_id = sonarrB_language_id + instanceB_tag_filter = sonarrB_tag_filter and sonarrB_tag_filter.split(',') + instanceB_tag_filter_id = sonarrB_tag_filter_id and sonarrB_tag_filter_id.split(',') instanceB_quality_match = sonarrB_quality_match api_version = V3_API_PATH # for sonarr try v3 first @@ -346,6 +358,7 @@ elif sonarrA_url and sonarrB_url: api_profile_path = 'qualityprofile' api_status_path = 'system/status' api_language_path = 'languageprofile' + api_tag_path = 'tag' content_id_key = 'tvdbId' is_sonarr = True @@ -353,7 +366,6 @@ elif sonarrA_url and sonarrB_url: ######################################################################################################################## # path generators - def get_path(instance_url, api_path, key, changed_api_version=False): global api_version, api_profile_path @@ -378,34 +390,34 @@ def get_path(instance_url, api_path, key, changed_api_version=False): url = f"{instance_url}/api/{api_version}{api_path}?apikey={key}" return url - def get_status_path(instance_url, key, changed_api_version): url = get_path(instance_url, api_status_path, key, changed_api_version) logger.debug('get_status_path: {}'.format(url)) return url - def get_content_path(instance_url, key): url = get_path(instance_url, api_content_path, key) logger.debug('get_content_path: {}'.format(url)) return url - def get_language_path(instance_url, key): url = get_path(instance_url, api_language_path, key) logger.debug('get_language_path: {}'.format(url)) return url - def get_profile_path(instance_url, key): url = get_path(instance_url, api_profile_path, key) logger.debug('get_profile_path: {}'.format(url)) return url +def get_tag_path(instance_url, key): + url = get_path(instance_url, api_tag_path, key) + logger.debug('get_tag_path: {}'.format(url)) + return url + ######################################################################################################################## # check for required fields - logger.debug({ 'instanceA_url': instanceA_url, 'instanceA_key': instanceA_key, @@ -416,6 +428,8 @@ logger.debug({ 'instanceA_profile_filter_id': instanceA_profile_filter_id, 'instanceA_language': instanceA_language, 'instanceA_language_id': instanceA_language_id, + 'instanceA_tag_filter': instanceA_tag_filter, + 'instanceA_tag_filter_id': instanceA_tag_filter_id, 'instanceA_quality_match': instanceA_quality_match, 'instanceB_url': instanceB_url, @@ -427,6 +441,8 @@ logger.debug({ 'instanceB_profile_filter_id': instanceB_profile_filter_id, 'instanceB_language': instanceB_language, 'instanceB_language_id': instanceB_language_id, + 'instanceB_tag_filter': instanceB_tag_filter, + 'instanceB_tag_filter_id': instanceB_tag_filter_id, 'instanceB_quality_match': instanceB_quality_match, 'api_content_path': api_content_path, diff --git a/index.py b/index.py index 866d686..ec6e913 100644 --- a/index.py +++ b/index.py @@ -13,13 +13,15 @@ from config import ( instanceA_url, instanceA_key, instanceA_path, instanceA_profile, instanceA_profile_id, instanceA_profile_filter, instanceA_profile_filter_id, instanceA_language_id, instanceA_language, instanceA_quality_match, + instanceA_tag_filter_id, instanceA_tag_filter, instanceB_url, instanceB_key, instanceB_path, instanceB_profile, instanceB_profile_id, instanceB_profile_filter, instanceB_profile_filter_id, instanceB_language_id, instanceB_language, instanceB_quality_match, + instanceB_tag_filter_id, instanceB_tag_filter, content_id_key, logger, is_sonarr, is_radarr, is_lidarr, - get_status_path, get_content_path, get_profile_path, get_language_path, + get_status_path, get_content_path, get_profile_path, get_language_path, get_tag_path, is_in_docker, instance_sync_interval_seconds, sync_bidirectionally, auto_search, monitor_new_content, @@ -106,6 +108,7 @@ def get_quality_profiles(instance_session, instance_url, instance_key): logger.error(f'Could not decode profile id from {instance_profile_url}') sys.exit(0) + def get_profile_from_id(instance_session, instance_url, instance_key, instance_profile, instance_name=''): instance_profiles = get_quality_profiles(instance_session=instance_session, instance_url=instance_url, instance_key=instance_key) @@ -120,27 +123,57 @@ def get_profile_from_id(instance_session, instance_url, instance_key, instance_p return instance_profile_id +def get_tag_from_id(instance_session, instance_url, instance_key, instance_tag, instance_name=''): + instance_tag_url = get_tag_path(instance_url, instance_key) + tag_response = instance_session.get(instance_tag_url) + if tag_response.status_code != 200: + logger.error(f'Could not get tag id from (instance{instance_name}) {instance_tag_url} - only works on Sonarr') + sys.exit(0) + + instance_tags = None + try: + instance_tags = tag_response.json() + except: + logger.error(f'Could not decode tag id from {instance_tag_url}') + sys.exit(0) + + tag_ids = [] + for item in instance_tags: + for instance_item in instance_tag: + if item.get('label').lower() == instance_item.lower(): + tag_ids.append(item) + + if not tag_ids: + logger.error(f'Could not find tag_id for instance {instance_name} and tag {instance_tags}') + sys.exit(0) + + instance_tag_ids = [tag.get('id') for tag in tag_ids] + logger.debug(f'found id "{instance_tag_ids}" from tag "{instance_tag}" for instance {instance_name}') + + if instance_tag_ids is None: + logger.error(f'tag_id is None for instance {instance_name} and tag {instance_tag}') + sys.exit(0) + + return instance_tag_ids + + def get_language_from_id(instance_session, instance_url, instance_key, instance_language, instance_name=''): instance_language_url = get_language_path(instance_url, instance_key) language_response = instance_session.get(instance_language_url) if language_response.status_code != 200: - logger.error( - f'Could not get language id from (instance{instance_name}) {instance_language_url} - only works on sonarr v3') + logger.error(f'Could not get language id from (instance{instance_name}) {instance_language_url} - only works on sonarr v3') sys.exit(0) instance_languages = None try: instance_languages = language_response.json() except: - logger.error( - f'Could not decode language id from {instance_language_url}') + logger.error(f'Could not decode language id from {instance_language_url}') sys.exit(0) instance_languages = instance_languages[0]['languages'] - language = next((item for item in instance_languages - if item.get('language', {}).get('name').lower() == instance_language.lower()), False) + language = next((item for item in instance_languages if item.get('language', {}).get('name').lower() == instance_language.lower()), False) - logger.error(language) if not language: logger.error(f'Could not find language_id for instance {instance_name} and language {instance_language}') sys.exit(0) @@ -157,8 +190,9 @@ def get_language_from_id(instance_session, instance_url, instance_key, instance_ def sync_servers(instanceA_contents, instanceB_language_id, instanceB_contentIds, instanceB_path, instanceB_profile_id, instanceA_profile_filter_id, - instanceB_session, instanceB_url, instanceB_key, instanceA_quality_match): - global is_radarr + instanceB_session, instanceB_url, instanceB_key, instanceA_quality_match, + instanceA_tag_filter_id): + global is_radarr, is_sonarr search_ids = [] # if given instance A profile id then we want to filter out content without that id @@ -184,6 +218,13 @@ def sync_servers(instanceA_contents, instanceB_language_id, instanceB_contentIds logging.debug(f'Skipping content {title} - mismatched content_quality {content_quality} with instanceA_quality_match {instanceA_quality_match}') continue + # if given tag filter then filter by tag - (Sonarr only) + if is_sonarr and instanceA_tag_filter_id: + content_tag_ids = content.get('tags') + if not (set(content_tag_ids) & set(instanceA_tag_filter_id)): + logging.debug(f'Skipping content {title} - mismatched content_tag_ids {content_tag_ids} with instanceA_tag_filter_id {instanceA_tag_filter_id}') + continue + logging.info(f'syncing content title "{title}"') # get the POST payload and sync content to instance B @@ -280,7 +321,7 @@ def check_status(instance_session, instance_url, instance_key, instance_name='', def sync_content(): - global instanceA_profile_id, instanceA_profile, instanceB_profile_id, instanceB_profile, instanceA_profile_filter, instanceA_profile_filter_id, instanceB_profile_filter, instanceB_profile_filter_id, tested_api_version, instanceA_language_id, instanceA_language, instanceB_language_id, instanceB_language, instanceA_quality_match, instanceB_quality_match + global instanceA_profile_id, instanceA_profile, instanceB_profile_id, instanceB_profile, instanceA_profile_filter, instanceA_profile_filter_id, instanceB_profile_filter, instanceB_profile_filter_id, tested_api_version, instanceA_language_id, instanceA_language, instanceB_language_id, instanceB_language, instanceA_quality_match, instanceB_quality_match, is_sonarr, instanceA_tag_filter_id, instanceA_tag_filter, instanceB_tag_filter_id, instanceB_tag_filter # get sessions instanceA_session = requests.Session() @@ -295,11 +336,9 @@ def sync_content(): # if given a profile instead of a profile id then try to find the profile id if not instanceA_profile_id and instanceA_profile: - instanceA_profile_id = get_profile_from_id( - instanceA_session, instanceA_url, instanceA_key, instanceA_profile, 'A') + instanceA_profile_id = get_profile_from_id(instanceA_session, instanceA_url, instanceA_key, instanceA_profile, 'A') if not instanceB_profile_id and instanceB_profile: - instanceB_profile_id = get_profile_from_id( - instanceB_session, instanceB_url, instanceB_key, instanceB_profile, 'B') + instanceB_profile_id = get_profile_from_id(instanceB_session, instanceB_url, instanceB_key, instanceB_profile, 'B') logger.debug({ 'instanceA_profile_id': instanceA_profile_id, 'instanceA_profile': instanceA_profile, @@ -309,11 +348,9 @@ def sync_content(): # do the same for profile id filters if they exist if not instanceA_profile_filter_id and instanceA_profile_filter: - instanceA_profile_filter_id = get_profile_from_id( - instanceA_session, instanceA_url, instanceA_key, instanceA_profile_filter, 'A') + instanceA_profile_filter_id = get_profile_from_id(instanceA_session, instanceA_url, instanceA_key, instanceA_profile_filter, 'A') if not instanceB_profile_filter_id and instanceB_profile_filter: - instanceB_profile_filter_id = get_profile_from_id( - instanceB_session, instanceB_url, instanceB_key, instanceB_profile_filter, 'B') + instanceB_profile_filter_id = get_profile_from_id(instanceB_session, instanceB_url, instanceB_key, instanceB_profile_filter, 'B') logger.debug({ 'instanceAprofile_filter_id': instanceA_profile_filter_id, 'instanceAprofile_filter': instanceA_profile_filter, @@ -321,8 +358,20 @@ def sync_content(): 'instanceBprofile_filter': instanceB_profile_filter, }) - # if given language instead of language id then try to find the lanaguage id - # only for sonarr v3 + # do the same for tag id filters if they exist - (only Sonarr) + if is_sonarr: + if not instanceA_tag_filter_id and instanceA_tag_filter: + instanceA_tag_filter_id = get_tag_from_id(instanceA_session, instanceA_url, instanceA_key, instanceA_tag_filter, 'A') + if not instanceB_tag_filter_id and instanceB_tag_filter: + instanceB_tag_filter_id = get_tag_from_id(instanceB_session, instanceB_url, instanceB_key, instanceA_tag_filter, 'B') + logger.debug({ + 'instanceA_tag_filter': instanceA_tag_filter, + 'instanceA_profile_filter': instanceA_profile_filter, + 'instanceB_tag_filter_id': instanceB_tag_filter_id, + 'instanceB_tag_filter': instanceB_tag_filter, + }) + + # if given language instead of language id then try to find the lanaguage id - (only Sonarr v3) if is_sonarr: if not instanceA_language_id and instanceA_language: instanceA_language_id = get_language_from_id( @@ -341,15 +390,14 @@ def sync_content(): instance_language=instanceB_language, instance_name='B' ) - - logger.debug({ - 'instanceA_language_id': instanceA_language_id, - 'instanceA_language': instanceA_language, - 'instanceB_language_id': instanceB_language_id, - 'instanceB_language': instanceB_language, - 'is_sonarr': is_sonarr, - 'api_version': api_version, - }) + logger.debug({ + 'instanceA_language_id': instanceA_language_id, + 'instanceA_language': instanceA_language, + 'instanceB_language_id': instanceB_language_id, + 'instanceB_language': instanceB_language, + 'is_sonarr': is_sonarr, + 'api_version': api_version, + }) # get contents to compare instanceA_contents, instanceA_contentIds = get_instance_contents(instanceA_url, instanceA_key, instanceA_session, instance_name='A') @@ -367,6 +415,7 @@ def sync_content(): instanceA_profile_filter_id=instanceA_profile_filter_id, instanceB_key=instanceB_key, instanceA_quality_match=instanceA_quality_match, + instanceA_tag_filter_id=instanceA_tag_filter_id, ) # if given bidirectional flag then sync from instance B to instance A @@ -384,6 +433,7 @@ def sync_content(): instanceA_profile_filter_id=instanceB_profile_filter_id, instanceB_key=instanceA_key, instanceA_quality_match=instanceB_quality_match, + instanceA_tag_filter_id=instanceB_tag_filter_id, ) ########################################################################################################################