diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 7ad98012..00000000 --- a/.gitignore +++ /dev/null @@ -1,258 +0,0 @@ - -# Created by https://www.toptal.com/developers/gitignore/api/python,pycharm,vscode -# Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm,vscode - -### PyCharm ### -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -### PyCharm Patch ### -# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 - -# *.iml -# modules.xml -# .idea/misc.xml -# *.ipr - -# Sonarlint plugin -# https://plugins.jetbrains.com/plugin/7973-sonarlint -.idea/**/sonarlint/ - -# SonarQube Plugin -# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin -.idea/**/sonarIssues.xml - -# Markdown Navigator plugin -# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced -.idea/**/markdown-navigator.xml -.idea/**/markdown-navigator-enh.xml -.idea/**/markdown-navigator/ - -# Cache file creation bug -# See https://youtrack.jetbrains.com/issue/JBR-2257 -.idea/$CACHE_FILE$ - -# CodeStream plugin -# https://plugins.jetbrains.com/plugin/12206-codestream -.idea/codestream.xml - -### 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,pycharm,vscode - -trash.yml \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d33521..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/TrashUpdater.iml b/.idea/TrashUpdater.iml deleted file mode 100644 index 72a80507..00000000 --- a/.idea/TrashUpdater.iml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index a55e7a17..00000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index dd4c951e..00000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index ab4cd9b2..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index ebca9072..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7f..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.markdownlint.json b/.markdownlint.json deleted file mode 100644 index 39e1e186..00000000 --- a/.markdownlint.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "default": true, - "line-length": { - "line_length": 100, - "tables": false - }, - "no-inline-html": { - "allowed_elements": ["br"] - } -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 99b087e5..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "python.linting.enabled": true -} \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index a1060c4a..00000000 --- a/README.md +++ /dev/null @@ -1,179 +0,0 @@ -# TRaSH Guide Updater Script - -Automatically mirror TRaSH guides to your Sonarr/Radarr 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! - -* Sonarr Release Profiles - * 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. - * Tags can be added to any updated or created profiles. - * 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. - -## Requirements - -* Python 3 -* The following packages installed with `pip`: - * `requests` - * `packaging` - * `pyyaml` -* For Sonarr updates, you must be running version `3.0.4.1098` or greater. - -To install all of the above required packages, here's a convenient copy & paste one-liner: - -```txt -pip install requests packaging pyyaml -``` - -## Getting Started - -The only script you will need to be using is `src/trash.py`. If you've cloned my repository, simply -`cd` to the `src` directory so you can run `trash.py` directly: - -```txt -PS E:\code\TrashUpdater\src> .\trash.py -h -usage: trash.py [-h] {profile,quality} ... - -Automatically mirror TRaSH guides to your Sonarr/Radarr instance. - -optional arguments: - -h, --help show this help message and exit - -subcommands: - Operations specific to different parts of the TRaSH guides - - {profile,quality} - profile Pages of the guide that define profiles - quality Pages in the guide that provide quality definitions -``` - -The command line is structured into a series of subcommands that each handle a different area of the -guides. For example, you use a separate subcommand to sync quality definitions than you do release -profiles. Simply run `trash.py [subcommand] -h` to get help for `[subcommand]`, which can be any -supported subcommand listed in the top level help output. - -### Examples - -Some command line examples to show you how to use the script for various tasks. Note that most -command line options were generated on a Windows environment, so you will see OS-specific syntax -(e.g. backslashes). Obviously Python works on Linux systems too, so adjust the examples as needed -for your platform. - -To preview what release profile information is parsed out of the Anime profile guide: - -```txt -.\trash.py profile sonarr:anime --preview -``` - -To sync the anime release profiles to your Sonarr instance: - -```txt -.\trash.py profile sonarr:anime --base-uri http://localhost:8989 --api-key a95cc792074644759fefe3ccab544f6e -``` - -To preview the Anime quality definition data parsed out of the Quality Definitions (file sizes) page -of the TRaSH guides: - -```txt -.\trash.py quality sonarr:anime --preview -``` - -Sync the non-anime quality definition to Sonarr: - -```txt -.\trash.py quality sonarr:non-anime --base-uri http://localhost:8989 --api-key a95cc792074644759fefe3ccab544f6e -``` - -## Configuration File - -By default, `trash.py` will look for a configuration file named `trash.yml` in the same directory as -the script itself. This configuration file may be used to store your Sonarr and Radarr Base URI and -API Key, which should make using the command line interface a bit less clunky. - -```yml -sonarr: - base_uri: http://localhost:8989 - api_key: a95cc792074644759fefe3ccab544f6e - profile: - - type: anime - tags: - - anime - - type: web-dl - tags: - - tv -``` - -Note that this file is not required to be present. If it is not present, then you will need to set -respective parameters using the equivalent command line arguments (e.g. `--base-uri` and -`--api-key`), as needed. - -Lastly, there's a `--config-file` argument you can use to point to your own YAML config file if you -don't like the where the default one is located. - -### Profile Settings - -* **`profile`**
- Provide a list of settings used per each type of release profile supported in the guide (e.g. - `web-dl`, `anime`). - - * **`type`**
- Type profile type to apply the settings to, such as adding new tags. The list of supported - profile types can be found by doing `trash.py profile -h`. Each valid choice listed under the - `type` argument can be used, just strip the `sonarr:` prefix. - - * **`tags`**
- A list of tags to apply to the profile. Functions exactly as it would if you used the `--tags` - option to provide this list on the command line. - -## 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 - -This script is a work in progress. At the moment, it only supports the following features and/or has -the following limitations: - -* Radarr custom formats are not supported yet (coming soon). -* Multiple scores on the same line are not supported. Only the first is used. - -### Roadmap - -In addition to the above limitations, the following items are planned for the future. - -* 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). - -## Development / Contributing - -### Prerequisites - -Some additional packages are required to run the unit tests. All can be installed via `pip`: - -* `pytest` -* `pytest-mock` - -To install all of the above required packages, here's a convenient copy & paste one-liner: - -```txt -pip install pytest pytest-mock -``` diff --git a/sonarr_api_examples/qualityProfile.update.json b/sonarr_api_examples/qualityProfile.update.json deleted file mode 100644 index a6d54c51..00000000 --- a/sonarr_api_examples/qualityProfile.update.json +++ /dev/null @@ -1,268 +0,0 @@ -[{ - "quality": { - "id": 0, - "name": "Unknown", - "source": "unknown", - "resolution": 0 - }, - "title": "Unknown", - "weight": 1, - "minSize": 1.0, - "maxSize": 227.5, - "id": 1 - }, - { - "quality": { - "id": 1, - "name": "SDTV", - "source": "television", - "resolution": 480 - }, - "title": "SDTV", - "weight": 2, - "minSize": 2.0, - "maxSize": 100.0, - "id": 2 - }, - { - "quality": { - "id": 12, - "name": "WEBRip-480p", - "source": "webRip", - "resolution": 480 - }, - "title": "WEBRip-480p", - "weight": 3, - "minSize": 2.0, - "maxSize": 100.0, - "id": 3 - }, - { - "quality": { - "id": 8, - "name": "WEBDL-480p", - "source": "web", - "resolution": 480 - }, - "title": "WEBDL-480p", - "weight": 3, - "minSize": 2.0, - "maxSize": 100.0, - "id": 4 - }, - { - "quality": { - "id": 2, - "name": "DVD", - "source": "dvd", - "resolution": 480 - }, - "title": "DVD", - "weight": 4, - "minSize": 2.0, - "maxSize": 100.0, - "id": 5 - }, - { - "quality": { - "id": 13, - "name": "Bluray-480p", - "source": "bluray", - "resolution": 480 - }, - "title": "Bluray-480p", - "weight": 5, - "minSize": 2.0, - "maxSize": 100.0, - "id": 6 - }, - { - "quality": { - "id": 4, - "name": "HDTV-720p", - "source": "television", - "resolution": 720 - }, - "title": "HDTV-720p", - "weight": 6, - "minSize": 3.0, - "maxSize": 125.0, - "id": 7 - }, - { - "quality": { - "id": 9, - "name": "HDTV-1080p", - "source": "television", - "resolution": 1080 - }, - "title": "HDTV-1080p", - "weight": 7, - "minSize": 4.0, - "maxSize": 125.0, - "id": 8 - }, - { - "quality": { - "id": 10, - "name": "Raw-HD", - "source": "televisionRaw", - "resolution": 1080 - }, - "title": "Raw-HD", - "weight": 8, - "minSize": 4.0, - "id": 9 - }, - { - "quality": { - "id": 14, - "name": "WEBRip-720p", - "source": "webRip", - "resolution": 720 - }, - "title": "WEBRip-720p", - "weight": 9, - "minSize": 3.0, - "maxSize": 130.0, - "id": 10 - }, - { - "quality": { - "id": 5, - "name": "WEBDL-720p", - "source": "web", - "resolution": 720 - }, - "title": "WEBDL-720p", - "weight": 9, - "minSize": 3.0, - "maxSize": 130.0, - "id": 11 - }, - { - "quality": { - "id": 6, - "name": "Bluray-720p", - "source": "bluray", - "resolution": 720 - }, - "title": "Bluray-720p", - "weight": 10, - "minSize": 4.0, - "maxSize": 130.0, - "id": 12 - }, - { - "quality": { - "id": 15, - "name": "WEBRip-1080p", - "source": "webRip", - "resolution": 1080 - }, - "title": "WEBRip-1080p", - "weight": 11, - "minSize": 4.0, - "maxSize": 130.0, - "id": 13 - }, - { - "quality": { - "id": 3, - "name": "WEBDL-1080p", - "source": "web", - "resolution": 1080 - }, - "title": "WEBDL-1080p", - "weight": 11, - "minSize": 4.0, - "maxSize": 130.0, - "id": 14 - }, - { - "quality": { - "id": 7, - "name": "Bluray-1080p", - "source": "bluray", - "resolution": 1080 - }, - "title": "Bluray-1080p", - "weight": 12, - "minSize": 4.0, - "maxSize": 155.0, - "id": 15 - }, - { - "quality": { - "id": 20, - "name": "Bluray-1080p Remux", - "source": "blurayRaw", - "resolution": 1080 - }, - "title": "Bluray-1080p Remux", - "weight": 13, - "minSize": 35.0, - "id": 16 - }, - { - "quality": { - "id": 16, - "name": "HDTV-2160p", - "source": "television", - "resolution": 2160 - }, - "title": "HDTV-2160p", - "weight": 14, - "minSize": 35.0, - "maxSize": 199.9, - "id": 17 - }, - { - "quality": { - "id": 17, - "name": "WEBRip-2160p", - "source": "webRip", - "resolution": 2160 - }, - "title": "WEBRip-2160p", - "weight": 15, - "minSize": 59.0, - "id": 18 - }, - { - "quality": { - "id": 18, - "name": "WEBDL-2160p", - "source": "web", - "resolution": 2160 - }, - "title": "WEBDL-2160p", - "weight": 15, - "minSize": 59.0, - "id": 19 - }, - { - "quality": { - "id": 19, - "name": "Bluray-2160p", - "source": "bluray", - "resolution": 2160 - }, - "title": "Bluray-2160p", - "weight": 16, - "minSize": 59.0, - "id": 20 - }, - { - "quality": { - "id": 21, - "name": "Bluray-2160p Remux", - "source": "blurayRaw", - "resolution": 2160 - }, - "title": "Bluray-2160p Remux", - "weight": 17, - "minSize": 58.2, - "id": 21 - } -] diff --git a/sonarr_api_examples/releaseprofile.json b/sonarr_api_examples/releaseprofile.json deleted file mode 100644 index ff192585..00000000 --- a/sonarr_api_examples/releaseprofile.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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/src/app/__init__.py b/src/app/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/api/__init__.py b/src/app/api/__init__.py deleted file mode 100644 index 8dd7c734..00000000 --- a/src/app/api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import server, sonarr \ No newline at end of file diff --git a/src/app/api/radarr.py b/src/app/api/radarr.py deleted file mode 100644 index 71b5a4aa..00000000 --- a/src/app/api/radarr.py +++ /dev/null @@ -1,73 +0,0 @@ -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 = [] - for quality, min_value, max_value, preferred in guide_definition: - entry = self.find_quality_definition_entry(server_definition, quality) - if not entry: - print(f'WARN: Quality definition lacks entry for {quality}; it will be skipped.') - continue - entry = deepcopy(entry) - entry['minSize'] = min_value - entry['maxSize'] = max_value - entry['preferredSize'] = preferred - new_definition.append(entry) - - self.logger.debug('Setting Quality ' - f'[Name: {entry["quality"]["name"]}] ' - f'[Source: {entry["quality"]["source"]}] ' - f'[Min: {entry["minSize"]}] ' - f'[Max: {entry["maxSize"]}] ' - f'[Preferred: {entry["preferredSize"]}] ' - ) - - 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/server.py b/src/app/api/server.py deleted file mode 100644 index a7b2b9d2..00000000 --- a/src/app/api/server.py +++ /dev/null @@ -1,29 +0,0 @@ -import json -import requests - -from app.trash_error import TrashError - -class TrashHttpError(TrashError): - def __init__(self, response): - self.response = response - -class Server: - dispatch = { - 'put': requests.put, - 'get': requests.get, - 'post': requests.post, - } - - def __init__(self, base_uri, apikey, exception_strategy): - self.base_uri = base_uri - self.apikey = apikey - self.exception_strategy = exception_strategy - - def build_uri(self, endpoint): - return self.base_uri + endpoint + self.apikey - - def request(self, method, endpoint, data=None): - r = Server.dispatch.get(method)(self.build_uri(endpoint), json.dumps(data)) - if 400 <= r.status_code < 600: - raise self.exception_strategy(r) - return json.loads(r.content) diff --git a/src/app/api/sonarr.py b/src/app/api/sonarr.py deleted file mode 100644 index 288ad887..00000000 --- a/src/app/api/sonarr.py +++ /dev/null @@ -1,154 +0,0 @@ -import requests -import json -from packaging import version # pip install packaging -from copy import deepcopy - -from app.api.server import Server, TrashHttpError -from app.profile_data import ProfileData -from app.trash_error import TrashError - -class SonarrHttpError(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 := SonarrHttpError.get_error_message(self.response): - msg += f'\n Response Message: {error_msg}' - return msg - -class Sonarr(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, SonarrHttpError) - self.do_version_check() - - # -------------------------------------------------------------------------------------------------- - def get_version(self): - body = self.request('get', '/system/status') - return version.parse(body['version']) - - # -------------------------------------------------------------------------------------------------- - def create_release_profile(self, profile_name: str, profile: ProfileData, tag_ids: list): - 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': tag_ids, - 'indexerId': 0 - } - - self.request('post', '/releaseprofile', data) - - # -------------------------------------------------------------------------------------------------- - def get_release_profiles(self): - return self.request('get', '/releaseprofile') - - # -------------------------------------------------------------------------------------------------- - def update_existing_profile(self, existing_profile, profile, tag_ids: list): - profile_id = existing_profile['id'] - self.logger.debug(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 - - if len(tag_ids) > 0: - existing_profile['tags'] = tag_ids - - self.request('put', f'/releaseprofile/{profile_id}', existing_profile) - - # -------------------------------------------------------------------------------------------------- - def get_tags(self): - return self.request('get', '/tag') - - # -------------------------------------------------------------------------------------------------- - def create_missing_tags(self, current_tags_json, new_tags: list): - for t in current_tags_json: - try: - new_tags.remove(t['label']) - except ValueError: - # The tag is not in the list specified by the user; ignore and continue - pass - - # Anything still left in `new_tags` represents tags we need to add in Sonarr - for t in new_tags: - self.logger.debug(f'Creating tag: {t}') - r = self.request('post', '/tag', {'label': t}) - current_tags_json.append(r) - - return current_tags_json - - # -------------------------------------------------------------------------------------------------- - def do_version_check(self): - # 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') - sonarr_version = self.get_version() - if sonarr_version < minimum_version: - raise TrashError(f'Your Sonarr version ({sonarr_version}) does not meet the minimum required version of {minimum_version} to use this script.') - - # -------------------------------------------------------------------------------------------------- - # 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 = [] - for quality, min_value, max_value in guide_definition: - entry = self.find_quality_definition_entry(server_definition, quality) - if not entry: - print(f'WARN: Quality definition lacks entry for {quality}; it will be skipped.') - continue - entry = deepcopy(entry) - entry['minSize'] = min_value - entry['maxSize'] = max_value - new_definition.append(entry) - - self.logger.debug('Setting Quality ' - f'[Name: {entry["quality"]["name"]}] ' - f'[Min: {entry["minSize"]}] ' - f'[Max: {entry["maxSize"]}] ' - ) - - 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/cmd.py b/src/app/cmd.py deleted file mode 100644 index b816a3f1..00000000 --- a/src/app/cmd.py +++ /dev/null @@ -1,59 +0,0 @@ -import argparse - -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): - def __init__(self, **kwargs): - kwargs.setdefault('default', argparse.SUPPRESS) - kwargs.setdefault('nargs', 0) - super(_NoAction, self).__init__(**kwargs) - - def __call__(self, parser, namespace, values, option_string=None): - pass - -def _add_choices_argument(parser, variable_name, help_text, choices: dict): - parser.register('action', 'none', _NoAction) - parser.add_argument(variable_name, help=help_text, metavar=variable_name.upper(), choices=choices.keys()) - group = parser.add_argument_group(title=f'Choices for {variable_name.upper()}') - for choice,choice_help in choices.items(): - group.add_argument(choice, help=choice_help, action='none') - -def setup_and_parse_args(args_override=None): - parent_p = argparse.ArgumentParser(add_help=False) - parent_p.add_argument('--base-uri', help='The base URL for your Sonarr/Radarr instance, for example `http://localhost:8989`. Required if not doing --preview.') - parent_p.add_argument('--api-key', help='Your API key. Required if not doing --preview.') - parent_p.add_argument('--preview', help='Only display the processed markdown results and nothing else.', - action='store_true', default=False) - parent_p.add_argument('--debug', help='Display additional logs useful for development/debug purposes', - action='store_true', default=False) - parent_p.add_argument('--config', help='The configuration YAML file to use. If not specified, the script will look for `trash.yml` in the same directory as the `trash.py` script.') - - parser = argparse.ArgumentParser(description='Automatically mirror TRaSH guides to your Sonarr/Radarr instance.') - subparsers = parser.add_subparsers(description='Operations specific to different parts of the TRaSH guides', dest='subcommand') - - # Subcommands for 'profile' - profile_p = subparsers.add_parser('profile', help='Pages of the guide that define profiles', - 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', - action='store_true') - - # Subcommands for 'quality' - quality_p = subparsers.add_parser('quality', help='Pages in the guide that provide quality definitions', - 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]') - - args = parser.parse_args(args=args_override) - if not args.subcommand: - parser.print_help() - exit(1) - - return args diff --git a/src/app/guide/__init__.py b/src/app/guide/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/guide/profile_types.py b/src/app/guide/profile_types.py deleted file mode 100644 index 6a8edfa6..00000000 --- a/src/app/guide/profile_types.py +++ /dev/null @@ -1,13 +0,0 @@ -# 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 deleted file mode 100644 index 7b9504ec..00000000 --- a/src/app/guide/quality_types.py +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index 025186d7..00000000 --- a/src/app/guide/radarr/quality.py +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 9601e48b..00000000 --- a/src/app/guide/radarr/utils.py +++ /dev/null @@ -1,54 +0,0 @@ -# -------------------------------------------------------------------------------------------------- -# 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 deleted file mode 100644 index bbb676c0..00000000 --- a/src/app/guide/sonarr/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import profile, quality \ No newline at end of file diff --git a/src/app/guide/sonarr/profile.py b/src/app/guide/sonarr/profile.py deleted file mode 100644 index dbdc351c..00000000 --- a/src/app/guide/sonarr/profile.py +++ /dev/null @@ -1,156 +0,0 @@ -import re -from collections import defaultdict -from enum import Enum -import requests - -from app.profile_data import ProfileData - -TermCategory = Enum('TermCategory', 'Preferred Required Ignored') - -header_regex = re.compile(r'^(#+)\s([\w\s\d]+)\s*$') -score_regex = re.compile(r'score.*?\[(-?[\d]+)\]', re.IGNORECASE) -header_release_profile_regex = re.compile(r'release profile', re.IGNORECASE) -category_regex = ( - (TermCategory.Required, re.compile(r'must contain', re.IGNORECASE)), - (TermCategory.Ignored, re.compile(r'must not contain', re.IGNORECASE)), - (TermCategory.Preferred, re.compile(r'preferred', re.IGNORECASE)), -) - -class ParserState: - def __init__(self): - self.profile_name = None - self.score = None - self.current_category = TermCategory.Preferred - self.bracket_depth = 0 - self.current_header_depth = -1 - - def reset(self): - self.__init__() - - def is_valid(self): - return \ - self.profile_name is not None and \ - self.current_category is not None and \ - (self.current_category != TermCategory.Preferred or self.score is not None) - -# -------------------------------------------------------------------------------------------------- -def get_markdown(page): - response = requests.get(f'https://raw.githubusercontent.com/TRaSH-/Guides/master/docs/Sonarr/V3/{page}.md') - return response.content.decode('utf8') - -# -------------------------------------------------------------------------------------------------- -def parse_category(line): - for rx in category_regex: - if rx[1].search(line): - return rx[0] - - return None - -# -------------------------------------------------------------------------------------------------- -def parse_markdown_outside_fence(args, logger, line, state, results): - # Header processing - if match := header_regex.search(line): - header_depth = len(match.group(1)) - header_text = match.group(2) - logger.debug(f'> Parsing Header [Text: {header_text}] [Depth: {header_depth}]') - - # Profile name (always reset previous state here) - if header_release_profile_regex.search(header_text): - state.reset() - state.profile_name = header_text - logger.debug(f' - New Profile [Text: {header_text}]') - return - - elif header_depth <= state.current_header_depth: - logger.debug(' - !! Non-nested, non-profile header found; resetting all state') - state.reset() - return - - # Until we find a header that defines a profile, we don't care about anything under it. - if not state.profile_name: - return - - # Check if we are enabling the "Include Preferred when Renaming" checkbox - profile = results[state.profile_name] - lower_line = line.lower() - if 'include preferred' in lower_line: - profile.include_preferred_when_renaming = 'not' not in lower_line - logger.debug(f' - "Include Preferred" found [Value: {profile.include_preferred_when_renaming}] [Line: {line}]') - return - - # Either we have a nested header or normal line at this point - # We need to check if we're defining a new category. - if category := parse_category(line): - state.current_category = category - logger.debug(f' - Category Set [Name: {category}] [Line: {line}]') - # DO NOT RETURN HERE! - # The category and score are sometimes in the same sentence (line); continue processing the line!! - # return - - # Check this line for a score value. We do this even if our category may not be set to 'Preferred' yet. - if match := score_regex.search(line): - state.score = int(match.group(1)) - logger.debug(f' - Score [Value: {state.score}]') - return - -# -------------------------------------------------------------------------------------------------- -def parse_markdown_inside_fence(args, logger, line, state, results): - profile = results[state.profile_name] - - if state.current_category == TermCategory.Preferred: - logger.debug(' + Capture Term ' - f'[Category: {state.current_category}] ' - f'[Score: {state.score}] ' - f'[Strict: {args.strict_negative_scores}] ' - f'[Term: {line}]') - - if args.strict_negative_scores and state.score < 0: - profile.ignored.append(line) - else: - profile.preferred[state.score].append(line) - return - - # 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. - line = line.rstrip(',') - - if state.current_category == TermCategory.Ignored: - profile.ignored.append(line) - logger.debug(f' + Capture Term [Category: {state.current_category}] [Term: {line}]') - return - - if state.current_category == TermCategory.Required: - profile.required.append(line) - logger.debug(f' + Capture Term [Category: {state.current_category}] [Term: {line}]') - return - -# -------------------------------------------------------------------------------------------------- -def parse_markdown(args, logger, markdown_content): - results = defaultdict(ProfileData) - state = ParserState() - - for line in markdown_content.splitlines(): - # Always check if we're starting a fenced code block. Whether we are inside one or not greatly affects - # the logic we use. - if line.startswith('```'): - state.bracket_depth = 1 - state.bracket_depth - continue - - # Not inside brackets - if state.bracket_depth == 0: - parse_markdown_outside_fence(args, logger, line, state, results) - # Inside brackets - elif state.bracket_depth == 1: - if not state.is_valid(): - logger.debug(' - !! Inside bracket with invalid state; skipping! ' - f'[Profile Name: {state.profile_name}] ' - f'[Category: {state.current_category}] ' - f'[Score: {state.score}] ' - f'[Line: {line}] ' - ) - else: - parse_markdown_inside_fence(args, logger, line, state, results) - - logger.debug('\n') - return results diff --git a/src/app/guide/sonarr/quality.py b/src/app/guide/sonarr/quality.py deleted file mode 100644 index f8eaaf6e..00000000 --- a/src/app/guide/sonarr/quality.py +++ /dev/null @@ -1,31 +0,0 @@ -import requests -import re -from collections import defaultdict - -header_regex = re.compile(r'^#+') -table_row_regex = re.compile(r'\| *(.*?) *\| *([\d.]+) *\| *([\d.]+) *\|') - -# -------------------------------------------------------------------------------------------------- -def get_markdown(): - trash_anime_markdown_url = 'https://raw.githubusercontent.com/TRaSH-/Guides/master/docs/Sonarr/V3/Sonarr-Quality-Settings-File-Size.md' - response = requests.get(trash_anime_markdown_url) - return response.content.decode('utf8') - -# -------------------------------------------------------------------------------------------------- -def parse_markdown(logger, markdown_content): - results = defaultdict(list) - table = None - - for line in markdown_content.splitlines(): - if not line: - continue - - if header_regex.search(line): - category = 'sonarr:anime' if 'anime' in line.lower() else 'sonarr:non-anime' - table = results[category] - if len(table) > 0: - table = None - elif (match := table_row_regex.search(line)) and table is not None: - table.append((match.group(1), float(match.group(2)), float(match.group(3)))) - - return results diff --git a/src/app/guide/sonarr/utils.py b/src/app/guide/sonarr/utils.py deleted file mode 100644 index 4028e8b2..00000000 --- a/src/app/guide/sonarr/utils.py +++ /dev/null @@ -1,54 +0,0 @@ -# -------------------------------------------------------------------------------------------------- -# 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}' - print(formats.format('Quality', 'Min', 'Max')) - print(formats.format('-------', '---', '---')) - for (quality, min, max) in definition: - print(formats.format(quality, min, max)) - print('') diff --git a/src/app/logger.py b/src/app/logger.py deleted file mode 100644 index e6810b8a..00000000 --- a/src/app/logger.py +++ /dev/null @@ -1,16 +0,0 @@ -from .orderedenum import OrderedEnum - -class Severity(OrderedEnum): - Info = 1 - Debug = 2 - -class Logger: - def __init__(self, args): - self.severity = Severity.Debug if args.debug else Severity.Info - - def info(self, msg): - print(msg) - - def debug(self, msg): - if self.severity >= Severity.Debug: - print(msg) \ No newline at end of file diff --git a/src/app/logic/__init__.py b/src/app/logic/__init__.py deleted file mode 100644 index 74a48b17..00000000 --- a/src/app/logic/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import config, main, sonarr, radarr \ No newline at end of file diff --git a/src/app/logic/config.py b/src/app/logic/config.py deleted file mode 100644 index 6162b91a..00000000 --- a/src/app/logic/config.py +++ /dev/null @@ -1,56 +0,0 @@ -from pathlib import Path -import yaml - -# -------------------------------------------------------------------------------------------------- -def find_profile_by_name(config, profile_type): - for profile in config['profile']: - if profile['type'] == profile_type: - return profile - return None - -# -------------------------------------------------------------------------------------------------- -def load_config(args, logger, default_load_path: Path): - if args.config: - config_path = Path(args.config) - else: - # Look for `trash.yml` in the same directory as the main (entrypoint) python script. - config_path = default_load_path / 'trash.yml' - - logger.debug(f'Using configuration file: {config_path}') - - if config_path.exists(): - with open(config_path, 'r') as f: - config_yaml = f.read() - load_config_string(args, logger, config_yaml) - else: - logger.debug('Config file could not be loaded because it does not exist') - -# -------------------------------------------------------------------------------------------------- -def _config_has_tags(profile): - if profile is None or 'tags' not in profile: - return False; - - tags = profile['tags'] - return tags is not None and len(tags) > 0 - -# -------------------------------------------------------------------------------------------------- -def load_config_string(args, logger, config_yaml): - config = yaml.load(config_yaml, Loader=yaml.Loader) - if not config: - return - - server_name, type_name = args.type.split(':') - server_config = config[server_name] - - if not args.base_uri: - args.base_uri = server_config['base_uri'] - - if not args.api_key: - args.api_key = server_config['api_key'] - - if args.subcommand == 'profile': - profile = find_profile_by_name(server_config, type_name) - if _config_has_tags(profile): - if args.tags is None: - args.tags = [] - args.tags.extend(t for t in profile['tags'] if t not in args.tags) diff --git a/src/app/logic/main.py b/src/app/logic/main.py deleted file mode 100644 index 2611eefc..00000000 --- a/src/app/logic/main.py +++ /dev/null @@ -1,26 +0,0 @@ -from pathlib import Path - -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: Path): - args = setup_and_parse_args() - logger = Logger(args) - - config.load_config(args, logger, 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] - - try: - subcommand_handlers[server_name, args.subcommand](args, logger) - except KeyError: - raise TrashError(f'{args.subcommand} support in {server_name} is not implemented yet') \ No newline at end of file diff --git a/src/app/logic/radarr.py b/src/app/logic/radarr.py deleted file mode 100644 index 70e366b1..00000000 --- a/src/app/logic/radarr.py +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 790f50f2..00000000 --- a/src/app/logic/sonarr.py +++ /dev/null @@ -1,98 +0,0 @@ -import re - -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 - -# -------------------------------------------------------------------------------------------------- -def process_profile(args, logger): - page = profile_types.get(args.type).get('markdown_doc_name') - logger.debug(f'Using markdown page: {page}') - profiles = guide.profile.parse_markdown(args, logger, guide.profile.get_markdown(page)) - - # 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. - utils.filter_profiles(profiles) - - if args.preview: - utils.print_terms_and_scores(profiles) - exit(0) - - sonarr = Sonarr(args, logger) - - # If tags were provided, ensure they exist. Tags that do not exist are added first, so that we - # may specify them with the release profile request payload. - tag_ids = [] - if args.tags: - tags = sonarr.get_tags() - tags = sonarr.create_missing_tags(tags, args.tags[:]) - logger.debug(f'Tags JSON: {tags}') - - # Get a list of IDs that we can pass along with the request to update/create release - # profiles - tag_ids = [t['id'] for t in tags if t['label'] in args.tags] - logger.debug(f'Tag IDs: {tag_ids}') - - # 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(): - type_for_name = profile_types.get(args.type).get('profile_typename') - new_profile_name = f'[Trash] {type_for_name} - {name}' - 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}') - sonarr.update_existing_profile(profile_to_update, profile, tag_ids) - else: - logger.info(f'Creating new profile: {new_profile_name}') - sonarr.create_release_profile(new_profile_name, profile, tag_ids) - -# -------------------------------------------------------------------------------------------------- -def process_quality(args, logger): - guide_definitions = guide.quality.parse_markdown(logger, guide.quality.get_markdown()) - - if args.type == 'sonarr:hybrid': - hybrid_quality_regex = re.compile(r'720|1080') - anime = guide_definitions.get('sonarr:anime') - nonanime = guide_definitions.get('sonarr:non-anime') - if len(anime) != len(nonanime): - raise TrashError('For some reason the anime and non-anime quality definitions are not the same length') - - logger.info( - 'Notice: Hybrid only functions on 720/1080 qualities and uses non-anime values for the rest (e.g. 2160)') - - hybrid = [] - for i in range(len(nonanime)): - left = nonanime[i] - if not hybrid_quality_regex.search(left[0]): - logger.debug('Ignored Quality: ' + left[0]) - hybrid.append(left) - else: - right = None - for r in anime: - if r[0] == left[0]: - right = r - - if right is None: - raise TrashError(f'Could not find matching anime quality for non-anime quality named: {left[0]}') - - hybrid.append((left[0], min(left[1], right[1]), max(left[2], right[2]))) - - guide_definitions['sonarr:hybrid'] = hybrid - - 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}') - sonarr = Sonarr(args, logger) - definition = sonarr.get_quality_definition() - sonarr.update_quality_definition(definition, selected_definition) \ No newline at end of file diff --git a/src/app/orderedenum.py b/src/app/orderedenum.py deleted file mode 100644 index 457fd718..00000000 --- a/src/app/orderedenum.py +++ /dev/null @@ -1,23 +0,0 @@ -from enum import Enum - - -class OrderedEnum(Enum): - def __ge__(self, other): - if self.__class__ is other.__class__: - return self.value >= other.value - return NotImplemented - - def __gt__(self, other): - if self.__class__ is other.__class__: - return self.value > other.value - return NotImplemented - - def __le__(self, other): - if self.__class__ is other.__class__: - return self.value <= other.value - return NotImplemented - - def __lt__(self, other): - if self.__class__ is other.__class__: - return self.value < other.value - return NotImplemented diff --git a/src/app/profile_data.py b/src/app/profile_data.py deleted file mode 100644 index fd08fe23..00000000 --- a/src/app/profile_data.py +++ /dev/null @@ -1,11 +0,0 @@ -from collections import defaultdict - -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 diff --git a/src/app/trash_error.py b/src/app/trash_error.py deleted file mode 100644 index b4d18b24..00000000 --- a/src/app/trash_error.py +++ /dev/null @@ -1,2 +0,0 @@ -class TrashError(Exception): - pass \ No newline at end of file diff --git a/src/tests/__init__.py b/src/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/tests/guide/__init__.py b/src/tests/guide/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/tests/guide/sonarr/__init__.py b/src/tests/guide/sonarr/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/tests/guide/sonarr/data/test_parse_markdown_complete_doc.md b/src/tests/guide/sonarr/data/test_parse_markdown_complete_doc.md deleted file mode 100644 index f5ff2902..00000000 --- a/src/tests/guide/sonarr/data/test_parse_markdown_complete_doc.md +++ /dev/null @@ -1,22 +0,0 @@ -### Release Profile 1 - -The score is [100] - -``` -term1 -``` - -This is another Score that should not be used [200] - -#### Must not contain - -``` -term2 -term3 -``` - -#### Must contain - -``` -term4 -``` \ No newline at end of file diff --git a/src/tests/guide/sonarr/data/test_parse_markdown_sonarr_quality_definitions.md b/src/tests/guide/sonarr/data/test_parse_markdown_sonarr_quality_definitions.md deleted file mode 100644 index 5d184229..00000000 --- a/src/tests/guide/sonarr/data/test_parse_markdown_sonarr_quality_definitions.md +++ /dev/null @@ -1,39 +0,0 @@ -## Sonarr Quality Definitions - -| Quality | Minimum | Maximum | -| ------------------ | ------- | ------- | -| HDTV-720p | 17.9 | 67.5 | -| HDTV-1080p | 20 | 137.3 | -| WEBRip-720p | 20 | 137.3 | -| WEBDL-720p | 20 | 137.3 | -| Bluray-720p | 34.9 | 137.3 | -| WEBRip-1080p | 22 | 137.3 | -| WEBDL-1080p | 22 | 137.3 | -| Bluray-1080p | 50.4 | 227 | -| Bluray-1080p Remux | 69.1 | 400 | -| HDTV-2160p | 84.5 | 350 | -| WEBRip-2160p | 84.5 | 350 | -| WEBDL-2160p | 84.5 | 350 | -| Bluray-2160p | 94.6 | 400 | -| Bluray-2160p Remux | 204.4 | 400 | - ------- - -### Sonarr Quality Definitions - Anime (Work in Progress) - -| Quality | Minimum | Maximum | -| ------------------ | ------- | ------- | -| HDTV-720p | 2.3 | 51.4 | -| HDTV-1080p | 2.3 | 100 | -| WEBRip-720p | 4.3 | 100 | -| WEBDL-720p | 4.3 | 51.4 | -| Bluray-720p | 4.3 | 102.2 | -| WEBRip-1080p | 4.5 | 257.4 | -| WEBDL-1080p | 4.3 | 253.6 | -| Bluray-1080p | 4.3 | 258.1 | -| Bluray-1080p Remux | 0 | 400 | -| HDTV-2160p | 84.5 | 350 | -| WEBRip-2160p | 84.5 | 350 | -| WEBDL-2160p | 84.5 | 350 | -| Bluray-2160p | 94.6 | 400 | -| Bluray-2160p Remux | 204.4 | 400 | \ No newline at end of file diff --git a/src/tests/guide/sonarr/test_profile.py b/src/tests/guide/sonarr/test_profile.py deleted file mode 100644 index 4cdefca4..00000000 --- a/src/tests/guide/sonarr/test_profile.py +++ /dev/null @@ -1,55 +0,0 @@ -from app.guide.sonarr import profile -from pathlib import Path -from tests.mock_logger import MockLogger - -data_files = Path(__file__).parent / 'data' - -def test_parse_markdown_complete_doc(): - md_file = data_files / 'test_parse_markdown_complete_doc.md' - with open(md_file) as file: - test_markdown = file.read() - - class args: - strict_negative_scores = False - - results = profile.parse_markdown(args, MockLogger(), test_markdown) - - assert len(results) == 1 - test_profile = next(iter(results.values())) - - assert len(test_profile.ignored) == 2 - assert sorted(test_profile.ignored) == sorted(['term2', 'term3']) - - assert len(test_profile.required) == 1 - assert test_profile.required == ['term4'] - - assert len(test_profile.preferred) == 1 - assert test_profile.preferred.get(100) == ['term1'] - - -def test_parse_markdown_strict_negative_scores(): - test_markdown = ''' -# Test Release Profile - -This score is negative [-1] - -``` -abc -``` - -This score is positive [0] - -``` -xyz -``` -''' - - class args: - strict_negative_scores = True - - results = profile.parse_markdown(args, MockLogger(), test_markdown) - assert len(results['Test Release Profile'].required) == 0 - assert len(results['Test Release Profile'].ignored) == 1 - assert results['Test Release Profile'].ignored[0] == 'abc' - assert len(results['Test Release Profile'].preferred) == 1 - assert results['Test Release Profile'].preferred[0] == ['xyz'] diff --git a/src/tests/guide/sonarr/test_quality.py b/src/tests/guide/sonarr/test_quality.py deleted file mode 100644 index df9f3650..00000000 --- a/src/tests/guide/sonarr/test_quality.py +++ /dev/null @@ -1,55 +0,0 @@ -import app.guide.sonarr.quality as quality -from pathlib import Path -from tests.mock_logger import MockLogger - -data_files = Path(__file__).parent / 'data' - -def test_parse_markdown_complete_doc(): - md_file = data_files / 'test_parse_markdown_sonarr_quality_definitions.md' - with open(md_file) as file: - test_markdown = file.read() - - results = quality.parse_markdown(MockLogger(), test_markdown) - - # Dictionary: Key (header name (anime or non-anime)), list (quality definitions table rows) - assert len(results) == 2 - - table = results.get('sonarr:anime') - assert len(table) == 14 - table_expected = [ - ('HDTV-720p', 2.3, 51.4), - ('HDTV-1080p', 2.3, 100.0), - ('WEBRip-720p', 4.3, 100.0), - ('WEBDL-720p', 4.3, 51.4), - ('Bluray-720p', 4.3, 102.2), - ('WEBRip-1080p', 4.5, 257.4), - ('WEBDL-1080p', 4.3, 253.6), - ('Bluray-1080p', 4.3, 258.1), - ('Bluray-1080p Remux', 0.0, 400.0), - ('HDTV-2160p', 84.5, 350.0), - ('WEBRip-2160p', 84.5, 350.0), - ('WEBDL-2160p', 84.5, 350.0), - ('Bluray-2160p', 94.6, 400.0), - ('Bluray-2160p Remux', 204.4, 400.0) - ] - assert sorted(table) == sorted(table_expected) - - table = results.get('sonarr:non-anime') - assert len(table) == 14 - table_expected = [ - ('HDTV-720p', 17.9, 67.5), - ('HDTV-1080p', 20.0, 137.3), - ('WEBRip-720p', 20.0, 137.3), - ('WEBDL-720p', 20.0, 137.3), - ('Bluray-720p', 34.9, 137.3), - ('WEBRip-1080p', 22.0, 137.3), - ('WEBDL-1080p', 22.0, 137.3), - ('Bluray-1080p', 50.4, 227.0), - ('Bluray-1080p Remux', 69.1, 400.0), - ('HDTV-2160p', 84.5, 350.0), - ('WEBRip-2160p', 84.5, 350.0), - ('WEBDL-2160p', 84.5, 350.0), - ('Bluray-2160p', 94.6, 400.0), - ('Bluray-2160p Remux', 204.4, 400.0) - ] - assert sorted(table) == sorted(table_expected) diff --git a/src/tests/logic/test_config.py b/src/tests/logic/test_config.py deleted file mode 100644 index b0e5b49e..00000000 --- a/src/tests/logic/test_config.py +++ /dev/null @@ -1,51 +0,0 @@ -from inspect import cleandoc -from pathlib import Path - -from app import cmd -from app.logic import config -from tests.mock_logger import MockLogger - -def test_config_load_from_file_default(mocker): - mock_open = mocker.patch('app.logic.config.open', mocker.mock_open(read_data='')) - mocker.patch.object(Path, 'exists', return_value=True) - - args = cmd.setup_and_parse_args(['profile', 'sonarr:anime']) - default_root = Path(__file__).parent - config.load_config(args, MockLogger(), default_root) - - mock_open.assert_called_once_with(default_root / 'trash.yml', 'r') - -def test_config_load_from_file_args(mocker): - mock_open = mocker.patch('app.logic.config.open', mocker.mock_open(read_data='')) - mocker.patch.object(Path, 'exists', return_value=True) - - expected_yml_path = Path(__file__).parent.parent / 'overridden_config.yml' - args = cmd.setup_and_parse_args(['profile', 'sonarr:anime', '--config', str(expected_yml_path)]) - config.load_config(args, MockLogger(), '.') - - mock_open.assert_called_once_with(expected_yml_path, 'r') - -def test_config_tags(): - yaml = cleandoc(''' - sonarr: - base_uri: http://localhost:8989 - api_key: a95cc792074644759fefe3ccab544f6e - profile: - - type: anime - tags: - - anime - - type: web-dl - tags: - - tv - - series - ''') - - args = cmd.setup_and_parse_args(['profile', 'sonarr:anime']) - config.load_config_string(args, MockLogger(), yaml) - assert args.base_uri == 'http://localhost:8989' - assert args.api_key == 'a95cc792074644759fefe3ccab544f6e' - assert args.tags == ['anime'] - - args = cmd.setup_and_parse_args(['profile', 'sonarr:web-dl']) - config.load_config_string(args, MockLogger(), yaml) - assert args.tags == ['tv', 'series'] diff --git a/src/tests/logic/test_main.py b/src/tests/logic/test_main.py deleted file mode 100644 index 834a868e..00000000 --- a/src/tests/logic/test_main.py +++ /dev/null @@ -1,22 +0,0 @@ -import sys -from pathlib import Path - -from app.logic import main - -def test_main_sonarr_profile(mocker): - test_args = ['trash.py', 'profile', 'sonarr:anime'] - mock_processor = mocker.patch('app.logic.sonarr.process_profile') - mocker.patch.object(sys, 'argv', test_args) - - main.main(Path()) - - mock_processor.assert_called_once() - -def test_main_sonarr_quality(mocker): - test_args = ['trash.py', 'quality', 'sonarr:anime'] - mock_processor = mocker.patch('app.logic.sonarr.process_quality') - mocker.patch.object(sys, 'argv', test_args) - - 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 deleted file mode 100644 index a72a94f2..00000000 --- a/src/tests/logic/test_radarr.py +++ /dev/null @@ -1,13 +0,0 @@ -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 diff --git a/src/tests/logic/test_sonarr.py b/src/tests/logic/test_sonarr.py deleted file mode 100644 index 0e886317..00000000 --- a/src/tests/logic/test_sonarr.py +++ /dev/null @@ -1,16 +0,0 @@ -import pytest - -from app.trash_error import TrashError -from app.logic import sonarr as logic -from app import cmd -from tests.mock_logger import MockLogger - -class TestSonarrLogic: - logger = MockLogger() - - @staticmethod - def test_throw_without_required_arguments(): - with pytest.raises(TrashError): - args = cmd.setup_and_parse_args(['profile', 'sonarr:anime', '--base-uri', 'value']) - logic.process_profile(args, TestSonarrLogic.logger) - diff --git a/src/tests/mock_logger.py b/src/tests/mock_logger.py deleted file mode 100644 index d7fcf9b5..00000000 --- a/src/tests/mock_logger.py +++ /dev/null @@ -1,3 +0,0 @@ -class MockLogger: - def info(self, msg): pass - def debug(self, msg): pass \ No newline at end of file diff --git a/src/trash.py b/src/trash.py deleted file mode 100644 index f9f689a3..00000000 --- a/src/trash.py +++ /dev/null @@ -1,12 +0,0 @@ -from pathlib import Path - -from app.logic.main import main -from app.trash_error import TrashError - -# -------------------------------------------------------------------------------------------------- -if __name__ == '__main__': - try: - main(Path(__file__).parent) - except TrashError as e: - print(f'ERROR: {e}') - exit(1)