- improved debugging info for exports
- improved user control while exporting
- added config file to add paths / api keys
- added master setup for radarr / sonarr to sync from (not implemented yet)
- adjusted custom format file names to be consistent with qp's
- combined import functionality into a single script
- improved debugging info for imports (functionality is still the same)
- error messages for profile naming conflicts, bad auth and missing app
- up to date set of profiles / custom formats as of 19/01/24
This commit is contained in:
santiagosayshey
2024-01-19 19:00:59 +10:30
parent aba057a8c9
commit 827efd6633
11 changed files with 2413 additions and 256 deletions

View File

@@ -1,12 +1,12 @@
# Profilarr
Profilarr is a Python-based tool that enables seamless synchronization of custom formats and quality profiles in Radarr / Sonarr. It's designed to aid users in sharing / importing custom formats & quality profiles seamlessly.
Profilarr is a Python-based tool that enables synchronization of custom formats and quality profiles in Radarr / Sonarr. It's designed to aid users in exporting / importing custom formats & quality profiles seamlessly.
Companion tool to Dictionarry to mass import custom formats / profiles quickly.
## ⚠️ Before Continuing
- **This tool will overwrite any custom formats in your Radarr installation that have the same name.**
- **This tool will overwrite any custom formats in your \*arr installation that have the same name.**
- **Custom Formats MUST be imported before syncing any premade profile.**
## 🛠️ Installation
@@ -20,7 +20,7 @@ Companion tool to Dictionarry to mass import custom formats / profiles quickly.
1. Download the Profilarr zip file from the release section.
2. Extract its contents into a folder.
3. Open `import.py` in a text editor of your choice.
3. Open either of the `import.py` files in a text editor of your choice.
- Add your Radarr / Sonarr API key to the designated section.
- Modify the Base URL if needed
4. Save the changes and close the text editor.

25
config.json Normal file
View File

@@ -0,0 +1,25 @@
{
"master": {
"sonarr": {
"base_url": "http://localhost:8989",
"api_key": "API_GOES_HERE"
},
"radarr": {
"base_url": "http://localhost:7878",
"api_key": "API_GOES_HERE"
}
},
"extra_installations": [
{
"name": "example",
"sonarr": {
"base_url": "http://otherhost1:8989",
"api_key": "API_GOES_HERE"
},
"radarr": {
"base_url": "http://otherhost1:7878",
"api_key": "API_GOES_HERE"
}
}
]
}

216
export.py
View File

