* added AMiABLE and PiGNUS to scene groups (#25)

* Major Code Refactor (#27)

Description
Reimplemented Profilarr from the ground up to be more reusable, in addition to implemented a few improvements. Works almost identically to before, but will be much easier to develop for going forward.

Improvements
Implements feature mentioned in Issue #11.
- Custom Formats are now automatically imported before quality profiles are imported.

* fixed 2160 remux bug (#28)

Fixed bug that was incorrectly prioritising WEBs in 2160p optimal profiles.
This commit is contained in:
santiagosayshey
2024-02-05 17:20:59 +10:30
committed by GitHub
parent 2e5cabe7ab
commit ff79de7724
33 changed files with 1124 additions and 835 deletions

373
README.md
View File

@@ -5,7 +5,6 @@ Profilarr is a Python-based tool designed to add import/export/sync functionalit
## ⚠️ Before Continuing
- **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.**
- **Always back up your Radarr and Sonarr configurations before using Profilarr to avoid unintended data loss.** (Seriously, do it. Even I've lost data to this tool because I forgot to back up my configs.)
## 🛠️ Installation
@@ -29,73 +28,47 @@ Profilarr is a Python-based tool designed to add import/export/sync functionalit
- Add the URL and API key to the master instances of Radarr / Sonarr.
- If syncing, add the URL, API key and a name to each extra instance of Radarr / Sonarr.
- If exporting, adjust the `export_path` to your desired export location.
- If importing non Dictionarry files, adjust the `import_path` to your desired import location.
5. Save the changes.
## 🚀 Usage
### Importing
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.
Note: For users who start using Profilarr before v0.3, you no longer need to manually import custom formats. They will be imported automatically. Quality Profiles still require manual selection.
#### Custom Format Import Example
1. If importing Dictionarry files, make sure the import path is `./imports` (This is the default path).
2. If importing non Dictionarry files, make sure the import path is set to your desired import location.
3. Run `python importarr.py` in your command line interface.
4. Follow the on-screen prompts to select your desired app and which instance(s) to import to.
5. Choose your desired quality profile(s) to import.
```bash
PS Z:\Profilarr> py importarr.py
Available instances to import to:
1. Sonarr [Master]
2. Radarr [Master]
3. Sonarr [4k-sonarr]
4. Radarr [4k-radarr]
Enter the number of the instance to import to: 4
#### Example: Importing 1080p Transparent and 2160p Optimal Quality Profiles
Choose what to import:
1. Custom Formats
2. Quality Profiles
Enter your choice (1/2): 1
Available files:
1. Custom Formats (Radarr).json
Select a file to import (or 'all' for all files): 1
Adding custom format 'D-Z0N3': SUCCESS
Adding custom format 'DON': SUCCESS
Adding custom format 'EbP': SUCCESS
Adding custom format 'Geek': SUCCESS
Adding custom format 'TayTo': SUCCESS
Adding custom format 'ZQ': SUCCESS
Adding custom format 'VietHD': SUCCESS
Adding custom format 'CtrlHD': SUCCESS
Adding custom format 'HiFi': SUCCESS
Adding custom format 'FoRM': SUCCESS
Adding custom format 'HiDt': SUCCESS
Adding custom format 'SA89': SUCCESS
...
Successfully added 0 custom formats, updated 131 custom formats.
```
Select your app of choice
1. Radarr
2. Sonarr
Enter your choice:
1
Select your Radarr instance
1. Radarr (Master)
2. Radarr (4k-radarr)
Choose an instance by number, multiple numbers separated by commas or type 'all' for all instances:
2
#### Quality Profile Import Example
Importing custom formats to Radarr : 4k-radarr
```bash
PS Z:\Profilarr> py importarr.py
Available instances to import to:
1. Sonarr [Master]
2. Radarr [Master]
3. Sonarr [4k-sonarr]
4. Radarr [4k-radarr]
Enter the number of the instance to import to: 4
Adding custom format 'D-Z0N3' : SUCCESS
Adding custom format 'DON' : SUCCESS
Adding custom format 'EbP' : SUCCESS
Adding custom format 'Geek' : SUCCESS
Adding custom format 'TayTo' : SUCCESS
... and 129 more.
Successfully added 0 custom formats, updated 134 custom formats.
Choose what to import:
1. Custom Formats
2. Quality Profiles
Enter your choice (1/2): 2
Available files:
Available profiles:
1. 1080p Balanced (Radarr).json
2. 1080p Balanced (Single Grab) (Radarr).json
3. 1080p h265 Balanced (Radarr).json
@@ -107,209 +80,177 @@ Available files:
9. 1080p Transparent (Single Grab) (Radarr).json
10. 2160p Optimal (Radarr).json
11. 2160p Optimal (Single Grab) (Radarr).json
Select a file to import (or 'all' for all files): all
Successfully added Quality Profile 1080p Balanced
Successfully added Quality Profile 1080p Balanced (Single Grab)
Successfully added Quality Profile 1080p h265 Balanced
Successfully added Quality Profile 1080p h265 Balanced (Single Grab)
Successfully added Quality Profile 1080p Optimal
Successfully added Quality Profile 1080p Optimal (Single Grab)
Successfully added Quality Profile 1080p Transparent (Double Grab)
Successfully added Quality Profile 1080p Transparent
Successfully added Quality Profile 1080p Transparent (Single Grab)
Successfully added Quality Profile 2160p Optimal
Successfully added Quality Profile 2160p Optimal (Single Grab)
PS Z:\Profilarr>
Enter the numbers of the profiles you want to import separated by commas, or type 'all' to import all profiles:
8,10
Importing Quality Profiles to Radarr : 4k-radarr
Adding '1080p Transparent' quality profile : SUCCESS
Adding '2160p Optimal' quality profile : SUCCESS
```
### Exporting
1. Run `python exportarr.py` in your command line interface.
2. Choose the instance you want to export from.
3. Choose the data you want to export.
4. The data will be exported to `exports/{instance_type}/{instance_name}/{data_type}`.
1. Make sure the export path is set to your desired export location. The default is `./exports`.
2. Run `python exportarr.py` in your command line interface.
3. Follow the on-screen prompts to select your desired app and which instance(s) to export from.
4. Choose the data you want to export.
5. The data will be exported to `exports/{data_type}/{app}/`.
#### Example
```bash
PS Z:\Profilarr> py exportarr.py
Available sources to export from:
1. Sonarr [Master]
2. Radarr [Master]
3. Sonarr [4k-sonarr]
4. Radarr [4k-radarr]
Enter the number of the app to export from: 2
Select your app of choice
1. Radarr
2. Sonarr
Enter your choice:
1
Select your Radarr instance
1. Radarr (Master)
2. Radarr (4k-radarr)
Choose an instance by number, multiple numbers separated by commas or type 'all' for all instances:
2
Choose what to export:
1. Custom Formats
2. Quality Profiles
3. Both
Enter your choice (1/2/3): 3
Exporting Custom Formats for Radarr : 4k-radarr
Exported 134 custom formats to ./exports/custom_formats/Radarr for 4k-radarr
Attempting to access Radarr at http://localhost:7878
Found 131 custom formats.
- D-Z0N3
- DON
- EbP
- Geek
- TayTo
- ZQ
- VietHD
- CtrlHD
- HiFi
- FoRM
... and 121 more.
Saved to './exports\radarr\master\custom_formats\Custom Formats (Radarr).json'
Attempting to access Radarr at http://localhost:7878
Found 13 quality profiles.
- 1080p Optimal
- 2160p Optimal
- 1080p Balanced
- 1080p Transparent
- 1080p Transparent (Double Grab)
- 1080p Transparent (Single Grab)
- 1080p Balanced (Single Grab)
- 1080p h265 Balanced
- 1080p h265 Balanced (Single Grab)
- 1080p x265 HDR Transparent
... and 3 more.
Saved to 'exports\radarr\master\profiles'
PS Z:\Profilarr>
Exporting Quality Profiles for Radarr : 4k-radarr...
Exported 2 quality profiles to ./exports/quality_profiles/Radarr for 4k-radarr
```
### Syncing
1. Make sure the import path is set to whatever your export path is. This is important, as the script will look for the exported files in this location.
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.
1. The script will automatically export data from the master instance and import it to all other instances specified in `config.json`.
#### Example
```bash
PS Z:\Profilarr> py syncarr.py
Select the app you want to sync:
Select your app of choice
1. Radarr
2. Sonarr
Enter your choice (1 or 2): 2
Attempting to access Sonarr at http://localhost:8989
Found 135 custom formats.
- D-Z0N3
- DON
- EbP
- Geek
- TayTo
- ZQ
- VietHD
- CtrlHD
- HiFi
- FoRM
... and 125 more.
Saved to './temp_directory/custom_formats\Custom Formats (Sonarr).json'
Enter your choice:
1
Exporting Custom Formats for radarr : Master
Exported 134 custom formats to ./exports\custom_formats\radarr for Master
Attempting to access Sonarr at http://localhost:8989
Found 11 quality profiles.
- 1080p Transparent
- 2160p Optimal
- 1080p Transparent (Single Grab)
- 1080p Transparent (Double Grab)
- 1080p Balanced
- 1080p Balanced (Single Grab)
- 1080p h265 Balanced
- 1080p h265 Balanced (Single Grab)
- 1080p Optimal
- 1080p Optimal (Single Grab)
... and 1 more.
Saved to 'temp_directory\quality_profiles'
Exporting Quality Profiles for radarr : Master...
Exported 14 quality profiles to ./exports\quality_profiles\radarr for Master
Importing to instance: 4k-sonarr
Adding custom format 'D-Z0N3': SUCCESS
Adding custom format 'DON': SUCCESS
Adding custom format 'EbP': SUCCESS
Adding custom format 'Geek': SUCCESS
Adding custom format 'TayTo': SUCCESS
Adding custom format 'ZQ': SUCCESS
Adding custom format 'VietHD': SUCCESS
Adding custom format 'CtrlHD': SUCCESS
Adding custom format 'HiFi': SUCCESS
... and 125 more.
Importing custom formats to radarr : 4k-radarr
Successfully added 135 custom formats, updated 0 custom formats.
Successfully added Quality Profile 1080p Balanced (Single Grab)
Successfully added Quality Profile 1080p Balanced
Successfully added Quality Profile 1080p h265 Balanced
Successfully added Quality Profile 1080p h265 Balanced (Single Grab)
Successfully added Quality Profile 1080p Optimal (Single Grab)
Successfully added Quality Profile 1080p Optimal
Successfully added Quality Profile 1080p Transparent (Double Grab)
Successfully added Quality Profile 1080p Transparent (Single Grab)
Successfully added Quality Profile 1080p Transparent
Successfully added Quality Profile 2160p Optimal (Single Grab)
Successfully added Quality Profile 2160p Optimal
Deleted temporary directory: ./temp_directory
PS Z:\Profilarr>
...
Updating custom format 'Blu-Ray (Remux)' : SUCCESS
Updating custom format 'MAX' : SUCCESS
Updating custom format 'h265 (4k)' : SUCCESS
Updating custom format 'TEST FLAC' : SUCCESS
Successfully added 134 custom formats, updated 0 custom formats.
Available profiles:
1. 1080p Balanced (Radarr).json
2. 1080p Balanced (Single Grab) (Radarr).json
3. 1080p h265 Balanced (Radarr).json
4. 1080p h265 Balanced (Single Grab) (Radarr).json
5. 1080p Optimal (Radarr).json
6. 1080p Optimal (Single Grab) (Radarr).json
7. 1080p Transparent (Double Grab) (Radarr).json
8. 1080p Transparent (Radarr).json
9. 1080p Transparent (Single Grab) (Radarr).json
10. 2160p Optimal (Radarr).json
11. 2160p Optimal (Single Grab) (Radarr).json
Enter the numbers of the profiles you want to import separated by commas, or type 'all' to import all profiles:
all
Importing Quality Profiles to radarr : 4k-radarr
Adding '1080p Balanced' quality profile : SUCCESS
Adding '1080p Balanced (Single Grab)' quality profile : SUCCESS
Adding '1080p h265 Balanced' quality profile : SUCCESS
Adding '1080p h265 Balanced (Single Grab)' quality profile : SUCCESS
Adding '1080p Optimal' quality profile : SUCCESS
Adding '1080p Optimal (Single Grab)' quality profile : SUCCESS
Adding '1080p Transparent (Double Grab)' quality profile : SUCCESS
Updating '1080p Transparent' quality profile : SUCCESS
Adding '1080p Transparent (Single Grab)' quality profile : SUCCESS
Updating '2160p Optimal' quality profile : SUCCESS
Adding '2160p Optimal (Single Grab)' quality profile : SUCCESS
```
### Deleting
1. Run `python deletarr.py` in your command line interface.
2. Select the instance from which you wish to delete data.
3. Choose between deleting Custom Formats or Quality Profiles.
2. Select the instance(s) from which you wish to delete data.
3. Choose between deleting Custom Formats, Quality Profiles or both
4. Select specific items by typing their numbers separated by commas, or type 'all' to delete everything.
#### Example: Deleting Custom Formats
#### Example
```plaintext
PS Z:\Profilarr> python deletarr.py
Select your app of choice
1. Radarr
2. Sonarr
Enter your choice:
1
Select your Radarr instance
1. Radarr (Master)
2. Radarr (4k-radarr)
Choose an instance by number, multiple numbers separated by commas or type 'all' for all instances:
2
Available instances to delete from:
1. Sonarr [Master]
2. Radarr [Master]
Enter the number of the instance to delete from: 2
Choose what to delete:
Please select what you want to delete:
1. Custom Formats
2. Quality Profiles
Enter your choice (1/2): 1
Deleting selected custom formats...
Available items:
1. UHDBits
2. Dolby Vision w/out Fallback
3. Both
Enter your choice: 3
Available items to delete:
1. D-Z0N3
2. DON
3. EbP
4. Geek
5. TayTo
6. ZQ
...
132. h265 (4k)
133. MAX
Enter the number(s) of the items you wish to delete, separated by commas, or type 'all' for all:
Your choice: all
Deleting Custom Format (D-Z0N3) : SUCCESS
Deleting Custom Format (DON) : SUCCESS
Deleting Custom Format (EbP) : SUCCESS
Deleting Custom Format (Geek) : SUCCESS
Deleting Custom Format (TayTo) : SUCCESS
Deleting Custom Format (ZQ) : SUCCESS
Available items to delete:
1. 1080p Transparent
2. 2160p Optimal
3. 1080p Balanced
4. 1080p Balanced (Single Grab)
5. 1080p h265 Balanced
6. 1080p h265 Balanced (Single Grab)
7. 1080p Optimal
8. 1080p Optimal (Single Grab)
9. 1080p Transparent (Double Grab)
10. 1080p Transparent (Single Grab)
11. 2160p Optimal (Single Grab)
Enter the number(s) of the items you wish to delete, separated by commas, or type 'all' for all:
Your choice: all
Deleting custom format 'UHDBits': SUCCESS
...
Deleting custom format 'MAX': SUCCESS
```
#### Example: Deleting Quality Profiles
```plaintext
PS Z:\Profilarr> python deletarr.py
Choose what to delete:
1. Custom Formats
2. Quality Profiles
Enter your choice (1/2): 2
Deleting selected quality profiles...
Available items:
1. 1080p Balanced
...
11. 2160p Optimal
Your choice: all
Deleting quality profile '1080p Balanced': SUCCESS
...
Deleting quality profile '2160p Optimal': SUCCESS
Deleting Quality Profile (1080p Transparent) : SUCCESS
Deleting Quality Profile (2160p Optimal) : SUCCESS
Deleting Quality Profile (1080p Balanced) : SUCCESS
Deleting Quality Profile (1080p Balanced (Single Grab)) : SUCCESS
Deleting Quality Profile (1080p h265 Balanced) : SUCCESS
Deleting Quality Profile (1080p h265 Balanced (Single Grab)) : SUCCESS
Deleting Quality Profile (1080p Optimal) : SUCCESS
Deleting Quality Profile (1080p Optimal (Single Grab)) : SUCCESS
Deleting Quality Profile (1080p Transparent (Double Grab)) : SUCCESS
Deleting Quality Profile (1080p Transparent (Single Grab)) : SUCCESS
Deleting Quality Profile (2160p Optimal (Single Grab)) : SUCCESS
PS Z:\Profilarr>
```
### Radarr and Sonarr Compatibility

View File

@@ -1,164 +1,101 @@
import requests
import os
import yaml
import json
# ANSI escape sequences for colors
class Colors:
HEADER = '\033[95m' # Purple for questions and headers
OKBLUE = '\033[94m' # Blue for actions
OKGREEN = '\033[92m' # Green for success messages
FAIL = '\033[91m' # Red for error messages
ENDC = '\033[0m' # Reset to default
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
# Load configuration for main app
with open('config.yml', 'r') as config_file:
config = yaml.safe_load(config_file)
master_config = config['instances']['master']
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():
print(Colors.HEADER + "\nAvailable instances to delete from:" + Colors.ENDC)
sources = []
# Add master installations
for app in master_config:
sources.append((app, f"{app.capitalize()} [Master]"))
# Add extra installations
if "extras" in config['instances']:
for app, instances in config['instances']['extras'].items():
for install in instances:
sources.append((app, f"{app.capitalize()} [{install['name']}]"))
# Display sources with numbers
for idx, (app, name) in enumerate(sources, start=1):
print(f"{idx}. {name}")
# User selection
choice = input(Colors.HEADER + "Enter the number of the instance to delete from: " + Colors.ENDC).strip()
while not choice.isdigit() or int(choice) < 1 or int(choice) > len(sources):
print_error("Invalid input. Please enter a valid number.")
choice = input(Colors.HEADER + "Enter the number of the instance to delete from: " + Colors.ENDC).strip()
selected_app, selected_name = sources[int(choice) - 1]
print()
return selected_app, selected_name
from helpers import *
def user_select_items_to_delete(items):
print(Colors.HEADER + "\nAvailable items:" + Colors.ENDC)
for idx, item in enumerate(items, start=1):
print(f"{idx}. {item['name']}")
print(Colors.HEADER + "Type the number(s) of the items you wish to delete separated by commas, or type 'all' to delete everything." + Colors.ENDC)
selection = input(Colors.HEADER + "Your choice: " + Colors.ENDC).strip().lower()
if selection == 'all':
return [item['id'] for item in items] # Return all IDs if "all" is selected
"""
Prompts the user to select items to delete from a given list of items.
Each item in the list is expected to be a dictionary with at least an 'id' and 'name' key.
"""
print_message("Available items to delete:", "purple")
for index, item in enumerate(items, start=1):
print_message(f"{index}. {item['name']}", "green")
print_message("Enter the number(s) of the items you wish to delete, separated by commas, or type 'all' for all:", "yellow")
user_input = input("Your choice: ").strip().lower()
selected_items = []
if user_input == 'all':
return items
else:
selected_ids = []
try:
selected_indices = [int(i) - 1 for i in selection.split(',') if i.isdigit()]
for idx in selected_indices:
if idx < len(items):
selected_ids.append(items[idx]['id'])
return selected_ids
except ValueError:
print_error("Invalid input. Please enter a valid number or 'all'.")
return []
def delete_custom_formats(source_config):
print(Colors.OKBLUE + "\nDeleting selected custom formats..." + Colors.ENDC)
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:
formats_to_delete = response.json()
selected_ids = user_select_items_to_delete(formats_to_delete)
for format_id in selected_ids:
delete_url = f"{get_url}/{format_id}"
del_response = requests.delete(delete_url, headers=headers)
format_name = next((item['name'] for item in formats_to_delete if item['id'] == format_id), "Unknown")
if del_response.status_code in [200, 202, 204]:
print(Colors.OKBLUE + f"Deleting custom format '{format_name}': " + Colors.ENDC + Colors.OKGREEN + "SUCCESS" + Colors.ENDC)
indices = user_input.split(',')
for index in indices:
try:
index = int(index.strip()) - 1
if 0 <= index < len(items):
selected_items.append(items[index])
else:
print(Colors.OKBLUE + f"Deleting custom format '{format_name}': " + Colors.ENDC + Colors.FAIL + "FAIL" + Colors.ENDC)
else:
print_error("Failed to retrieve custom formats for deletion!")
except requests.exceptions.ConnectionError:
print_connection_error()
print_message("Invalid selection, ignoring.", "red")
except ValueError:
print_message("Invalid input, please enter numbers only.", "red")
def delete_quality_profiles(source_config):
print(Colors.OKBLUE + "\nDeleting selected quality profiles..." + Colors.ENDC)
headers = {"X-Api-Key": source_config['api_key']}
get_url = f"{source_config['base_url']}/api/v3/qualityprofile"
return selected_items
try:
response = requests.get(get_url, headers=headers)
if response.status_code == 200:
profiles_to_delete = response.json()
selected_ids = user_select_items_to_delete(profiles_to_delete)
for profile_id in selected_ids:
delete_url = f"{get_url}/{profile_id}"
del_response = requests.delete(delete_url, headers=headers)
profile_name = next((item['name'] for item in profiles_to_delete if item['id'] == profile_id), "Unknown")
if del_response.status_code in [200, 202, 204]:
print(Colors.OKBLUE + f"Deleting quality profile '{profile_name}': " + Colors.ENDC + Colors.OKGREEN + "SUCCESS" + Colors.ENDC)
else:
# Handle failure due to the profile being in use or other errors
error_message = "Failed to delete due to an unknown error."
try:
# Attempt to parse JSON error message from response
error_details = del_response.json()
if 'message' in error_details:
error_message = error_details['message']
elif 'error' in error_details:
error_message = error_details['error']
except json.JSONDecodeError:
# If response is not JSON or doesn't have expected fields
error_message = del_response.text or "Failed to delete with no detailed error message."
print(Colors.OKBLUE + f"Deleting quality profile '{profile_name}': " + Colors.ENDC + Colors.FAIL + f"FAIL - {error_message}" + Colors.ENDC)
else:
print_error("Failed to retrieve quality profiles for deletion!")
except requests.exceptions.ConnectionError:
print_connection_error()
def prompt_export_choice():
"""
Prompt user to choose between exporting Custom Formats, Quality Profiles, or both.
Returns a list of choices.
"""
print_message("Please select what you want to delete:", "blue")
options = {"1": "Custom Formats", "2": "Quality Profiles", "3": "Both"}
for key, value in options.items():
print_message(f"{key}. {value}", "green")
choice = input("Enter your choice: ").strip()
def get_app_config(app_name, instance_name):
if instance_name.endswith("[Master]"):
return master_config[app_name]
# Validate choice
while choice not in options:
print_message("Invalid choice, please select a valid option:", "red")
choice = input("Enter your choice: ").strip()
if choice == "3":
return ["Custom Formats", "Quality Profiles"]
else:
instance_name = instance_name.replace(f"{app_name.capitalize()} [", "").replace("]", "")
extras = config['instances']['extras'].get(app_name, [])
for instance in extras:
if instance['name'] == instance_name:
return instance
raise ValueError(f"Configuration for {app_name} - {instance_name} not found.")
return [options[choice]]
def delete_custom_formats_or_profiles(app, instance, item_type, config):
"""
Deletes either custom formats or quality profiles based on the item_type.
"""
api_key = instance['api_key']
base_url = instance['base_url']
resource_type = item_type # 'customformat' or 'qualityprofile'
if item_type == 'customformat':
type = 'Custom Format'
elif item_type == 'qualityprofile':
type = 'Quality Profile'
# Fetch items to delete
items = make_request('get', base_url, api_key, resource_type)
if items is None or not isinstance(items, list):
return
# Assuming a function to select items to delete. It could list items and ask the user which to delete.
items_to_delete = user_select_items_to_delete(items) # This needs to be implemented or adapted
# Proceed to delete selected items
for item in items_to_delete:
item_id = item['id']
item_name = item['name']
print_message(f"Deleting {type} ({item_name})", "blue", newline=False)
response = make_request('delete', base_url, api_key, f"{resource_type}/{item_id}")
if response in [200, 202, 204]:
print_message(" : SUCCESS", "green")
else:
print_message(" : FAIL", "red")
def main():
app = get_app_choice()
instances = get_instance_choice(app)
config = load_config()
choices = prompt_export_choice()
for instance in instances:
for choice in choices:
if choice == "Custom Formats":
delete_custom_formats_or_profiles(app, instance, 'customformat', config)
elif choice == "Quality Profiles":
delete_custom_formats_or_profiles(app, instance, 'qualityprofile', config)
if __name__ == "__main__":
selected_app, selected_instance = get_user_choice()
source_config = get_app_config(selected_app, selected_instance)
source_config['app_name'] = selected_app
print(Colors.HEADER + "\nChoose what to delete:" + Colors.ENDC)
print("1. Custom Formats")
print("2. Quality Profiles")
choice = input(Colors.HEADER + "Enter your choice (1/2): " + Colors.ENDC).strip()
if choice == "1":
delete_custom_formats(source_config)
elif choice == "2":
delete_quality_profiles(source_config)
main()

View File

@@ -1,23 +1,37 @@
version: "3.3"
x-common-settings: &common-settings
environment:
PUID: 1000 # user id, change as necessary
PGID: 1000 # group id, change as necessary
TZ: Europe/London # timezone, change as necessary
restart: unless-stopped
services:
radarr:
<<: *common-settings
image: linuxserver/radarr
container_name: radarr
environment:
- PUID=1000 # user id, change as necessary
- PGID=1000 # group id, change as necessary
- TZ=Europe/London # timezone, change as necessary
ports:
- "7887:7878" # change the left value to the desired host port for Radarr
restart: unless-stopped
radarr2:
<<: *common-settings
image: linuxserver/radarr
container_name: radarr2
ports:
- "7888:7878" # change the left value to the desired host port for Radarr
sonarr:
<<: *common-settings
image: linuxserver/sonarr
container_name: sonarr
environment:
- PUID=1000 # user id, change as necessary
- PGID=1000 # group id, change as necessary
- TZ=Europe/London # timezone, change as necessary
ports:
- "8998:8989" # change the left value to the desired host port for Sonarr
restart: unless-stopped
sonarr2:
<<: *common-settings
image: linuxserver/sonarr
container_name: sonarr2
ports:
- "8999:8989" # change the left value to the desired host port for Sonarr

View File

@@ -1,183 +1,133 @@
import requests
from helpers import *
import os
import re
import yaml
import json
# 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'
def prompt_export_choice():
options = { "1": "Custom Formats", "2": "Quality Profiles" }
# Load configuration for main app
with open('config.yml', 'r') as config_file:
config = yaml.safe_load(config_file)
master_config = config['instances']['master']
export_base_path = config['settings']['export_path']
print_message("Please select what you want to export:", "blue")
for number, option in options.items():
print_message(f"{number}. {option}", "green")
print_message("Enter the number(s) of your choice, multiple separated by commas, or type 'all' for all options", "yellow")
def get_user_choice():
sources = []
print(Colors.HEADER + "Available sources to export from:" + Colors.ENDC)
user_choice = input("Your choice: ")
# Add master installations
for app in master_config:
sources.append((app, f"{app.capitalize()} [Master]", "master"))
# Add extra installations
if "extras" in config['instances']:
for app, instances in config['instances']['extras'].items():
for install in instances:
sources.append((app, f"{app.capitalize()} [{install['name']}]", install['name']))
# Display sources with numbers
for idx, (app, name, _) in enumerate(sources, start=1):
print(f"{idx}. {name}")
# User selection
choice = input("Enter the number of the app to export from: ").strip()
while not choice.isdigit() or int(choice) < 1 or int(choice) > len(sources):
print(Colors.FAIL + "Invalid input. Please enter a valid number." + Colors.ENDC)
choice = input("Enter the number of the app to export from: ").strip()
selected_app, instance_name = sources[int(choice) - 1][0], sources[int(choice) - 1][2]
print()
return selected_app, instance_name
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 get_app_config(source):
app_config = master_config[source]
return app_config['base_url'], app_config['api_key']
def sanitize_filename(filename):
sanitized_filename = re.sub(r'[\\/*?:"<>|]', '_', filename)
return sanitized_filename
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)
if user_choice.lower() == 'all':
return list(options.values())
else:
print(Colors.FAIL + f"An error occurred! (HTTP {response.status_code})" + Colors.ENDC)
print("Response Content: ", response.content.decode('utf-8'))
return [options[choice] for choice in user_choice.split(',') if choice in options]
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 create_export_path(export_path, app):
# Create a directory path for the export
dir_path = os.path.join(export_path, 'custom_formats', app)
def ensure_directory_exists(directory):
if not os.path.exists(directory):
os.makedirs(directory)
print(Colors.OKBLUE + f"Created directory: {directory}" + Colors.ENDC)
# Create the directory if it doesn't exist
os.makedirs(dir_path, exist_ok=True)
def export_cf(source, instance_name, save_path=None):
if save_path is None:
save_path = os.path.join(export_base_path, source, instance_name, 'custom_formats')
ensure_directory_exists(save_path)
return dir_path
base_url, api_key = get_app_config(source)
headers = {"X-Api-Key": api_key}
params = {"apikey": api_key}
def export_custom_formats(app, instances, config):
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)
for instance in instances:
print_message(f"Exporting Custom Formats for {app} : {instance['name']}", 'blue')
if response.status_code == 200:
data = response.json()
print(Colors.OKGREEN + f"Found {len(data)} custom formats." + Colors.ENDC)
url = instance['base_url']
api_key = instance['api_key']
saved_formats = []
for custom_format in data:
custom_format.pop('id', None)
saved_formats.append(custom_format['name'])
file_path = os.path.join(save_path, f'Custom Formats ({source.capitalize()}).json')
with open(file_path, 'w') as f:
json.dump(data, f, indent=4)
# Get the export path from the config
export_path = config['settings']['export_path']
print_saved_items(saved_formats, "Custom Formats")
print(Colors.OKGREEN + f"Saved to '{file_path}'" + Colors.ENDC)
print()
else:
handle_response_errors(response)
# Create the export directory
dir_path = create_export_path(export_path, app)
except requests.exceptions.ConnectionError:
print(Colors.FAIL + f"Failed to connect to {source.capitalize()}! Please check if it's running and accessible." + Colors.ENDC)
# Assuming 'export' is a valid resource_type for the API
response = make_request('get', url, api_key, 'customformat')
successful_exports = 0 # Counter for successful exports
# Scrub the JSON data and save each custom format in its own file
all_custom_formats = []
for custom_format in response:
# Remove the 'id' field
custom_format.pop('id', None)
def export_qf(source, instance_name, save_path=None):
if save_path is None:
save_path = os.path.join(export_base_path, source, instance_name, 'profiles')
ensure_directory_exists(save_path)
all_custom_formats.append(custom_format)
successful_exports += 1 # Increment the counter if the export was successful
base_url, api_key = get_app_config(source)
headers = {"X-Api-Key": api_key}
params = {"apikey": api_key}
# Hardcode the file name as 'Custom Formats (Radarr).json'
file_name = f"Custom Formats ({app.capitalize()} - {instance['name']}).json"
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)
# Save all custom formats to a single file in the export directory
try:
with open(os.path.join(dir_path, file_name), 'w') as f:
json.dump(all_custom_formats, f, indent=4)
status = 'SUCCESS'
status_color = 'green'
except Exception as e:
status = 'FAILED'
status_color = 'red'
if response.status_code == 200:
quality_profiles = response.json()
print(Colors.OKGREEN + f"Found {len(quality_profiles)} quality profiles." + Colors.ENDC)
print_message(f"Exported {successful_exports} custom formats to {dir_path} for {instance['name']}", 'yellow')
print()
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(save_path, profile_filename)
with open(profile_filepath, 'w') as file:
json.dump([profile], file, indent=4)
saved_profiles.append(profile_name) # Add the profile name to the list
def create_quality_profiles_export_path(app, config):
# Get the export path from the config
export_path = config['settings']['export_path']
print_saved_items(saved_profiles, "Quality Profiles")
print(Colors.OKGREEN + f"Saved to '{os.path.normpath(save_path)}'" + Colors.ENDC) # Normalize the path
print()
else:
handle_response_errors(response)
# Create a directory path for the export
dir_path = os.path.join(export_path, 'quality_profiles', app)
except requests.exceptions.ConnectionError:
print(Colors.FAIL + f"Failed to connect to {source.capitalize()}! Please check if it's running and accessible." + Colors.ENDC)
# Create the directory if it doesn't exist
os.makedirs(dir_path, exist_ok=True)
return dir_path
def export_quality_profiles(app, instances, config):
for instance in instances:
print_message(f"Exporting Quality Profiles for {app} : {instance['name']}...", 'blue')
url = instance['base_url']
api_key = instance['api_key']
# Create the export directory
dir_path = create_quality_profiles_export_path(app, config)
# Assuming 'qualityprofile' is the valid resource_type for the API
response = make_request('get', url, api_key, 'qualityprofile')
successful_exports = 0 # Counter for successful exports
# Scrub the JSON data and save each quality profile in its own file
for quality_profile in response:
# Remove the 'id' field
quality_profile.pop('id', None)
# Create a file name from the quality profile name and app
file_name = f"{quality_profile['name']} ({app.capitalize()} - {instance['name']}).json"
file_name = re.sub(r'[\\/*?:"<>|]', '', file_name) # Remove invalid characters
# Save the quality profile to a file in the export directory
try:
with open(os.path.join(dir_path, file_name), 'w') as f:
json.dump([quality_profile], f, indent=4) # Wrap quality_profile in a list
status = 'SUCCESS'
status_color = 'green'
except Exception as e:
status = 'FAILED'
status_color = 'red'
if status == 'SUCCESS':
successful_exports += 1 # Increment the counter if the export was successful
print_message(f"Exported {successful_exports} quality profiles to {dir_path} for {instance['name']}", 'yellow')
print()
def main():
app = get_app_choice()
instances = get_instance_choice(app)
config = load_config()
export_custom_formats(app, instances, config)
export_quality_profiles(app, instances, config)
if __name__ == "__main__":
user_choice, instance_name = get_user_choice()
export_choice = get_export_choice()
if export_choice in ["1", "3"]:
export_cf(user_choice, instance_name)
if export_choice in ["2", "3"]:
export_qf(user_choice, instance_name)
main()

131
helpers.py Normal file
View File

@@ -0,0 +1,131 @@
import yaml
import json
import requests
from requests.exceptions import ConnectionError, Timeout, TooManyRedirects
import json
class Colors:
GREEN = '\033[92m'
RED = '\033[91m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
PURPLE = '\033[95m'
ENDC = '\033[0m'
class Apps:
APP_CHOICES = {
"1": "Radarr",
"2": "Sonarr",
# Add more apps here as needed
}
def print_message(message, message_type='', newline=True):
color = Colors.ENDC # default color
message_type = message_type.lower()
if message_type == 'green':
color = Colors.GREEN
elif message_type == 'red':
color = Colors.RED
elif message_type == 'yellow':
color = Colors.YELLOW
elif message_type == 'blue':
color = Colors.BLUE
elif message_type == 'purple':
color = Colors.PURPLE
if newline:
print(color + message + Colors.ENDC)
else:
print(color + message + Colors.ENDC, end='')
def load_config():
with open('config.yml', 'r') as config_file:
config = yaml.safe_load(config_file)
return config
def get_app_choice():
print_message("Select your app of choice", "blue")
# Dynamically generate the app selection menu
app_menu = "\n".join([f"{key}. {value}" for key, value in Apps.APP_CHOICES.items()])
print_message(app_menu)
print_message("Enter your choice: ", "blue")
app_choice = input().strip()
while app_choice not in Apps.APP_CHOICES.keys():
print_message("Invalid input. Please enter a valid choice.", "red")
app_choice = input().strip()
app = Apps.APP_CHOICES[app_choice]
return app
def get_instance_choice(app):
config = load_config()
app_instances = config['instances'].get(app.lower(), [])
print_message(f"Select your {app.capitalize()} instance", "blue")
# Display instances and prompt for choice
for i, instance in enumerate(app_instances, start=1):
print_message(f"{i}. {app.capitalize()} ({instance['name']})")
print_message("Choose an instance by number, multiple numbers separated by commas or type 'all' for all instances: ", "blue")
choice = input().strip()
print()
selected_instances = []
if choice.lower() == 'all':
selected_instances = app_instances
else:
choices = choice.split(',')
for choice in choices:
choice = choice.strip() # remove any leading/trailing whitespace
while not choice.isdigit() or int(choice) < 1 or int(choice) > len(app_instances):
print_message("Invalid input. Please select a valid number.", "warning")
choice = input().strip()
selected_instance = app_instances[int(choice) - 1]
selected_instances.append(selected_instance)
return selected_instances
def make_request(request_type, url, api_key, resource_type, json_payload=None):
full_url = f"{url}/api/v3/{resource_type}"
headers = {"X-Api-Key": api_key}
try:
# Make the appropriate request based on the request_type
if request_type.lower() == 'get':
response = requests.get(full_url, headers=headers, json=json_payload)
elif request_type.lower() == 'post':
response = requests.post(full_url, headers=headers, json=json_payload)
elif request_type.lower() == 'put':
response = requests.put(full_url, headers=headers, json=json_payload)
elif request_type.lower() == 'delete':
response = requests.delete(full_url, headers=headers)
return response.status_code
elif request_type.lower() == 'patch':
response = requests.patch(full_url, headers=headers, json=json_payload)
else:
raise ValueError("Unsupported request type provided.")
# Process response
if response.status_code in [200, 201, 202]:
try:
return response.json()
except json.JSONDecodeError:
print_message("Failed to decode JSON response.", "red")
return None
elif response.status_code == 401:
print_message("Unauthorized. Check your API key.", "red")
elif response.status_code == 409:
print_message("Conflict detected. The requested action could not be completed.", "red")
else:
print_message(f"HTTP Error {response.status_code}.", "red")
except (ConnectionError, Timeout, TooManyRedirects) as e:
# Update the message here to suggest checking the application's accessibility
print_message("Network error. Make sure the application is running and accessible.", "red")
return None

View File

@@ -1,265 +1,211 @@
import requests
from helpers import *
import os
import re
import yaml
import fnmatch
import json
# 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.yml', 'r') as config_file:
config = yaml.safe_load(config_file)
master_config = config['instances']['master']
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():
sources = []
print(Colors.HEADER + "Available instances to import to:" + Colors.ENDC)
# Add master installations
for app in master_config:
sources.append((app, f"{app.capitalize()} [Master]"))
# Add extra installations
if "extras" in config['instances']:
for app, instances in config['instances']['extras'].items():
for install in instances:
sources.append((app, f"{app.capitalize()} [{install['name']}]"))
# Display sources with numbers
for idx, (app, name) in enumerate(sources, start=1):
print(f"{idx}. {name}")
# User selection
choice = input("Enter the number of the instance to import to: ").strip()
while not choice.isdigit() or int(choice) < 1 or int(choice) > len(sources):
print_error("Invalid input. Please enter a valid number.")
choice = input("Enter the number of the instance to import to: ").strip()
selected_app, selected_name = sources[int(choice) - 1]
print()
return selected_app, selected_name
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(app_name, instance_name):
if instance_name.endswith("[Master]"):
return master_config[app_name]
else:
instance_name = instance_name.replace(f"{app_name.capitalize()} [", "").replace("]", "")
extras = config['instances']['extras'].get(app_name, [])
for instance in extras:
if instance['name'] == instance_name:
return instance
raise ValueError(f"Configuration for {app_name} - {instance_name} not found.")
def select_file(directory, app_name, sync_mode=False):
app_name = app_name.lower()
files = [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f)) and app_name in f.lower()]
if not files:
print_error(f"No files found for {app_name.capitalize()} in {directory}.")
return None
if sync_mode:
# Automatically select all files in sync mode
return files
print()
print(Colors.OKBLUE + "Available files:" + Colors.ENDC)
for i, file in enumerate(files, 1):
print(f"{i}. {file}")
choice = input("Select a file to import (or 'all' for all files): ").strip()
print()
if choice.isdigit() and 1 <= int(choice) <= len(files):
return [files[int(choice) - 1]]
elif choice.lower() == 'all':
return files
else:
print_error("Invalid input. Please enter a valid number or 'all'.")
print()
return None
def get_custom_formats(app):
config = load_config()
import_path = f"{config['settings']['import_path']}/custom_formats/{app}" # Adjusted path
for file in os.listdir(import_path):
if fnmatch.fnmatch(file, f'*{app}*'):
return file
return None
def import_custom_formats(source_config, import_path='./custom_formats', selected_files=None, sync_mode=False):
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}
if selected_files is None:
selected_files = select_file(import_path, source_config['app_name'], sync_mode=sync_mode)
if not selected_files:
return # Exit if no file is selected
for selected_file in selected_files:
added_count, updated_count = 0, 0
with open(os.path.join(import_path, selected_file), '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"{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.")
def process_format(format, existing_names_to_id, base_url, api_key):
format_name = format['name']
if format_name in existing_names_to_id:
format_id = existing_names_to_id[format_name]
response = make_request('put', base_url, api_key, f'customformat/{format_id}', format)
if response is not None:
print_message(f"Updating custom format '{format_name}'", "yellow", newline=False)
print_message(" : SUCCESS", "green")
return 1, 0
else:
print_error(f"Failed to retrieve existing custom formats from {get_url}! (HTTP {response.status_code})")
print(response.content.decode())
print_message(f"Updating custom format '{format_name}'", "yellow", newline=False)
print_message(" : FAIL", "red", newline=False)
else:
response = make_request('post', base_url, api_key, 'customformat', format)
if response is not None:
print_message(f"Adding custom format '{format_name}'", "blue", newline=False)
print_message(" : SUCCESS", "green")
return 0, 1
else:
print_message(f"Adding custom format '{format_name}'", "blue", newline=False)
print_message(" : FAIL", "red", newline=False)
return 0, 0
except requests.exceptions.ConnectionError:
print_connection_error()
def import_custom_formats(app, instances):
config = load_config()
for instance in instances:
api_key = instance['api_key']
base_url = instance['base_url']
existing_formats = make_request('get', base_url, api_key, 'customformat')
existing_names_to_id = {format['name']: format['id'] for format in existing_formats}
app_file = get_custom_formats(app)
if app_file is None:
print_message(f"No file found for app: {app}", "red")
continue
added_count, updated_count = 0, 0
with open(f"{config['settings']['import_path']}/custom_formats/{app}/{app_file}", 'r') as import_file:
import_formats = json.load(import_file)
print_message(f"Importing custom formats to {app} : {instance['name']}", "purple")
print()
for format in import_formats:
added, updated = process_format(format, existing_names_to_id, base_url, api_key)
added_count += added
updated_count += updated
print()
print_message(
f"Successfully added {added_count} custom formats, "
f"updated {updated_count} custom formats.",
"purple"
)
print()
def get_profiles(app):
config = load_config()
import_path = f"{config['settings']['import_path']}/quality_profiles/{app.lower()}" # Adjusted path
matching_files = [] # Create an empty list to hold matching files
for file in os.listdir(import_path):
if fnmatch.fnmatch(file, f'*{app}*'):
matching_files.append(file) # Add matching file to the list
return matching_files # Return the list of matching files
def get_existing_profiles(base_url, api_key):
resource_type = 'qualityprofile'
existing_profiles = make_request('get', base_url, api_key, resource_type)
return {profile['name']: profile for profile in existing_profiles} if existing_profiles else {}
def cf_import_sync(instances):
for instance in instances:
api_key = instance['api_key']
base_url = instance['base_url']
resource_type = 'customformat'
response = make_request('get', base_url, api_key, resource_type)
if response:
instance['custom_formats'] = {format['name']: format['id'] for format in response}
else:
print_message("No custom formats found for this instance.", "purple")
print()
def user_select_profiles(profiles):
print_message("Available profiles:", "purple")
for idx, profile in enumerate(profiles, start=1):
print(f"{idx}. {profile}")
print()
while True:
# Display prompt message
print_message("Enter the numbers of the profiles you want to import separated by commas, or type 'all' to import all profiles: ", "blue", newline=False)
print()
user_input = input().strip()
if user_input.lower() == 'all':
return profiles # Return all profiles if 'all' is selected
selected_profiles = []
try:
selected_indices = [int(index.strip()) for index in user_input.split(',')]
for index in selected_indices:
if 1 <= index <= len(profiles):
selected_profiles.append(profiles[index - 1])
else:
raise ValueError(f"Invalid selection: {index}. Please enter valid numbers.") # Raise an error to trigger except block
return selected_profiles # Return the selected profiles if all inputs are valid
except ValueError as e:
print_message(str(e), "red") # Display error message in red
def import_quality_profiles(source_config, import_path='./profiles', selected_files=None, sync_mode=False):
headers = {"X-Api-Key": source_config['api_key']}
def process_profile(profile, base_url, api_key, custom_formats, existing_profiles):
profile_name = profile.get('name')
existing_profile = existing_profiles.get(profile_name)
try:
cf_import_sync(source_config)
# Update or add custom format items as needed
if 'formatItems' in profile:
for format_item in profile['formatItems']:
format_name = format_item.get('name')
if format_name and format_name in custom_formats:
format_item['format'] = custom_formats[format_name]
if not selected_files:
if sync_mode:
# Automatically select all profile files
selected_files = [f for f in os.listdir(import_path) if os.path.isfile(os.path.join(import_path, f))]
for format_name, format_id in custom_formats.items():
if format_name not in {item.get('name') for item in profile.get('formatItems', [])}:
profile.setdefault('formatItems', []).append({
"format": format_id,
"name": format_name,
"score": 0
})
if not selected_files:
return # Exit if no file is selected
if existing_profile:
profile['id'] = existing_profile['id']
action = "Updating"
action_color = "yellow"
resource_type = f"qualityprofile/{profile['id']}"
else:
action = "Adding"
action_color = "blue"
resource_type = "qualityprofile"
for selected_file in selected_files:
with open(os.path.join(import_path, selected_file), 'r') as file:
response = make_request('put' if existing_profile else 'post', base_url, api_key, resource_type, profile)
# Print the action statement in blue for Adding and yellow for Updating
print_message(f"{action} '{profile_name}' quality profile", action_color, newline=False)
# Determine the status and print the status in green (OK) or red (FAIL)
if response:
print_message(" : SUCCESS", "green")
else:
print_message(" : FAIL", "red")
def import_quality_profiles(app, instances):
config = load_config()
cf_import_sync(instances)
all_profiles = get_profiles(app)
selected_profiles_names = user_select_profiles(all_profiles)
for instance in instances:
base_url = instance['base_url']
api_key = instance['api_key']
custom_formats = instance.get('custom_formats', {})
existing_profiles = get_existing_profiles(base_url, api_key) # Retrieve existing profiles
print_message(f"Importing Quality Profiles to {app} : {instance['name']}", "purple")
print()
for profile_file in selected_profiles_names:
with open(f"{config['settings']['import_path']}/quality_profiles/{app}/{profile_file}", 'r') as file:
try:
quality_profiles = json.load(file)
except json.JSONDecodeError as e:
print_error(f"Error loading selected profile: {e}")
print_message(f"Error loading selected profile: {e}", "red")
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]
process_profile(profile, base_url, api_key, custom_formats, existing_profiles)
print()
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)
def main():
app = get_app_choice()
instances = get_instance_choice(app)
import_custom_formats(app, instances)
import_quality_profiles(app, instances)
if __name__ == "__main__":
selected_app, selected_instance = get_user_choice()
source_config = get_app_config(selected_app, selected_instance)
source_config['app_name'] = selected_app
import_choice = get_import_choice()
if import_choice == "1":
selected_files = select_file('./custom_formats', selected_app)
if selected_files:
import_custom_formats(source_config, './custom_formats', selected_files)
elif import_choice == "2":
selected_files = select_file('./profiles', selected_app)
if selected_files:
import_quality_profiles(source_config, './profiles', selected_files)
main()

