mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
17
README.md
17
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.
|
||||
|
||||
39
config.json
39
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
39
syncarr.py
Normal 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()
|
||||
Reference in New Issue
Block a user