@@ -3,93 +3,155 @@ import requests
import os
import re
# Define constants
base_url = "http://localhost:7878"
api_key = "API_GOES_HERE"
# ANSI escape sequences for colors
class Colors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
# Define parameters and headers
params = {"apikey": api_key}
headers = {"X-Api-Key": api_key}
files = {'file': ('', '')} # Empty file to force multipart/form-data
# Load configuration for main app
with open('config.json', 'r') as config_file:
config = json.load(config_file)['master']
# Login
login_url = f"{base_url}/login"
response = requests.get(login_url, params=params, headers=headers, files=files)
def get_user_choice():
choice = input("Enter an app to export from (radarr/sonarr): ").lower()
while choice not in ["radarr", "sonarr"]:
print(Colors.FAIL + "Invalid input. Please enter either 'radarr' or 'sonarr'." + Colors.ENDC)
choice = input("Enter the source (radarr/sonarr): ").lower()
print()
return choice
if response.status_code != 200:
print(f"Login Failed! (HTTP {response.status_code})")
print("Response Content: ", response.content)
exit()
def get_export_choice():
print(Colors.HEADER + "Choose what to export:" + Colors.ENDC)
print("1. Custom Formats")
print("2. Quality Profiles")
print("3. Both")
choice = input("Enter your choice (1/2/3): ").strip()
while choice not in ["1", "2", "3"]:
print(Colors.FAIL + "Invalid input. Please enter 1, 2, or 3." + Colors.ENDC)
choice = input("Enter your choice (1/2/3): ").strip()
print()
return choice
def export_cf():
# Prompt the user to specify the source (Radarr or Sonarr)
source = input("Enter the source (radarr/sonarr): ").lower()
while source not in ["radarr", "sonarr"]:
print("Invalid input. Please enter either 'radarr' or 'sonarr'.")
source = input("Enter the source (radarr/sonarr): ").lower()
custom_format_url = f"{base_url}/api/v3/customformat"
response = requests.get(custom_format_url, params=params, headers=headers)
if response.status_code == 200:
data = response.json()
# Remove 'id' from each custom format
for custom_format in data:
custom_format.pop('id', None)
# Save to JSON file with adjusted name based on source
file_path = f'./custom_formats/{source}_custom_formats.json'
with open(file_path, 'w') as f:
json.dump(data, f, indent=4)
print(f"Custom Formats have been saved to '{file_path}'")
else:
print(f"Failed to retrieve custom formats! (HTTP {response.status_code})")
print("Response Content: ", response.content)
def get_app_config(source):
app_config = config[source]
return app_config['base_url'], app_config['api_key']
def sanitize_filename(filename):
# Replace any characters not allowed in filenames with _
sanitized_filename = re.sub(r'[\\/*?:"<>|]', '_', filename)
return sanitized_filename
def export_qf():
# Prompt the user to specify the source (Radarr or Sonarr)
source = input("Enter the source (radarr/sonarr): ").lower()
while source not in ["radarr", "sonarr"]:
print("Invalid input. Please enter either 'radarr' or 'sonarr'.")
source = input("Enter the source (radarr/sonarr): ").lower()
# Capitalize the first letter of the source
source = source.capitalize()
response = requests.get(f"{base_url}/api/v3/qualityprofile", params=params, headers=headers)
if response.status_code == 200:
quality_profiles = response.json()
# Ensure the ./profiles directory exists
if not os.path.exists('./profiles'):
os.makedirs('./profiles')
# Process each profile separately
for profile in quality_profiles:
profile.pop('id', None) # Remove the 'id' field
# Use the name of the profile to create a filename and append the source
profile_name = profile.get('name', 'unnamed_profile') # Use a default name if the profile has no name
profile_name = sanitize_filename(profile_name) # Sanitize the filename
profile_filename = f"{profile_name} ({source}).json"
profile_filepath = os.path.join('./profiles', profile_filename)
# Save the individual profile to a file as a single-element array
with open(profile_filepath, 'w') as file:
json.dump([profile], file, indent=4) # Note the [profile], it will make it an array with a single element
print("Quality profiles have been successfully saved to the ./profiles directory")
def handle_response_errors(response):
if response.status_code == 401:
print(Colors.FAIL + "Authentication error: Invalid API key." + Colors.ENDC)
elif response.status_code == 403:
print(Colors.FAIL + "Forbidden: Access is denied." + Colors.ENDC)
else:
print("Failed to retrieve quality profiles!")
print(Colors.FAIL + f"An error occurred! (HTTP {response.status_code})" + Colors.ENDC)
print("Response Content: ", response.content.decode('utf-8'))
def print_saved_items(items, item_type):
if len(items) > 10:
items_to_display = items[:10]
for item in items_to_display:
print(f" - {item}")
print(f"... and {len(items) - 10} more.")
else:
for item in items:
print(f" - {item}")
def ensure_directory_exists(directory):
if not os.path.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
base_url, api_key = get_app_config(source)
headers = {"X-Api-Key": api_key}
params = {"apikey": api_key}
print(Colors.OKBLUE + f"Attempting to access {source.capitalize()} at {base_url}" + Colors.ENDC)
custom_format_url = f"{base_url}/api/v3/customformat"
try:
response = requests.get(custom_format_url, params=params, headers=headers)
if response.status_code == 200:
data = response.json()
print(Colors.OKGREEN + f"Found {len(data)} custom formats." + Colors.ENDC)
saved_formats = []
for custom_format in data:
custom_format.pop('id', None)
saved_formats.append(custom_format['name'])
file_path = f'./custom_formats/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()
else:
handle_response_errors(response)
except requests.exceptions.ConnectionError:
print(Colors.FAIL + f"Failed to connect to {source.capitalize()}! Please check if it's running and accessible." + Colors.ENDC)
def export_qf(source):
ensure_directory_exists('./profiles') # Ensure the directory exists
base_url, api_key = get_app_config(source)
headers = {"X-Api-Key": api_key}
params = {"apikey": api_key}
print(Colors.OKBLUE + f"Attempting to access {source.capitalize()} at {base_url}" + Colors.ENDC)
try:
response = requests.get(f"{base_url}/api/v3/qualityprofile", params=params, headers=headers)
if response.status_code == 200:
quality_profiles = response.json()
print(Colors.OKGREEN + f"Found {len(quality_profiles)} quality profiles." + Colors.ENDC)
if not os.path.exists('./profiles'):
os.makedirs('./profiles')
saved_profiles = []
for profile in quality_profiles:
profile.pop('id', None)
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)
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()
else:
handle_response_errors(response)
except requests.exceptions.ConnectionError:
print(Colors.FAIL + f"Failed to connect to {source.capitalize()}! Please check if it's running and accessible." + Colors.ENDC)
if __name__ == "__main__":
export_cf()
export_qf()
user_choice = get_user_choice()
export_choice = get_export_choice()
if export_choice in ["1", "3"]:
export_cf(user_choice)
if export_choice in ["2", "3"]:
export_qf(user_choice)