View File

@@ -8949,6 +8949,130 @@
"isFloat": false
}
]
},
{
"name": "WEB",
"implementation": "SourceSpecification",
"implementationName": "Source",
"infoLink": "https://wiki.servarr.com/radarr/settings#custom-formats-2",
"negate": true,
"required": false,
"fields": [
{
"order": 0,
"name": "value",
"label": "Source",
"value": 7,
"type": "select",
"advanced": false,
"selectOptions": [
{
"value": 0,
"name": "UNKNOWN",
"order": 0,
"dividerAfter": false
},
{
"value": 1,
"name": "CAM",
"order": 1,
"dividerAfter": false
},
{
"value": 2,
"name": "TELESYNC",
"order": 2,
"dividerAfter": false
},
{
"value": 3,
"name": "TELECINE",
"order": 3,
"dividerAfter": false
},
{
"value": 4,
"name": "WORKPRINT",
"order": 4,
"dividerAfter": false
},
{
"value": 5,
"name": "DVD",
"order": 5,
"dividerAfter": false
},
{
"value": 6,
"name": "TV",
"order": 6,
"dividerAfter": false
},
{
"value": 7,
"name": "WEBDL",
"order": 7,
"dividerAfter": false
},
{
"value": 8,
"name": "WEBRIP",
"order": 8,
"dividerAfter": false
},
{
"value": 9,
"name": "BLURAY",
"order": 9,
"dividerAfter": false
}
],
"privacy": "normal",
"isFloat": false
}
]
},
{
"name": "AMiABLE",
"implementation": "ReleaseGroupSpecification",
"implementationName": "Release Group",
"infoLink": "https://wiki.servarr.com/radarr/settings#custom-formats-2",
"negate": false,
"required": false,
"fields": [
{
"order": 0,
"name": "value",
"label": "Regular Expression",
"helpText": "Custom Format RegEx is Case Insensitive",
"value": "(?<=^|[\\s.-])AMiABLE\\b",
"type": "textbox",
"advanced": false,
"privacy": "normal",
"isFloat": false
}
]
},
{
"name": "PiGNUS",
"implementation": "ReleaseGroupSpecification",
"implementationName": "Release Group",
"infoLink": "https://wiki.servarr.com/radarr/settings#custom-formats-2",
"negate": false,
"required": false,
"fields": [
{
"order": 0,
"name": "value",
"label": "Regular Expression",
"helpText": "Custom Format RegEx is Case Insensitive",
"value": "(?<=^|[\\s.-])PiGNUS\\b",
"type": "textbox",
"advanced": false,
"privacy": "normal",
"isFloat": false
}
]
}
]
},
@@ -12794,5 +12918,32 @@
]
}
]
},
{
"name": "TEST FLAC",
"includeCustomFormatWhenRenaming": false,
"specifications": [
{
"name": "flac",
"implementation": "ReleaseTitleSpecification",
"implementationName": "Release Title",
"infoLink": "https://wiki.servarr.com/radarr/settings#custom-formats-2",
"negate": false,
"required": true,
"fields": [
{
"order": 0,
"name": "value",
"label": "Regular Expression",
"helpText": "Custom Format RegEx is Case Insensitive",
"value": "[.\\- ]FLAC",
"type": "textbox",
"advanced": false,
"privacy": "normal",
"isFloat": false
}
]
}
]
}
]

