From 50571ff3a591977ed5c628df6d7c043777573e94 Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Wed, 3 Feb 2021 19:44:16 -0600 Subject: [PATCH] Initial Commit --- .gitattributes | 1 + .gitignore | 153 +++++++++++++ README.md | 61 ++++++ sonarr_api_examples/releaseprofile.json | 15 ++ trash.py | 279 ++++++++++++++++++++++++ 5 files changed, 509 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 sonarr_api_examples/releaseprofile.json create mode 100644 trash.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..176a458f --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..4b46c510 --- /dev/null +++ b/.gitignore @@ -0,0 +1,153 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/python,vscode +# Edit at https://www.toptal.com/developers/gitignore?templates=python,vscode + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +pytestdebug.log + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +doc/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +pythonenv* + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# profiling data +.prof + +### vscode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# End of https://www.toptal.com/developers/gitignore/api/python,vscode diff --git a/README.md b/README.md new file mode 100644 index 00000000..90fff1b9 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# TRaSH Guide Updater Script + +Automatically mirror TRaSH guides to your *darr instance. + +> **NOTICE**: This is a work-in-progress Python script + +## Features + +Features list will continue to grow. See the limitations & roadmap section for more details! + +* Preferred, Must Not Contain, and Must Contain lists from guides are reflected completely in + corresponding fields in release profiles in Sonarr. +* "Include Preferred when Renaming" is properly checked/unchecked depending on explicit mention of + this in the guides. +* Profiles get created if they do not exist, or updated if they already exist. Profiles get a unique + name based on the guide and this name is used to find them in subsequent runs. + +## Requirements + +* Python 3 +* The following packages installed with `pip`: + * `requests` + * `packaging` +* For Sonarr updates, you must be running version `3.0.4.1098` or greater. + +## Getting Started + +I plan to add more tutorials/details/instructions later, but for now just run `trash.py --help`: + +```txt +usage: trash.py [-h] [--preview] [--debug] base_uri api_key + +Automatically mirror TRaSH guides to your *darr instance. + +positional arguments: + base_uri The base URL for your Sonarr instance, for example `http://localhost:8989`. + api_key Your API key. + +optional arguments: + -h, --help show this help message and exit + --preview Only display the processed markdown results and nothing else. + --debug Display additional logs useful for development/debug purposes +``` + +## Important Notices + +Please be aware that this script relies on a deterministic and consistent structure of the TRaSH +Guide markdown files. I'm in the process of creating a set of rules/guidelines to reduce the risk of +the guide breaking this script, but in the meantime the script may stop working at any time due to +guide updates. I will do my best to fix them in a timely manner. Reporting such issues would be +appreciated and will help identify issues more quickly. + +### Limitations & Roadmap + +This script is a work in progress. At the moment, it only supports the following features: + +* Only Sonarr is supported (Radarr will come in the future) +* Only the [Sonarr Anime Guide][1] is supported (more guides to come) +* Better and more polished error handling (it's pretty minimal right now) + +[1]: https://trash-guides.info/Sonarr/V3/Sonarr-Release-Profile-RegEx-Anime/ diff --git a/sonarr_api_examples/releaseprofile.json b/sonarr_api_examples/releaseprofile.json new file mode 100644 index 00000000..7d74dff1 --- /dev/null +++ b/sonarr_api_examples/releaseprofile.json @@ -0,0 +1,15 @@ +{ + "enabled": true, + "required": "one,two,three", + "ignored": "one,two,three", + "preferred": [{ + "key": "/abc/", + "value": "100" + }, { + "key": "/xyz/", + "value": "200" + }], + "includePreferredWhenRenaming": true, + "tags": [2], + "indexerId": 0 +} diff --git a/trash.py b/trash.py new file mode 100644 index 00000000..e466f730 --- /dev/null +++ b/trash.py @@ -0,0 +1,279 @@ +import requests +import re +import json +from collections import defaultdict +import argparse +# from dataclasses import dataclass +from enum import Enum +from packaging import version # pip install packaging + + +# Argparse setup +argparser = argparse.ArgumentParser(description='Automatically mirror TRaSH guides to your *darr instance.') +argparser.add_argument('--preview', help='Only display the processed markdown results and nothing else.', + action='store_true') +argparser.add_argument('--debug', help='Display additional logs useful for development/debug purposes', + action='store_true') +argparser.add_argument('base_uri', help='The base URL for your Sonarr instance, for example `http://localhost:8989`.') +argparser.add_argument('api_key', help='Your API key.') +args = argparser.parse_args() + +Filter = Enum('FilterType', 'Preferred Required Ignored') + +header_regex = re.compile(r'^(#+)\s([\w\s\d]+)\s*$') +score_regex = re.compile(r'score.*?\[([-\d]+)\]') +# included_preferred_regex = re.compile(r'include preferred', re.IGNORECASE) +# not_regex = re.compile(r'not', re.IGNORECASE) +filter_regexes = ( + (Filter.Ignored, re.compile(r'must not contain', re.IGNORECASE)), + (Filter.Preferred, re.compile(r'preferred', re.IGNORECASE)), +) + +# SONARR API STUFFS +base_uri = f'{args.base_uri}/api/v3' +key = f'?apikey={args.api_key}' + +# -------------------------------------------------------------------------------------------------- +def get_trash_anime_markdown(): + trash_anime_markdown_url = 'https://raw.githubusercontent.com/TRaSH-/Guides/master/docs/Sonarr/V3/Sonarr-Release-Profile-RegEx-Anime.md' + response = requests.get(trash_anime_markdown_url) + return response.content.decode('utf8') + +# -------------------------------------------------------------------------------------------------- +def parse_filter(line): + for rx in filter_regexes: + if rx[1].search(line): + return rx[0] + + return None + +# -------------------------------------------------------------------------------------------------- +def debug_print(text): + if args.debug: + print(text) + +# -------------------------------------------------------------------------------------------------- +class ProfileData: + def __init__(self): + self.preferred = defaultdict(list) + self.required = [] + self.ignored = [] + # We use 'none' here to represent no explicit mention of the "include preferred" string + # found in the markdown. We use this to control whether or not the corresponding profile + # section gets printed in the first place. + self.include_preferred_when_renaming = None + +def parse_markdown(markdown_content): + class state: + results = defaultdict(ProfileData) + profile_name = None + score = None + filter = None + bracket_depth = 0 + + for line in markdown_content.splitlines(): + # Header processing + if match := header_regex.search(line): + header_depth = len(match.group(1)) + header_text = match.group(2) + + # Profile name (always reset previous state here) + if header_depth == 3: + state.score = None + state.filter = Filter.Preferred + state.profile_name = header_text + debug_print(f'New Profile: {header_text}') + # Filter type for provided regexes + elif header_depth == 4: + state.filter = parse_filter(header_text) + if state.filter: + debug_print(f' Filter Set: {state.filter}') + + # Lines we always look for + elif line.startswith('```'): + state.bracket_depth = 1 - state.bracket_depth + + # Filter-based line processing + elif state.profile_name: + profile = state.results[state.profile_name] + lower_line = line.lower() + if 'include preferred' in lower_line: + profile.include_preferred_when_renaming = 'not' not in lower_line + debug_print(f' Include preferred found: {profile.include_preferred_when_renaming}, {lower_line}') + elif state.filter == Filter.Preferred: + if match := score_regex.search(line): + # bracket_depth = 0 + state.score = int(match.group(1)) + elif state.bracket_depth: + if state.score is not None: + debug_print(f' [Preferred] Score: {state.score}, Term: {line}') + profile.preferred[state.score].append(line) + elif state.filter == Filter.Ignored: + if state.bracket_depth: + # Sometimes a comma is present at the end of these regexes, because when it's + # pasted into Sonarr it acts as a delimiter. However, when using them with the + # API we do not need them. + profile.ignored.append(line.rstrip(',')) + + debug_print('\n\n') + return state.results + +# -------------------------------------------------------------------------------------------------- +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 success_status_code(response): + return response.status_code >= 200 and response.status_code < 300 + +# -------------------------------------------------------------------------------------------------- +def get_error_message(response: requests.Response): + response_body = json.dumps(response.content.decode('utf8')) + if type(response_body) is list and len(response_body) > 0: + return response[0]['errorMessage'] + return None + +# -------------------------------------------------------------------------------------------------- +def sonarr_request(method, endpoint, data=None): + dispatch = { + 'put': requests.put, + 'get': requests.get, + 'post': requests.post, + } + + complete_uri = base_uri + endpoint + key + r = dispatch.get(method)(complete_uri, json.dumps(data)) + r.raise_for_status() + return json.loads(r.content) + +# -------------------------------------------------------------------------------------------------- +def sonarr_get_version(): + body = sonarr_request('get', '/system/status') + return version.parse(body['version']) + +# -------------------------------------------------------------------------------------------------- +def sonarr_create_release_profile(profile_name: str, profile: ProfileData): + json_preferred = [] + for score, terms in profile.preferred.items(): + for term in terms: + json_preferred.append({"key": term, "value": score}) + + data = { + 'name': profile_name, + 'enabled': True, + 'required': ','.join(profile.required), + 'ignored': ','.join(profile.ignored), + 'preferred': json_preferred, + 'includePreferredWhenRenaming': profile.include_preferred_when_renaming, + 'tags': [], + 'indexerId': 0 + } + + sonarr_request('post', '/releaseprofile', data) + +# -------------------------------------------------------------------------------------------------- +def sonarr_get_release_profiles(): + return sonarr_request('get', '/releaseprofile') + +# -------------------------------------------------------------------------------------------------- +def find_existing_profile(profile_name, existing_profiles): + for p in existing_profiles: + if p.get('name') == new_profile_name: + return p + return None + +# -------------------------------------------------------------------------------------------------- +def sonarr_update_existing_profile(existing_profile, profile): + profile_id = existing_profile['id'] + debug_print(f'update existing profile with id {profile_id}') + + # Create the release profile + json_preferred = [] + for score, terms in profile.preferred.items(): + for term in terms: + json_preferred.append({"key": term, "value": score}) + + existing_profile['required'] = ','.join(profile.required) + existing_profile['ignored'] = ','.join(profile.ignored) + existing_profile['preferred'] = json_preferred + existing_profile['includePreferredWhenRenaming'] = profile.include_preferred_when_renaming + + sonarr_request('put', f'/releaseprofile/{profile_id}', existing_profile) + +#################################################################################################### + +try: + profiles = parse_markdown(get_trash_anime_markdown()) + + # 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. + filter_profiles(profiles) + + if args.preview: + print_terms_and_scores(profiles) + exit(0) + + # Since this script requires a specific version of v3 Sonarr that implements name support for + # release profiles, we perform that version check here and bail out if it does not meet a minimum + # required version. + minimum_version = version.parse('3.0.4.1098') + version = sonarr_get_version() + if version < minimum_version: + print(f'ERROR: Your Sonarr version ({version}) does not meet the minimum required version of {minimum_version} to use this script.') + exit(1) + + # Obtain all of the existing release profiles first. If any were previously created by our script + # here, we favor replacing those instead of creating new ones, which would just be mostly duplicates + # (but with some differences, since there have likely been updates since the last run). + existing_profiles = sonarr_get_release_profiles() + + for name, profile in profiles.items(): + new_profile_name = f'[Trash] Anime - {name}' + profile_to_update = find_existing_profile(new_profile_name, existing_profiles) + + if profile_to_update: + print(f'Updating existing profile: {new_profile_name}') + sonarr_update_existing_profile(profile_to_update, profile) + else: + print(f'Creating new profile: {new_profile_name}') + sonarr_create_release_profile(new_profile_name, profile) + +except requests.exceptions.HTTPError as e: + print(e) + if error_msg := get_error_message(e.response): + print(f'Response Message: {error_msg}') + exit(1)