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
This commit is contained in:
santiagosayshey
2024-01-20 12:54:49 +10:30
parent da2d76fc2c
commit bae4d0c45c
5 changed files with 139 additions and 72 deletions

View File

@@ -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.

View File

@@ -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"
}
]
}
}

View File

@@ -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)

View File

@@ -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"

39
syncarr.py Normal file
View File

@@ -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()