From bae4d0c45cd097c6290735eafaf0eb133993c9ec Mon Sep 17 00:00:00 2001 From: santiagosayshey Date: Sat, 20 Jan 2024 12:54:49 +1030 Subject: [PATCH] Implemented syncing functionality for Radarr and Sonarr instances - config changes to allow for single app sync - new command to sync - some code reformatting in import/export to allow for temp files - updated readme to reflect syncing capability --- README.md | 17 ++++--- config.json | 39 ++++++++++----- export.py => exportarr.py | 15 +++--- import.py => importarr.py | 101 +++++++++++++++++++++----------------- syncarr.py | 39 +++++++++++++++ 5 files changed, 139 insertions(+), 72 deletions(-) rename export.py => exportarr.py (90%) rename import.py => importarr.py (64%) create mode 100644 syncarr.py diff --git a/README.md b/README.md index 664aad2..9683fd1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Profilarr -Profilarr is a Python-based tool designed to add import / export functionality to the \*arr suite. It offers a user-friendly way to export and import custom formats and quality profiles between Radarr and Sonarr installations. +Profilarr is a Python-based tool designed to add import/export functionality to the \*arr suite. It offers a user-friendly way to export and import custom formats and quality profiles between Radarr and Sonarr installations. ## ⚠️ Before Continuing @@ -28,24 +28,29 @@ Profilarr is a Python-based tool designed to add import / export functionality t - If importing / exporting, only change the master installation's API key and base URL. - If syncing, add the API keys and base URLs of all instances you want to sync. - The master install will be the one that all other instances sync to. - - Sync coming soon (so don't worry about it for now) 4. Save the changes. ## 🚀 Usage ### Exporting -1. Run `python export.py` in your command line interface. +1. Run `python exportarr.py` in your command line interface. 2. Follow the on-screen prompts to select the app (Radarr or Sonarr) and the data (Custom Formats or Quality Profiles) you want to export. 3. Exported data will be saved in respective directories within the tool's folder. ### Importing -1. Run `python import.py` in your command line interface. +1. Run `python importarr.py` in your command line interface. 2. Follow the on-screen prompts to select the app and the data you want to import. 3. Choose the specific file for Custom Formats or select a profile for Quality Profiles. 4. The data will be imported to your selected Radarr or Sonarr installation. +### Syncing + +1. Run `python syncarr.py` in your command line interface. +2. The script will automatically export data from the master instance and import it to all other instances specified in `config.json`. +3. This feature is designed to manage multiple Radarr/Sonarr instances, syncing profiles and formats seamlessly. + ### Radarr and Sonarr Compatibility - Custom formats _can_ be imported and exported between Radarr and Sonarr (but might not work as expected). @@ -54,9 +59,5 @@ Profilarr is a Python-based tool designed to add import / export functionality t ## 🌟 Upcoming Features - **Lidarr Support:** Expand functionality to include Lidarr, allowing users to manage music quality profiles and custom formats. - -- **Syncing Multiple Instances:** Simplify the management of multiple Radarr/Sonarr instances. This feature aims to enable seamless syncing of profiles and formats across different installations. - - **User Interface (UI):** Development of a graphical user interface (GUI) for easier and more intuitive interaction with Profilarr. This UI will cater to users who prefer graphical over command-line interactions. - - **Automatic Updates:** Implement an auto-update mechanism for Profilarr, ensuring users always have access to the latest features, improvements, and bug fixes without manual intervention. diff --git a/config.json b/config.json index 7764a64..f42dfea 100644 --- a/config.json +++ b/config.json @@ -2,24 +2,37 @@ "master": { "sonarr": { "base_url": "http://localhost:8989", - "api_key": "API_GOES_HERE" + "api_key": "API_KEY_HERE" }, "radarr": { "base_url": "http://localhost:7878", - "api_key": "API_GOES_HERE" + "api_key": "API_KEY_HERE" } }, - "extra_installations": [ - { - "name": "example", - "sonarr": { - "base_url": "http://otherhost1:8989", - "api_key": "API_GOES_HERE" + "extra_installations": { + "radarr": [ + { + "name": "extra_radarr1", + "base_url": "http://localhost:7788", + "api_key": "API_KEY_HERE" }, - "radarr": { - "base_url": "http://otherhost1:7878", - "api_key": "API_GOES_HERE" + { + "name": "extra_radarr2", + "base_url": "http://localhost:7789", + "api_key": "API_KEY_HERE" } - } - ] + ], + "sonarr": [ + { + "name": "extra_sonarr1", + "base_url": "http://localhost:8988", + "api_key": "API_KEY_HERE" + }, + { + "name": "extra_sonarr2", + "base_url": "http://localhost:8987", + "api_key": "API_KEY_HERE" + } + ] + } } diff --git a/export.py b/exportarr.py similarity index 90% rename from export.py rename to exportarr.py index 727a0bb..a520859 100644 --- a/export.py +++ b/exportarr.py @@ -70,8 +70,8 @@ def ensure_directory_exists(directory): os.makedirs(directory) print(Colors.OKBLUE + f"Created directory: {directory}" + Colors.ENDC) -def export_cf(source): - ensure_directory_exists('./custom_formats') # Ensure the directory exists +def export_cf(source, save_path='./custom_formats'): + ensure_directory_exists(save_path) # Ensure the directory exists with the given save_path base_url, api_key = get_app_config(source) headers = {"X-Api-Key": api_key} @@ -93,9 +93,10 @@ def export_cf(source): custom_format.pop('id', None) saved_formats.append(custom_format['name']) - file_path = f'./custom_formats/Custom Formats ({source.capitalize()}).json' + file_path = f'{save_path}/Custom Formats ({source.capitalize()}).json' with open(file_path, 'w') as f: json.dump(data, f, indent=4) + print_saved_items(saved_formats, "Custom Formats") print(Colors.OKGREEN + f"Saved to '{file_path}'" + Colors.ENDC) print() @@ -107,8 +108,8 @@ def export_cf(source): -def export_qf(source): - ensure_directory_exists('./profiles') # Ensure the directory exists +def export_qf(source, save_path='./profiles'): + ensure_directory_exists(save_path) # Ensure the directory exists with the given save_path base_url, api_key = get_app_config(source) headers = {"X-Api-Key": api_key} @@ -132,13 +133,13 @@ def export_qf(source): profile_name = profile.get('name', 'unnamed_profile') profile_name = sanitize_filename(profile_name) profile_filename = f"{profile_name} ({source.capitalize()}).json" - profile_filepath = os.path.join('./profiles', profile_filename) + profile_filepath = os.path.join(save_path, profile_filename) saved_profiles.append(profile_name) with open(profile_filepath, 'w') as file: json.dump([profile], file, indent=4) print_saved_items(saved_profiles, "Quality Profiles") - print(Colors.OKGREEN + "Saved to the ./profiles directory" + Colors.ENDC) + print(Colors.OKGREEN + f"Saved to '{profile_filepath}'" + Colors.ENDC) print() else: handle_response_errors(response) diff --git a/import.py b/importarr.py similarity index 64% rename from import.py rename to importarr.py index 83b0d32..72250d3 100644 --- a/import.py +++ b/importarr.py @@ -56,7 +56,7 @@ def select_file(directory): choice = int(input("Select a file to import: ")) return files[choice - 1] -def import_custom_formats(source_config): +def import_custom_formats(source_config, import_path='./custom_formats', auto_select_file=False): headers = {"X-Api-Key": source_config['api_key']} get_url = f"{source_config['base_url']}/api/v3/customformat" try: @@ -65,10 +65,15 @@ def import_custom_formats(source_config): existing_formats = response.json() existing_names_to_id = {format['name']: format['id'] for format in existing_formats} - selected_file = select_file('./custom_formats') + files = os.listdir(import_path) + if auto_select_file and len(files) == 1: + selected_file = files[0] + else: + selected_file = select_file(import_path) + added_count, updated_count = 0, 0 - with open(os.path.join('./custom_formats', selected_file), 'r') as import_file: + with open(os.path.join(import_path, selected_file), 'r') as import_file: import_formats = json.load(import_file) print() @@ -108,72 +113,80 @@ def import_custom_formats(source_config): except requests.exceptions.ConnectionError: print_connection_error() -def import_quality_profiles(source_config): +def import_quality_profiles(source_config, import_path='./profiles'): headers = {"X-Api-Key": source_config['api_key']} try: cf_import_sync(source_config) - profile_dir = './profiles' + profile_dir = import_path profiles = [f for f in os.listdir(profile_dir) if f.endswith('.json')] print() print(Colors.HEADER + "Available Profiles:" + Colors.ENDC) for i, profile in enumerate(profiles, 1): print(f"{i}. {profile}") + print(f"{len(profiles) + 1}. Import all profiles") print() - selection = input("Please enter the number of the profile you want to import: ") + selection = input("Please enter the number of the profile you want to import (or enter " + str(len(profiles) + 1) + " to import all): ") + selected_files = [] try: - selected_file = profiles[int(selection) - 1] + selection = int(selection) + if selection == len(profiles) + 1: + selected_files = profiles + else: + selected_files = [profiles[selection - 1]] except (ValueError, IndexError): print_error("Invalid selection, please enter a valid number.") return - with open(os.path.join(profile_dir, selected_file), 'r') as file: - try: - quality_profiles = json.load(file) - except json.JSONDecodeError as e: - print_error(f"Error loading selected profile: {e}") - return + for selected_file in selected_files: + with open(os.path.join(profile_dir, selected_file), 'r') as file: + try: + quality_profiles = json.load(file) + except json.JSONDecodeError as e: + print_error(f"Error loading selected profile: {e}") + continue - for profile in quality_profiles: - existing_format_names = set() - if 'formatItems' in profile: - for format_item in profile['formatItems']: - format_name = format_item.get('name') - if format_name: - existing_format_names.add(format_name) - if format_name in source_config['custom_formats']: - format_item['format'] = source_config['custom_formats'][format_name] + for profile in quality_profiles: + existing_format_names = set() + if 'formatItems' in profile: + for format_item in profile['formatItems']: + format_name = format_item.get('name') + if format_name: + existing_format_names.add(format_name) + if format_name in source_config['custom_formats']: + format_item['format'] = source_config['custom_formats'][format_name] - for format_name, format_id in source_config['custom_formats'].items(): - if format_name not in existing_format_names: - profile.setdefault('formatItems', []).append({ - "format": format_id, - "name": format_name, - "score": 0 - }) + for format_name, format_id in source_config['custom_formats'].items(): + if format_name not in existing_format_names: + profile.setdefault('formatItems', []).append({ + "format": format_id, + "name": format_name, + "score": 0 + }) - post_url = f"{source_config['base_url']}/api/v3/qualityprofile" - response = requests.post(post_url, json=profile, headers=headers) + post_url = f"{source_config['base_url']}/api/v3/qualityprofile" + response = requests.post(post_url, json=profile, headers=headers) - if response.status_code in [200, 201]: - print_success(f"Successfully added Quality Profile {profile['name']}") - elif response.status_code == 409: - print_error(f"Failed to add Quality Profile {profile['name']} due to a naming conflict. Quality profile names must be unique. (HTTP {response.status_code})") - else: - try: - errors = response.json() - message = errors.get("message", "No Message Provided") - print_error(f"Failed to add Quality Profile {profile['name']}! (HTTP {response.status_code})") - print(message) - except json.JSONDecodeError: - print_error("Failed to parse error message:") - print(response.text) + if response.status_code in [200, 201]: + print_success(f"Successfully added Quality Profile {profile['name']}") + elif response.status_code == 409: + print_error(f"Failed to add Quality Profile {profile['name']} due to a naming conflict. Quality profile names must be unique. (HTTP {response.status_code})") + else: + try: + errors = response.json() + message = errors.get("message", "No Message Provided") + print_error(f"Failed to add Quality Profile {profile['name']}! (HTTP {response.status_code})") + print(message) + except json.JSONDecodeError: + print_error("Failed to parse error message:") + print(response.text) except requests.exceptions.ConnectionError: print_connection_error() + def cf_import_sync(source_config): headers = {"X-Api-Key": source_config['api_key']} custom_format_url = f"{source_config['base_url']}/api/v3/customformat" diff --git a/syncarr.py b/syncarr.py new file mode 100644 index 0000000..f7d3244 --- /dev/null +++ b/syncarr.py @@ -0,0 +1,39 @@ +import exportarr +import importarr +import json +import shutil +import os + +def sync_data(): + # Load configuration for main app + with open('config.json', 'r') as config_file: + config = json.load(config_file) + + # Specify the temporary path where files were saved + temp_cf_path = './temp_directory/custom_formats' + temp_qf_path = './temp_directory/quality_profiles' + + # Get user choice for app (radarr/sonarr) + app_choice = importarr.get_user_choice() + + # Export data for the chosen app + exportarr.export_cf(app_choice, save_path=temp_cf_path) + exportarr.export_qf(app_choice, save_path=temp_qf_path) + + # Sync with each extra installation of the chosen app + for extra_instance in config['extra_installations'].get(app_choice, []): + source_config = extra_instance + print(f"Importing to instance: {extra_instance['name']}") + + # Import custom formats and quality profiles to each extra instance + importarr.import_custom_formats(source_config, import_path=temp_cf_path, auto_select_file=True) + importarr.import_quality_profiles(source_config, import_path=temp_qf_path) + + # Delete the temporary directories after the sync is complete + temp_directory = './temp_directory' + if os.path.exists(temp_directory): + shutil.rmtree(temp_directory) + print(f"Deleted temporary directory: {temp_directory}") + +if __name__ == "__main__": + sync_data()