206
import.py Normal file
View File

@@ -0,0 +1,206 @@
import json
import requests
import os
# ANSI escape sequences for colors
class Colors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
# Load configuration for main app
with open('config.json', 'r') as config_file:
config = json.load(config_file)
def print_success(message):
print(Colors.OKGREEN + message + Colors.ENDC)
def print_error(message):
print(Colors.FAIL + message + Colors.ENDC)
def print_connection_error():
print(Colors.FAIL + "Failed to connect to the service! Please check if it's running and accessible." + Colors.ENDC)
def get_user_choice():
choice = input("Enter the app you want to import to (radarr/sonarr): ").lower()
while choice not in ["radarr", "sonarr"]:
print_error("Invalid input. Please enter either 'radarr' or 'sonarr'.")
choice = input("Enter the source (radarr/sonarr): ").lower()
return choice
def get_import_choice():
print()
print(Colors.HEADER + "Choose what to import:" + Colors.ENDC)
print("1. Custom Formats")
print("2. Quality Profiles")
choice = input("Enter your choice (1/2): ").strip()
while choice not in ["1", "2"]:
print_error("Invalid input. Please enter 1 or 2.")
choice = input("Enter your choice (1/2): ").strip()
return choice
def get_app_config(source):
return config['master'][source]
def select_file(directory):
files = [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))]
print()
print(Colors.OKBLUE + "Available files:" + Colors.ENDC)
for i, file in enumerate(files, 1):
print(f"{i}. {file}")
choice = int(input("Select a file to import: "))
return files[choice - 1]
def import_custom_formats(source_config):
headers = {"X-Api-Key": source_config['api_key']}
get_url = f"{source_config['base_url']}/api/v3/customformat"
try:
response = requests.get(get_url, headers=headers)
if response.status_code == 200:
existing_formats = response.json()
existing_names_to_id = {format['name']: format['id'] for format in existing_formats}
selected_file = select_file('./custom_formats')
added_count, updated_count = 0, 0
with open(os.path.join('./custom_formats', selected_file), 'r') as import_file:
import_formats = json.load(import_file)
print()
for format in import_formats:
format_name = format['name']
if format_name in existing_names_to_id:
format_id = existing_names_to_id[format_name]
put_url = f"{source_config['base_url']}/api/v3/customformat/{format_id}"
response = requests.put(put_url, json=format, headers=headers)
if response.status_code in [200, 201, 202]:
print(Colors.WARNING + f"Updating custom format '{format_name}': " + Colors.ENDC, end='')
print_success("SUCCESS")
updated_count += 1
else:
print_error(f"Updating custom format '{format_name}': FAIL")
print(response.content.decode())
else:
post_url = f"{source_config['base_url']}/api/v3/customformat"
response = requests.post(post_url, json=format, headers=headers)
if response.status_code in [200, 201]:
print(Colors.OKBLUE + f"Adding custom format '{format_name}': " + Colors.ENDC, end='')
print_success("SUCCESS")
added_count += 1
else:
print_error(f"Adding custom format '{format_name}': FAIL")
print(response.content.decode())
print()
print_success(f"Successfully added {added_count} custom formats, updated {updated_count} custom formats.")
else:
print_error(f"Failed to retrieve existing custom formats from {get_url}! (HTTP {response.status_code})")
print(response.content.decode())
except requests.exceptions.ConnectionError:
print_connection_error()
def import_quality_profiles(source_config):
headers = {"X-Api-Key": source_config['api_key']}
try:
cf_import_sync(source_config)
profile_dir = './profiles'
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()
selection = input("Please enter the number of the profile you want to import: ")
try:
selected_file = profiles[int(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 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
})
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)
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"
try:
response = requests.get(custom_format_url, headers=headers)
if response.status_code == 200:
data = response.json()
source_config['custom_formats'] = {format['name']: format['id'] for format in data}
elif response.status_code == 401:
print_error("Authentication error: Invalid API key. Terminating program.")
exit(1)
else:
print_error(f"Failed to retrieve custom formats! (HTTP {response.status_code})")
print(response.content.decode())
exit(1)
except requests.exceptions.ConnectionError:
print_connection_error()
exit(1)
if __name__ == "__main__":
user_choice = get_user_choice()
source_config = get_app_config(user_choice)
import_choice = get_import_choice()
if import_choice == "1":
import_custom_formats(source_config)
elif import_choice == "2":
import_quality_profiles(source_config)

View File

