parent
b7f34c3b52
commit
ffc5a9df56
@ -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
|
@ -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…
Reference in new issue