Delete existing Python project

pull/5/head
Robert Dailey 3 years ago
parent b7f34c3b52
commit ffc5a9df56

258
.gitignore vendored

@ -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

3
.idea/.gitignore vendored

@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
<excludeFolder url="file://$MODULE_DIR$/.vscode" />
<excludeFolder url="file://$MODULE_DIR$/sonarr_api_examples" />
</content>
<orderEntry type="jdk" jdkName="Python 3.9 (TrashUpdater)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PackageRequirementsSettings">
<option name="requirementsPath" value="" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="pytest" />
</component>
</module>

@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

@ -1,7 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="PROJECT_PROFILE" value="Default" />
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.9 (TrashUpdater)" project-jdk-type="Python SDK" />
</project>

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/TrashUpdater.iml" filepath="$PROJECT_DIR$/.idea/TrashUpdater.iml" />
</modules>
</component>
</project>

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

@ -1,10 +0,0 @@
{
"default": true,
"line-length": {
"line_length": 100,
"tables": false
},
"no-inline-html": {
"allowed_elements": ["br"]
}
}

@ -1,3 +0,0 @@
{
"python.linting.enabled": true
}

@ -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`**<br>
Provide a list of settings used per each type of release profile supported in the guide (e.g.
`web-dl`, `anime`).
* **`type`**<br>
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`**<br>
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
```

@ -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
}
]

@ -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
}

@ -1 +0,0 @@
from . import server, sonarr

@ -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

@ -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)

@ -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

@ -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

@ -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'
},
}

@ -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.'
},
}

@ -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

@ -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('')

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

@ -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

@ -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

@ -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('')

@ -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)

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

@ -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)

@ -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')

@ -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)

@ -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)

@ -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

@ -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

@ -1,2 +0,0 @@
class TrashError(Exception):
pass

@ -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
```

@ -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 |

@ -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']

@ -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)

@ -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']

@ -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()

@ -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())

@ -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)

@ -1,3 +0,0 @@
class MockLogger:
def info(self, msg): pass
def debug(self, msg): pass

@ -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)
Loading…
Cancel
Save