@@ -1,60 +0,0 @@
import json
import requests
# Define constants
base_url = "http://localhost:7878" # Update to your Radarr URL
api_key = "API_GOES_HERE" # Update to your Radarr API Key
# Define headers
headers = {"X-Api-Key": api_key}
def get_existing_formats():
get_url = f"{base_url}/api/v3/customformat"
print(f"Getting existing formats from {get_url}")
response = requests.get(get_url, headers=headers)
if response.status_code == 200:
with open('temp_cf.json', 'w') as temp_file:
json.dump(response.json(), temp_file)
else:
print(f"Failed to retrieve existing custom formats from {get_url}! (HTTP {response.status_code})")
print("Response Content: \n", response.content.decode())
exit(1)
def import_custom_formats():
with open('temp_cf.json', 'r') as temp_file:
existing_formats = json.load(temp_file)
existing_names_to_id = {format['name']: format['id'] for format in existing_formats}
with open('custom_formats/cf.json', 'r') as import_file:
import_formats = json.load(import_file)
for format in import_formats:
format_name = format['name']
if format_name in existing_names_to_id:
format_id = existing_names_to_id[format_name]
put_url = f"{base_url}/api/v3/customformat/{format_id}"
print(f"Updating existing format {format_name} using PUT at {put_url}")
format['id'] = format_id # Include the id in the request body
response = requests.put(put_url, json=format, headers=headers)
if response.status_code in [200, 201, 202]:
print(f"Successfully updated custom format {format_name}! (HTTP {response.status_code})")
else:
print(f"Failed to update custom format {format_name} at {put_url}! (HTTP {response.status_code})")
print("Response Content: \n", response.content.decode())
else:
post_url = f"{base_url}/api/v3/customformat"
print(f"Creating new format {format_name} using POST at {post_url}")
response = requests.post(post_url, json=format, headers=headers)
if response.status_code in [200, 201]:
print(f"Successfully created custom format {format_name}! (HTTP {response.status_code})")
else:
print(f"Failed to create custom format {format_name} at {post_url}! (HTTP {response.status_code})")
print("Response Content: \n", response.content.decode())
if __name__ == "__main__":
get_existing_formats()
import_custom_formats()

View File

@@ -1,115 +0,0 @@
import json
import requests
import os # For deleting the temporary file
# Define constants
base_url = "http://localhost:7878" # Update to your Radarr URL
api_key = "API_GOES_HERE" # Update to your Radarr API Key
# Define headers
params = {"apikey": api_key}
headers = {"X-Api-Key": api_key}
def cf_import_sync():
custom_format_url = f"{base_url}/api/v3/customformat"
response = requests.get(custom_format_url, headers=headers)
if response.status_code == 200:
data = response.json()
with open('custom_formats.json', 'w') as file:
json.dump(data, file, indent=4)
print("Custom Formats have been saved to 'custom_formats.json'")
return True
else:
print(f"Failed to retrieve custom formats! (HTTP {response.status_code})")
print("Response Content: ", response.content.decode('utf-8'))
return False
def import_qf():
# Call cf_import_sync first
cf_import_sync()
profile_dir = './profiles'
profiles = [f for f in os.listdir(profile_dir) if f.endswith('.json')]
# Prompt user to select a profile
print("Available Profiles:")
for i, profile in enumerate(profiles, 1):
print(f"{i}. {profile}")
selection = input("Please enter the number of the profile you want to import: ")
try:
selected_file = profiles[int(selection) - 1]
except (ValueError, IndexError):
print("Invalid selection, please enter a valid number.")
return
# Load the selected profile
with open(os.path.join(profile_dir, selected_file), 'r') as file:
try:
quality_profiles = json.load(file)
except json.JSONDecodeError as e:
print(f"Error loading selected profile: {e}")
return
# Load custom formats
try:
with open('custom_formats.json', 'r') as file:
custom_formats_data = json.load(file)
custom_formats = {format['name']: format['id'] for format in custom_formats_data}
except Exception as e:
print(f"Failed to load custom formats! Error: {e}")
return
# Process each profile and send requests
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 custom_formats:
format_item['format'] = custom_formats[format_name]
for format_name, format_id in 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"{base_url}/api/v3/qualityprofile"
response = requests.post(post_url, json=profile, params=params, headers=headers)
if response.status_code in [200, 201]:
print(f"Successfully added Quality Profile {profile['name']}! (HTTP {response.status_code})")
else:
try:
# Assuming the response is JSON, parse it
errors = response.json()
# Extract relevant information from the error message
message = errors.get("message", "No Message Provided")
description = errors.get("description", "No Description Provided")
# Format and print the error message
print(f"Failed to add Quality Profile {profile['name']}! (HTTP {response.status_code})")
print(f"Error Message: {message}")
except json.JSONDecodeError:
# If response is not JSON, print the whole response
print("Failed to parse error message:")
print(response.text)
try:
os.remove('custom_formats.json')
except FileNotFoundError:
pass # File already deleted or does not exist
if __name__ == "__main__":
import_qf()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
[]