From 3aad617219bdfec4cd189b3ac35592680dc2ca10 Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Mon, 22 Feb 2021 16:26:40 -0600 Subject: [PATCH] Radarr quality definition support --- README.md | 9 +-- src/app/api/radarr.py | 63 +++++++++++++++++++ src/app/api/sonarr.py | 10 +-- src/app/cmd.py | 7 ++- src/app/guide/__init__.py | 1 - src/app/guide/profile_types.py | 13 ++++ src/app/guide/quality_types.py | 15 +++++ src/app/guide/radarr/quality.py | 39 ++++++++++++ src/app/guide/radarr/utils.py | 54 ++++++++++++++++ src/app/guide/sonarr/__init__.py | 1 + src/app/guide/{ => sonarr}/profile.py | 16 +---- src/app/guide/{ => sonarr}/quality.py | 13 ---- src/app/guide/{ => sonarr}/utils.py | 0 src/app/logic/__init__.py | 2 +- src/app/logic/main.py | 5 +- src/app/logic/radarr.py | 20 ++++++ src/app/logic/sonarr.py | 13 ++-- .../{test_trash.py => guide/__init__.py} | 0 src/tests/guide/sonarr/__init__.py | 0 .../data/test_parse_markdown_complete_doc.md | 0 ...rse_markdown_sonarr_quality_definitions.md | 0 src/tests/guide/{ => sonarr}/test_profile.py | 2 +- src/tests/guide/{ => sonarr}/test_quality.py | 2 +- src/tests/logic/test_main.py | 6 +- src/tests/logic/test_radarr.py | 13 ++++ 25 files changed, 248 insertions(+), 56 deletions(-) create mode 100644 src/app/api/radarr.py create mode 100644 src/app/guide/profile_types.py create mode 100644 src/app/guide/quality_types.py create mode 100644 src/app/guide/radarr/quality.py create mode 100644 src/app/guide/radarr/utils.py create mode 100644 src/app/guide/sonarr/__init__.py rename src/app/guide/{ => sonarr}/profile.py (92%) rename src/app/guide/{ => sonarr}/quality.py (71%) rename src/app/guide/{ => sonarr}/utils.py (100%) create mode 100644 src/app/logic/radarr.py rename src/tests/{test_trash.py => guide/__init__.py} (100%) create mode 100644 src/tests/guide/sonarr/__init__.py rename src/tests/guide/{ => sonarr}/data/test_parse_markdown_complete_doc.md (100%) rename src/tests/guide/{ => sonarr}/data/test_parse_markdown_sonarr_quality_definitions.md (100%) rename src/tests/guide/{ => sonarr}/test_profile.py (97%) rename src/tests/guide/{ => sonarr}/test_quality.py (97%) create mode 100644 src/tests/logic/test_radarr.py diff --git a/README.md b/README.md index ec13db6a..a1060c4a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app/api/radarr.py b/src/app/api/radarr.py new file mode 100644 index 00000000..485db17b --- /dev/null +++ b/src/app/api/radarr.py @@ -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 \ No newline at end of file diff --git a/src/app/api/sonarr.py b/src/app/api/sonarr.py index f8c8cdaf..87824760 100644 --- a/src/app/api/sonarr.py +++ b/src/app/api/sonarr.py @@ -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() # -------------------------------------------------------------------------------------------------- diff --git a/src/app/cmd.py b/src/app/cmd.py index a483c910..2015de47 100644 --- a/src/app/cmd.py +++ b/src/app/cmd.py @@ -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) diff --git a/src/app/guide/__init__.py b/src/app/guide/__init__.py index 7f10236e..e69de29b 100644 --- a/src/app/guide/__init__.py +++ b/src/app/guide/__init__.py @@ -1 +0,0 @@ -from . import profile, quality, utils \ No newline at end of file diff --git a/src/app/guide/profile_types.py b/src/app/guide/profile_types.py new file mode 100644 index 00000000..6a8edfa6 --- /dev/null +++ b/src/app/guide/profile_types.py @@ -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' + }, +} diff --git a/src/app/guide/quality_types.py b/src/app/guide/quality_types.py new file mode 100644 index 00000000..7b9504ec --- /dev/null +++ b/src/app/guide/quality_types.py @@ -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.' + }, +} diff --git a/src/app/guide/radarr/quality.py b/src/app/guide/radarr/quality.py new file mode 100644 index 00000000..025186d7 --- /dev/null +++ b/src/app/guide/radarr/quality.py @@ -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 diff --git a/src/app/guide/radarr/utils.py b/src/app/guide/radarr/utils.py new file mode 100644 index 00000000..9601e48b --- /dev/null +++ b/src/app/guide/radarr/utils.py @@ -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('') diff --git a/src/app/guide/sonarr/__init__.py b/src/app/guide/sonarr/__init__.py new file mode 100644 index 00000000..bbb676c0 --- /dev/null +++ b/src/app/guide/sonarr/__init__.py @@ -0,0 +1 @@ +from . import profile, quality \ No newline at end of file diff --git a/src/app/guide/profile.py b/src/app/guide/sonarr/profile.py similarity index 92% rename from src/app/guide/profile.py rename to src/app/guide/sonarr/profile.py index 2ec1e96b..dbdc351c 100644 --- a/src/app/guide/profile.py +++ b/src/app/guide/sonarr/profile.py @@ -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') diff --git a/src/app/guide/quality.py b/src/app/guide/sonarr/quality.py similarity index 71% rename from src/app/guide/quality.py rename to src/app/guide/sonarr/quality.py index 4909aab2..f8eaaf6e 100644 --- a/src/app/guide/quality.py +++ b/src/app/guide/sonarr/quality.py @@ -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.]+) *\|') diff --git a/src/app/guide/utils.py b/src/app/guide/sonarr/utils.py similarity index 100% rename from src/app/guide/utils.py rename to src/app/guide/sonarr/utils.py diff --git a/src/app/logic/__init__.py b/src/app/logic/__init__.py index 0eeca831..74a48b17 100644 --- a/src/app/logic/__init__.py +++ b/src/app/logic/__init__.py @@ -1 +1 @@ -from . import config, main, sonarr \ No newline at end of file +from . import config, main, sonarr, radarr \ No newline at end of file diff --git a/src/app/logic/main.py b/src/app/logic/main.py index f88dce1e..2611eefc 100644 --- a/src/app/logic/main.py +++ b/src/app/logic/main.py @@ -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] diff --git a/src/app/logic/radarr.py b/src/app/logic/radarr.py new file mode 100644 index 00000000..70e366b1 --- /dev/null +++ b/src/app/logic/radarr.py @@ -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) \ No newline at end of file diff --git a/src/app/logic/sonarr.py b/src/app/logic/sonarr.py index 1315653b..790f50f2 100644 --- a/src/app/logic/sonarr.py +++ b/src/app/logic/sonarr.py @@ -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}') diff --git a/src/tests/test_trash.py b/src/tests/guide/__init__.py similarity index 100% rename from src/tests/test_trash.py rename to src/tests/guide/__init__.py diff --git a/src/tests/guide/sonarr/__init__.py b/src/tests/guide/sonarr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/guide/data/test_parse_markdown_complete_doc.md b/src/tests/guide/sonarr/data/test_parse_markdown_complete_doc.md similarity index 100% rename from src/tests/guide/data/test_parse_markdown_complete_doc.md rename to src/tests/guide/sonarr/data/test_parse_markdown_complete_doc.md diff --git a/src/tests/guide/data/test_parse_markdown_sonarr_quality_definitions.md b/src/tests/guide/sonarr/data/test_parse_markdown_sonarr_quality_definitions.md similarity index 100% rename from src/tests/guide/data/test_parse_markdown_sonarr_quality_definitions.md rename to src/tests/guide/sonarr/data/test_parse_markdown_sonarr_quality_definitions.md diff --git a/src/tests/guide/test_profile.py b/src/tests/guide/sonarr/test_profile.py similarity index 97% rename from src/tests/guide/test_profile.py rename to src/tests/guide/sonarr/test_profile.py index 6b8d7068..4cdefca4 100644 --- a/src/tests/guide/test_profile.py +++ b/src/tests/guide/sonarr/test_profile.py @@ -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 diff --git a/src/tests/guide/test_quality.py b/src/tests/guide/sonarr/test_quality.py similarity index 97% rename from src/tests/guide/test_quality.py rename to src/tests/guide/sonarr/test_quality.py index 277034a1..df9f3650 100644 --- a/src/tests/guide/test_quality.py +++ b/src/tests/guide/sonarr/test_quality.py @@ -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 diff --git a/src/tests/logic/test_main.py b/src/tests/logic/test_main.py index 667627bd..834a868e 100644 --- a/src/tests/logic/test_main.py +++ b/src/tests/logic/test_main.py @@ -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() \ No newline at end of file diff --git a/src/tests/logic/test_radarr.py b/src/tests/logic/test_radarr.py new file mode 100644 index 00000000..a72a94f2 --- /dev/null +++ b/src/tests/logic/test_radarr.py @@ -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()) \ No newline at end of file