Radarr quality definition support

pull/5/head
Robert Dailey 3 years ago
parent 8c31968bfc
commit 3aad617219

@ -1,6 +1,6 @@
# TRaSH Guide Updater Script
Automatically mirror TRaSH guides to your *darr instance.
Automatically mirror TRaSH guides to your Sonarr/Radarr instance.
> **NOTICE**: This is a work-in-progress Python script
@ -19,6 +19,7 @@ Features list will continue to grow. See the limitations & roadmap section for m
* Ability to convert preferred with negative scores to "Must not contain" terms.
* Sonarr Quality Definitions
* Anime and Non-Anime quality definitions are now synced to Sonarr
* Radarr Quality Definition can be synced (there's only one for now).
* Configuration support using YAML
* Many command line arguments can instead be provided in YAML configuration to reduce the
redundancy of using the CLI.
@ -151,12 +152,9 @@ appreciated and will help identify issues more quickly.
This script is a work in progress. At the moment, it only supports the following features and/or has
the following limitations:
* Only Sonarr is supported (Radarr will come in the future)
* Only the [Sonarr Anime Guide][1] is supported (more guides to come)
* Radarr custom formats are not supported yet (coming soon).
* Multiple scores on the same line are not supported. Only the first is used.
[1]: https://trash-guides.info/Sonarr/V3/Sonarr-Release-Profile-RegEx-Anime/
### Roadmap
In addition to the above limitations, the following items are planned for the future.
@ -164,7 +162,6 @@ In addition to the above limitations, the following items are planned for the fu
* Better and more polished error handling (it's pretty minimal right now)
* Implement some sort of guide versioning (e.g. to avoid updating a release profile if the guide did
not change).
* Unit Testing
## Development / Contributing

@ -0,0 +1,63 @@
import requests
import json
from copy import deepcopy
from app.api.server import Server, TrashHttpError
from app.trash_error import TrashError
class RadarrHttpError(TrashHttpError):
@staticmethod
def get_error_message(response: requests.Response):
content = json.loads(response.content)
if len(content) > 0:
if type(content) is list:
return content[0]['errorMessage']
elif type(content) is dict and 'message' in content:
return content['message']
return None
def __str__(self):
msg = f'HTTP Response Error [Status Code {self.response.status_code}] [URI: {self.response.url}]'
if error_msg := RadarrHttpError.get_error_message(self.response):
msg += f'\n Response Message: {error_msg}'
return msg
class Radarr(Server):
# --------------------------------------------------------------------------------------------------
def __init__(self, args, logger):
if not args.base_uri or not args.api_key:
raise TrashError('--base-uri and --api-key are required arguments when not using --preview')
self.logger = logger
base_uri = f'{args.base_uri}/api/v3'
key = f'?apikey={args.api_key}'
super().__init__(base_uri, key, RadarrHttpError)
# --------------------------------------------------------------------------------------------------
# GET /qualitydefinition
def get_quality_definition(self):
return self.request('get', '/qualitydefinition')
# --------------------------------------------------------------------------------------------------
# PUT /qualityDefinition/update
def update_quality_definition(self, server_definition, guide_definition):
new_definition = deepcopy(server_definition)
for quality, min, max, preferred in guide_definition:
entry = self.find_quality_definition_entry(new_definition, quality)
if not entry:
print(f'WARN: Quality definition lacks entry for {quality}; it will be skipped.')
continue
entry['minSize'] = min
entry['maxSize'] = max
entry['preferredSize'] = preferred
self.request('put', '/qualityDefinition/update', new_definition)
# --------------------------------------------------------------------------------------------------
def find_quality_definition_entry(self, definition, quality):
for entry in definition:
if entry.get('quality').get('name') == quality:
return entry
return None

@ -27,14 +27,14 @@ class SonarrHttpError(TrashHttpError):
class Sonarr(Server):
# --------------------------------------------------------------------------------------------------
def __init__(self, args, logger):
base_uri = f'{args.base_uri}/api/v3'
key = f'?apikey={args.api_key}'
self.logger = logger
super().__init__(base_uri, key, SonarrHttpError)
if not args.base_uri or not args.api_key:
raise TrashError('--base-uri and --api-key are required arguments when not using --preview')
self.logger = logger
base_uri = f'{args.base_uri}/api/v3'
key = f'?apikey={args.api_key}'
super().__init__(base_uri, key, SonarrHttpError)
self.do_version_check()
# --------------------------------------------------------------------------------------------------

@ -1,7 +1,7 @@
import argparse
from app.guide.profile import types as profile_types
from app.guide.quality import types as quality_types
from app.guide.profile_types import types as profile_types
from app.guide.quality_types import types as quality_types
# class args: pass
class _NoAction(argparse.Action):
@ -38,7 +38,6 @@ def setup_and_parse_args(args_override=None):
parents=[parent_p])
_add_choices_argument(profile_p, 'type', 'The specific guide type/page to pull data from.',
{type: data.get('cmd_help') for type, data in profile_types.items()})
# })
profile_p.add_argument('--tags', help='Tags to assign to the profiles that are created or updated. These tags will replace any existing tags when updating profiles.',
nargs='+')
profile_p.add_argument('--strict-negative-scores', help='Any negative scores get added to the list of "Must Not Contain" items',
@ -49,5 +48,7 @@ def setup_and_parse_args(args_override=None):
parents=[parent_p])
_add_choices_argument(quality_p, 'type', 'The specific guide type/page to pull data from.',
{type: data.get('cmd_help') for type, data in quality_types.items()})
quality_p.add_argument('--preferred-percentage', help='A percentage value that determines the preferred quality, when needed. Default is 100. Value is interpolated between the min (0%%) and max (100%%) value for each table row.',
type=int, default=100, metavar='[0-100]')
return parser.parse_args(args=args_override)

@ -1 +0,0 @@
from . import profile, quality, utils

@ -0,0 +1,13 @@
# This defines general information specific to guide types. Used across different modules as needed.
types = {
'sonarr:anime': {
'cmd_help': 'The anime release profile for Sonarr v3',
'markdown_doc_name': 'Sonarr-Release-Profile-RegEx-Anime',
'profile_typename': 'Anime'
},
'sonarr:web-dl': {
'cmd_help': 'The WEB-DL release profile for Sonarr v3',
'markdown_doc_name': 'Sonarr-Release-Profile-RegEx',
'profile_typename': 'WEB-DL'
},
}

@ -0,0 +1,15 @@
# This defines general information specific to quality definition types. Used across different modules as needed.
types = {
'sonarr:anime': {
'cmd_help': 'Choose the Sonarr quality definition best fit for anime'
},
'sonarr:non-anime': {
'cmd_help': 'Choose the Sonarr quality definition best fit for tv shows (non-anime)'
},
'sonarr:hybrid': {
'cmd_help': 'The script will generate a Sonarr quality definition that works best for all show types'
},
'radarr:movies': {
'cmd_help': 'Choose the Radarr quality definition used for movies.'
},
}

@ -0,0 +1,39 @@
import requests
import re
from collections import defaultdict
header_regex = re.compile(r'^#+')
table_row_regex = re.compile(r'\| *(.*?) *\| *([\d.]+) *\| *([\d.]+) *\|')
# --------------------------------------------------------------------------------------------------
def get_markdown():
markdown_page_url = 'https://raw.githubusercontent.com/TRaSH-/Guides/master/docs/Radarr/V3/Radarr-Quality-Settings-File-Size.md'
response = requests.get(markdown_page_url)
return response.content.decode('utf8')
# --------------------------------------------------------------------------------------------------
def parse_markdown(args, logger, markdown_content):
results = defaultdict(list)
table = None
# Convert from 0-100 to 0.0-1.0
preferred_ratio = args.preferred_percentage / 100
for line in markdown_content.splitlines():
if not line:
continue
if header_regex.search(line):
category = args.type
table = results[category]
if len(table) > 0:
table = None
elif (match := table_row_regex.search(line)) and table is not None:
quality = match.group(1)
min = float(match.group(2))
max = float(match.group(3))
# TODO: Support reading preferred from table data in the guide
preferred = round(min + (max-min) * preferred_ratio, 1)
table.append((quality, min, max, preferred))
return results

@ -0,0 +1,54 @@
# --------------------------------------------------------------------------------------------------
# Filter out false-positive profiles that are empty.
def filter_profiles(profiles):
for name in list(profiles.keys()):
profile = profiles[name]
if not len(profile.required) and not len(profile.ignored) and not len(profile.preferred):
del profiles[name]
# --------------------------------------------------------------------------------------------------
def print_terms_and_scores(profiles):
for name, profile in profiles.items():
print(name)
if profile.include_preferred_when_renaming is not None:
print(' Include Preferred when Renaming?')
print(' ' + ('CHECKED' if profile.include_preferred_when_renaming else 'NOT CHECKED'))
print('')
if len(profile.required):
print(' Must Contain:')
for term in profile.required:
print(f' {term}')
print('')
if len(profile.ignored):
print(' Must Not Contain:')
for term in profile.ignored:
print(f' {term}')
print('')
if len(profile.preferred):
print(' Preferred:')
for score, terms in profile.preferred.items():
for term in terms:
print(f' {score:<10} {term}')
print('')
# --------------------------------------------------------------------------------------------------
def find_existing_profile(profile_name, existing_profiles):
for p in existing_profiles:
if p.get('name') == profile_name:
return p
return None
# --------------------------------------------------------------------------------------------------
def quality_preview(definition):
print('')
formats = '{:<20} {:<10} {:<10} {:<10}'
print(formats.format('Quality', 'Min', 'Max', 'Preferred'))
print(formats.format('-------', '---', '---', '---'))
for (quality, min, max, preferred) in definition:
print(formats.format(quality, min, max, preferred))
print('')

@ -0,0 +1 @@
from . import profile, quality

@ -3,21 +3,7 @@ from collections import defaultdict
from enum import Enum
import requests
from ..profile_data import ProfileData
# This defines general information specific to guide types. Used across different modules as needed.
types = {
'sonarr:anime': {
'cmd_help': 'The anime release profile for Sonarr v3',
'markdown_doc_name': 'Sonarr-Release-Profile-RegEx-Anime',
'profile_typename': 'Anime'
},
'sonarr:web-dl': {
'cmd_help': 'The WEB-DL release profile for Sonarr v3',
'markdown_doc_name': 'Sonarr-Release-Profile-RegEx',
'profile_typename': 'WEB-DL'
}
}
from app.profile_data import ProfileData
TermCategory = Enum('TermCategory', 'Preferred Required Ignored')

@ -2,19 +2,6 @@ import requests
import re
from collections import defaultdict
# This defines general information specific to quality definition types. Used across different modules as needed.
types = {
'sonarr:anime': {
'cmd_help': 'Choose the Sonarr quality definition best fit for anime'
},
'sonarr:non-anime': {
'cmd_help': 'Choose the Sonarr quality definition best fit for tv shows (non-anime)'
},
'sonarr:hybrid': {
'cmd_help': 'The script will generate a Sonarr quality definition that works best for all show types'
}
}
header_regex = re.compile(r'^#+')
table_row_regex = re.compile(r'\| *(.*?) *\| *([\d.]+) *\| *([\d.]+) *\|')

@ -1 +1 @@
from . import config, main, sonarr
from . import config, main, sonarr, radarr

@ -1,12 +1,12 @@
from pathlib import Path
from app.logic import sonarr, config
from app.logic import sonarr, config, radarr
from app.cmd import setup_and_parse_args
from app.logger import Logger
from app.trash_error import TrashError
# --------------------------------------------------------------------------------------------------
def main(root_directory):
def main(root_directory: Path):
args = setup_and_parse_args()
logger = Logger(args)
@ -15,6 +15,7 @@ def main(root_directory):
subcommand_handlers = {
('sonarr', 'profile'): sonarr.process_profile,
('sonarr', 'quality'): sonarr.process_quality,
('radarr', 'quality'): radarr.process_quality,
}
server_name = args.type.split(':')[0]

@ -0,0 +1,20 @@
from app.guide.radarr import quality, utils
from app.api.radarr import Radarr
from app.trash_error import TrashError
# --------------------------------------------------------------------------------------------------
def process_quality(args, logger):
if 0 > args.preferred_percentage > 100:
raise TrashError(f'Preferred percentage is out of range: {args.preferred_percentage}')
guide_definitions = quality.parse_markdown(args, logger, quality.get_markdown())
selected_definition = guide_definitions.get(args.type)
if args.preview:
utils.quality_preview(selected_definition)
exit(0)
print(f'Updating quality definition using {args.type}')
server = Radarr(args, logger)
definition = server.get_quality_definition()
server.update_quality_definition(definition, selected_definition)

@ -1,7 +1,8 @@
import re
from app import guide
from app.guide.profile import types as profile_types
import app.guide.sonarr as guide
from app.guide.sonarr import utils
from app.guide.profile_types import types as profile_types
from app.api.sonarr import Sonarr
from app.trash_error import TrashError
@ -14,10 +15,10 @@ def process_profile(args, logger):
# A few false-positive profiles are added sometimes. We filter these out by checking if they
# actually have meaningful data attached to them, such as preferred terms. If they are mostly empty,
# we remove them here.
guide.utils.filter_profiles(profiles)
utils.filter_profiles(profiles)
if args.preview:
guide.utils.print_terms_and_scores(profiles)
utils.print_terms_and_scores(profiles)
exit(0)
sonarr = Sonarr(args, logger)
@ -43,7 +44,7 @@ def process_profile(args, logger):
for name, profile in profiles.items():
type_for_name = profile_types.get(args.type).get('profile_typename')
new_profile_name = f'[Trash] {type_for_name} - {name}'
profile_to_update = guide.utils.find_existing_profile(new_profile_name, existing_profiles)
profile_to_update = utils.find_existing_profile(new_profile_name, existing_profiles)
if profile_to_update:
logger.info(f'Updating existing profile: {new_profile_name}')
@ -88,7 +89,7 @@ def process_quality(args, logger):
selected_definition = guide_definitions.get(args.type)
if args.preview:
guide.utils.quality_preview(selected_definition)
utils.quality_preview(selected_definition)
exit(0)
print(f'Updating quality definition using {args.type}')

@ -1,4 +1,4 @@
from app.guide import profile
from app.guide.sonarr import profile
from pathlib import Path
from tests.mock_logger import MockLogger

@ -1,4 +1,4 @@
import app.guide.quality as quality
import app.guide.sonarr.quality as quality
from pathlib import Path
from tests.mock_logger import MockLogger

@ -1,4 +1,6 @@
import sys
from pathlib import Path
from app.logic import main
def test_main_sonarr_profile(mocker):
@ -6,7 +8,7 @@ def test_main_sonarr_profile(mocker):
mock_processor = mocker.patch('app.logic.sonarr.process_profile')
mocker.patch.object(sys, 'argv', test_args)
main.main()
main.main(Path())
mock_processor.assert_called_once()
@ -15,6 +17,6 @@ def test_main_sonarr_quality(mocker):
mock_processor = mocker.patch('app.logic.sonarr.process_quality')
mocker.patch.object(sys, 'argv', test_args)
main.main()
main.main(Path())
mock_processor.assert_called_once()

@ -0,0 +1,13 @@
import pytest
from app import cmd
from app.logic import radarr
from app.trash_error import TrashError
from tests.mock_logger import MockLogger
@pytest.mark.parametrize('percentage', ['-1', '101'])
def test_process_quality_bad_preferred_percentage(percentage):
input_args = ['quality', 'radarr:movies', '--preferred-percentage', percentage]
args = cmd.setup_and_parse_args(input_args)
with pytest.raises(TrashError):
radarr.process_quality(args, MockLogger())
Loading…
Cancel
Save