View File

@@ -366,6 +366,21 @@
"minFormatScore": 0,
"cutoffFormatScore": 500,
"formatItems": [
{
"format": 338,
"name": "TEST FLAC",
"score": 0
},
{
"format": 337,
"name": "h265 (4k)",
"score": -9999
},
{
"format": 336,
"name": "MAX",
"score": 0
},
{
"format": 335,
"name": "Blu-Ray (Remux)",

View File

@@ -366,6 +366,21 @@
"minFormatScore": 0,
"cutoffFormatScore": 0,
"formatItems": [
{
"format": 338,
"name": "TEST FLAC",
"score": 0
},
{
"format": 337,
"name": "h265 (4k)",
"score": 0
},
{
"format": 336,
"name": "MAX",
"score": 0
},
{
"format": 335,
"name": "Blu-Ray (Remux)",

View File

@@ -359,6 +359,21 @@
"minFormatScore": 0,
"cutoffFormatScore": 1000,
"formatItems": [
{
"format": 338,
"name": "TEST FLAC",
"score": 0
},
{
"format": 337,
"name": "h265 (4k)",
"score": 0
},
{
"format": 336,
"name": "MAX",
"score": 0
},
{
"format": 335,
"name": "Blu-Ray (Remux)",

View File

@@ -359,6 +359,21 @@
"minFormatScore": 0,
"cutoffFormatScore": 0,
"formatItems": [
{
"format": 338,
"name": "TEST FLAC",
"score": 0
},
{
"format": 337,
"name": "h265 (4k)",
"score": 0
},
{
"format": 336,
"name": "MAX",
"score": 0
},
{
"format": 335,
"name": "Blu-Ray (Remux)",

View File

@@ -366,6 +366,21 @@
"minFormatScore": 0,
"cutoffFormatScore": 140,
"formatItems": [
{
"format": 338,
"name": "TEST FLAC",
"score": 0
},
{
"format": 337,
"name": "h265 (4k)",
"score": 0
},
{
"format": 336,
"name": "MAX",
"score": 0
},
{
"format": 335,
"name": "Blu-Ray (Remux)",

View File

@@ -366,6 +366,21 @@
"minFormatScore": 0,
"cutoffFormatScore": 500,
"formatItems": [
{
"format": 338,
"name": "TEST FLAC",
"score": 0
},
{
"format": 337,
"name": "h265 (4k)",
"score": 0
},
{
"format": 336,
"name": "MAX",
"score": 0
},
{
"format": 335,
"name": "Blu-Ray (Remux)",

View File

@@ -366,6 +366,21 @@
"minFormatScore": 0,
"cutoffFormatScore": 0,
"formatItems": [
{
"format": 338,
"name": "TEST FLAC",
"score": 0
},
{
"format": 337,
"name": "h265 (4k)",
"score": 0
},
{
"format": 336,
"name": "MAX",
"score": 0
},
{
"format": 335,
"name": "Blu-Ray (Remux)",

View File

@@ -366,6 +366,21 @@
"minFormatScore": 0,
"cutoffFormatScore": 1000,
"formatItems": [
{
"format": 338,
"name": "TEST FLAC",
"score": 0
},
{
"format": 337,
"name": "h265 (4k)",
"score": 0
},
{
"format": 336,
"name": "MAX",
"score": 0
},
{
"format": 335,
"name": "Blu-Ray (Remux)",

View File

@@ -366,6 +366,21 @@
"minFormatScore": 0,
"cutoffFormatScore": 0,
"formatItems": [
{
"format": 338,
"name": "TEST FLAC",
"score": 0
},
{
"format": 337,
"name": "h265 (4k)",
"score": 0
},
{
"format": 336,
"name": "MAX",
"score": 0
},
{
"format": 335,
"name": "Blu-Ray (Remux)",

View File

@@ -359,10 +359,50 @@
"minFormatScore": 0,
"cutoffFormatScore": 320,
"formatItems": [
{
"format": 344,
"name": "jennaortegaUHD",
"score": -99999
},
{
"format": 342,
"name": "Freeleech25",
"score": 3
},
{
"format": 341,
"name": "Freeleech50",
"score": 2
},
{
"format": 340,
"name": "Freeleech75",
"score": 1
},
{
"format": 339,
"name": "Freeleech100",
"score": 4
},
{
"format": 338,
"name": "TEST FLAC",
"score": 0
},
{
"format": 337,
"name": "h265 (4k)",
"score": 0
},
{
"format": 336,
"name": "MAX",
"score": 0
},
{
"format": 335,
"name": "Blu-Ray (Remux)",
"score": 0
"score": 60
},
{
"format": 334,

View File

@@ -359,10 +359,50 @@
"minFormatScore": 0,
"cutoffFormatScore": 0,
"formatItems": [
{
"format": 344,
"name": "jennaortegaUHD",
"score": -9999
},
{
"format": 342,
"name": "Freeleech25",
"score": 0
},
{
"format": 341,
"name": "Freeleech50",
"score": 0
},
{
"format": 340,
"name": "Freeleech75",
"score": 0
},
{
"format": 339,
"name": "Freeleech100",
"score": 0
},
{
"format": 338,
"name": "TEST FLAC",
"score": 0
},
{
"format": 337,
"name": "h265 (4k)",
"score": 0
},
{
"format": 336,
"name": "MAX",
"score": 0
},
{
"format": 335,
"name": "Blu-Ray (Remux)",
"score": 0
"score": 60
},
{
"format": 334,

View File

@@ -239,6 +239,21 @@
"minFormatScore": 0,
"cutoffFormatScore": 0,
"formatItems": [
{
"format": 229,
"name": "HR",
"score": 0
},
{
"format": 228,
"name": "MAX",
"score": 0
},
{
"format": 227,
"name": "h265 (4k)",
"score": 0
},
{
"format": 226,
"name": "PCM",
@@ -247,7 +262,7 @@
{
"format": 225,
"name": "Blu-Ray (Remux)",
"score": 0
"score": 60
},
{
"format": 224,

View File

@@ -239,6 +239,21 @@
"minFormatScore": 0,
"cutoffFormatScore": 320,
"formatItems": [
{
"format": 229,
"name": "HR",
"score": 0
},
{
"format": 228,
"name": "MAX",
"score": 0
},
{
"format": 227,
"name": "h265 (4k)",
"score": 0
},
{
"format": 226,
"name": "PCM",
@@ -247,7 +262,7 @@
{
"format": 225,
"name": "Blu-Ray (Remux)",
"score": 0
"score": 60
},
{
"format": 224,

Binary file not shown.

View File

@@ -1,24 +1,23 @@
config_content = """
instances:
master:
sonarr:
base_url: "http://localhost:8989"
api_key: "API_KEY"
radarr:
radarr:
- name: "Master"
base_url: "http://localhost:7878"
api_key: "API_KEY"
extras:
sonarr:
- name: "4k-sonarr"
base_url: "http://localhost:8998"
api_key: "API_KEY"
radarr:
- name: "4k-radarr"
base_url: "http://localhost:7887"
api_key: "API_KEY"
- name: "4k-radarr"
base_url: "http://localhost:7887"
api_key: "API_KEY"
sonarr:
- name: "Master"
base_url: "http://localhost:8989"
api_key: "API_KEY"
- name: "4k-sonarr"
base_url: "http://localhost:8998"
api_key: "API_KEY"
settings:
export_path: "./exports"
import_path: "./imports"
"""
with open('config.yml', 'w') as file:

View File

@@ -1,47 +1,22 @@
import yaml
import json
import shutil
import os
import exportarr # Assuming this module contains the export functions
import importarr # Assuming this module contains the import functions
from exportarr import export_custom_formats, export_quality_profiles
from importarr import import_custom_formats, import_quality_profiles
from helpers import load_config, get_app_choice
def sync_data(sync_mode=False):
# Load configuration from YAML file
with open('config.yml', 'r') as config_file:
config = yaml.safe_load(config_file)
def main():
app = get_app_choice().lower() # Convert to lowercase
config = load_config() # Load the entire configuration
# Specify the temporary path where files will be saved
temp_cf_path = './temp_directory/custom_formats'
temp_qf_path = './temp_directory/quality_profiles'
# Now app will be 'radarr' or 'sonarr', matching the keys in the config dictionary
master_instance = next((inst for inst in config['instances'][app] if inst['name'] == 'Master'), None)
extra_instances = [inst for inst in config['instances'][app] if inst['name'] != 'Master']
# Get user choice for app (radarr/sonarr)
app_choice = input("Select the app you want to sync:\n1. Radarr\n2. Sonarr\nEnter your choice (1 or 2): ").strip()
while app_choice not in ["1", "2"]:
print("Invalid input. Please enter 1 for Radarr or 2 for Sonarr.")
app_choice = input("Enter your choice (1 or 2): ").strip()
app_choice = "radarr" if app_choice == "1" else "sonarr"
instance_name = "temp"
exportarr.export_cf(app_choice, instance_name, save_path=temp_cf_path)
exportarr.export_qf(app_choice, instance_name, save_path=temp_qf_path)
# Sync with each extra installation of the chosen app
for extra_instance in config['instances'].get('extras', {}).get(app_choice, []):
print(f"Importing to instance: {extra_instance['name']}")
# Import custom formats and quality profiles to each extra instance
extra_instance['app_name'] = app_choice
importarr.import_custom_formats(extra_instance, import_path=temp_cf_path, selected_files=None, sync_mode=sync_mode)
importarr.import_quality_profiles(extra_instance, import_path=temp_qf_path, selected_files=None, sync_mode=sync_mode)
# 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 master_instance:
export_custom_formats(app, [master_instance], config)
export_quality_profiles(app, [master_instance], config)
if extra_instances:
import_custom_formats(app, extra_instances)
import_quality_profiles(app, extra_instances)
if __name__ == "__main__":
# Set sync_mode to True to enable automatic selection of all files during import
sync_data(sync_mode=True)
main()