Complete rewrite using C# .NET Core 5

pull/5/head v1.0.0
Robert Dailey 3 years ago
parent ffc5a9df56
commit bb5c74e0cb

@ -0,0 +1,127 @@
name: Build & Test
on:
push:
paths-ignore:
- 'wiki/**'
- '**.md'
pull_request:
paths-ignore:
- 'wiki/**'
- '**.md'
env:
dotnetVersion: 5.0.x
defaults:
run:
working-directory: src
jobs:
test:
name: Test
runs-on: windows-latest
steps:
- name: Checkout Source Code
uses: actions/checkout@v2
with:
fetch-depth: 0 # avoid shallow clone for NBGV
- name: Setup .NET Core SDK ${{ env.dotnetVersion }}
uses: actions/setup-dotnet@v1
with:
dotnet-version: ${{ env.dotnetVersion }}
- name: Test
run: dotnet test --configuration Release --logger GitHubActions
build:
name: Build
needs: test
strategy:
fail-fast: true
matrix:
runtime: [win-x64, linux-x64, osx-x64]
# Must run on Windows so that version info gets properly set in host EXE. See:
# https://github.com/dotnet/runtime/issues/3828
runs-on: windows-latest
steps:
- name: Checkout Source Code
uses: actions/checkout@v2
with:
fetch-depth: 0 # avoid shallow clone for NBGV
- uses: dotnet/nbgv@master
id: nbgv
- name: Setup .NET Core SDK ${{ env.dotnetVersion }}
uses: actions/setup-dotnet@v1
with:
dotnet-version: ${{ env.dotnetVersion }}
- name: Publish
run: >
dotnet publish Trash
--configuration Release
--output publish
--runtime ${{ matrix.runtime }}
--self-contained true
-p:PublishSingleFile=true
-p:PublishTrimmed=true
-p:IncludeNativeLibrariesForSelfExtract=true
- name: Zip Binary
shell: pwsh
run: Compress-Archive publish/trash* trash-${{ matrix.runtime }}.zip
- name: Upload Artifacts
uses: actions/upload-artifact@v2
with:
name: trash
path: src/trash-*.zip
release:
name: Release
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
# github.event.create.ref_type == 'tag'
# startsWith(github.event.push.ref, 'refs/heads/release/')
# github.event.pull_request.merged == true &&
# startsWith(github.event.pull_request.head.ref, 'release/')
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0 # avoid shallow clone for NBGV
# token: ${{ secrets.GITHUB_TOKEN }} # Allows git push
- name: Set up NBGV
uses: dotnet/nbgv@master
id: nbgv
- name: Verify tag matches version.json
if: endsWith(github.ref, steps.nbgv.outputs.SimpleVersion) != true
run: |
echo "The tag ${{ github.ref }} does not match version.json: ${{ steps.nbgv.outputs.SimpleVersion }}"
exit 1
- name: Download Artifacts
uses: actions/download-artifact@v2
with:
name: trash
- name: Extract Changelog
id: changelog
uses: ffurrer2/extract-release-notes@v1
- name: Create Release
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
with:
files: trash-*.zip
body: ${{ steps.changelog.outputs.release_notes }}
tag_name: ${{ github.event.create.ref }}
draft: false
prerelease: ${{ steps.nbgv.outputs.PrereleaseVersion != '' }}

@ -0,0 +1,78 @@
name: Draft New Release
on:
workflow_dispatch:
jobs:
draft_new_release:
name: Draft a new release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0 # avoid shallow clone for NBGV
token: ${{ secrets.PAT }} # Allows git push
- name: Set up NBGV
uses: dotnet/nbgv@master
id: nbgv
- run: echo "VERSION=${{ steps.nbgv.outputs.SimpleVersion }}${{ steps.nbgv.outputs.PrereleaseVersion }}" >> $GITHUB_ENV
- name: Initialize mandatory git config
run: |
git config user.name "GitHub Actions"
git config user.email noreply@github.com
# TODO: Support specifying a SHA1 to branch from in the workflow run?
- name: Create Release Branch
run: |
nbgv prepare-release
git checkout release/${{ steps.nbgv.outputs.SimpleVersion }}
- name: Update changelog
uses: thomaseizinger/keep-a-changelog-new-release@1.1.0
with:
version: ${{ env.VERSION }}
- name: Commit Changelog
run: git commit -m 'Finalize changelog for version ${{ env.VERSION }}' -- CHANGELOG.md
- name: Push master and release branch
run: git push origin master +release/${{ steps.nbgv.outputs.SimpleVersion }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@v3
id: cpr
with:
token: ${{ secrets.PAT }}
delete-branch: true
base: master
- name: Enable Pull Request Automerge
uses: peter-evans/enable-pull-request-automerge@v1
with:
token: ${{ secrets.PAT }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
merge-method: merge
title: "Preparation for Release: ${{ env.VERSION }}"
body: |
This pull request represents changes to be made in preparation of the next release,
${{ env.VERSION }}.
Once the build and release tasks in this PR are completed, the release will be created
and this PR will be automatically merged.
- name: Auto Approve Pull Request
uses: actions/github-script@v3
if: steps.cpr.outputs.pull-request-operation == 'created'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: ${{ steps.cpr.outputs.pull-request-number }},
event: 'APPROVE'
})

@ -0,0 +1,25 @@
name: Publish Wiki
on:
push:
paths:
- 'wiki/**'
branches:
- master
jobs:
wiki:
name: Publish Wiki
runs-on: ubuntu-latest
steps:
- name: Checkout Source Code
uses: actions/checkout@v2
- name: Upload Documentation to Wiki
uses: Andrew-Chen-Wang/github-wiki-action@v2
env:
WIKI_DIR: wiki/
GH_TOKEN: ${{ secrets.PAT }}
GH_MAIL: ${{ secrets.EMAIL }}
GH_NAME: ${{ github.repository_owner }}
EXCLUDED_FILES: "*.json"

466
.gitignore vendored

@ -0,0 +1,466 @@
# Created by https://www.toptal.com/developers/gitignore/api/windows,rider,csharp
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,rider,csharp
### Csharp ###
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*[.json, .xml, .info]
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
### Rider ###
# 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
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/windows,rider,csharp

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/.idea.TrashUpdater.iml
/modules.xml
/contentModel.xml
/projectSettingsUpdater.xml

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

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

@ -0,0 +1,7 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="DuplicatedCode" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="PyPep8Inspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
</profile>
</component>

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

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

@ -0,0 +1,34 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.0] - 2021-04-14
See the [Python Migration Guide][py-mig] for details on how to update your YAML configuration.
[py-mig]: https://github.com/rcdailey/trash-updater/wiki/Python-Migration-Guide
### Added
- Full rewrite of the application in C# .NET Core 5
- More than one configuration (YAML) file can be specified using the `--config` option.
- Multiple Sonarr and Radarr instances can be specified in a single YAML config.
### Removed
- Nearly all command line options removed in favor of YAML equivalents.
- Completely removed old python project & source code
## [0.1.0]
First (and final) release of the Python version of the application.
<!-- Release Links -->
[unreleased]: https://github.com/rcdailey/trash-updater/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/rcdailey/trash-updater/compare/v0.1.0...v1.0.0
[0.1.0]: https://github.com/rcdailey/trash-updater/releases/tag/v0.1.0

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Robert Dailey
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -0,0 +1,15 @@
[CmdletBinding()]
param (
[Parameter()]
[string]
$runtime
)
dotnet publish Trash `
--output publish `
--runtime $runtime `
--configuration Release `
--self-contained true `
-p:PublishSingleFile=true `
-p:PublishTrimmed=true `
-p:IncludeNativeLibrariesForSelfExtract=true

@ -0,0 +1,109 @@
# TRaSH Guide Updater
Automatically mirror TRaSH guides to your Sonarr/Radarr instance.
> **NOTICE**: This program is a work-in-progress!
## 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" terms from guides are reflected in
corresponding release profile fields 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.
Quality Definitions
- Anime and Series (Non-Anime) quality definitions from the guide.
- "Hybrid" type supported that is a mixture of both.
### Radarr
Quality Definitions
- Movie quality definition from the guide
## Installation
Simply download the latest release for your platform:
- [Windows (64-bit)](https://github.com/rcdailey/trash-updater/releases/latest/download/trash-win-x64.zip)
- [Linux (64-bit)](https://github.com/rcdailey/trash-updater/releases/latest/download/trash-linux-x64.zip)
- [macOS (64-bit)](https://github.com/rcdailey/trash-updater/releases/latest/download/trash-osx-x64.zip)
The above links are from the latest release on the [releases page][rp]. Feel free to visit there for
release notes and older releases.
> **Note**: For Sonarr updates to work, you must be running version `3.0.4.1098` or greater.
[rp]: https://github.com/rcdailey/trash-updater/releases
### Special Note about Linux
When you extract the ZIP archive on Linux, it will *not* have the executable permission set. Here is
a quick one-liner you can use in a terminal to download the latest release, extract it, and set it
as executable. Run this from the directory where you want `trash` to be installed.
```bash
wget -O trash.zip https://github.com/rcdailey/trash-updater/releases/latest/download/trash-linux-x64.zip \
&& unzip trash.zip && rm trash.zip && chmod +x trash
```
## Getting Started
> **TL;DR**: Run `trash [sonarr|radarr] --help` for help with available command line options. Visit
> [the wiki](https://github.com/rcdailey/trash-updater/wiki) for in-depth documentation about the
> command line, configuration, and other topics.
The `trash` executable provides one subcommand per distinct service. This means, for example, you
can run `trash sonarr` and `trash radarr`. When you run these subcommands, the relevant service
configuration is read from the YAML files.
That's all you need to do on the command line to get the program to parse guides and push settings
to the respective service. Most of the documentation will be for the YAML configuration, which is
what drives the behavior of the program.
### Read the Documentation
Main documentation is located in the wiki. Links provided below for some main topics.
- [Command Line Reference](../wiki/Command-Line-Reference)
- [Configuration Reference](../wiki/Configuration-Reference)
## Important Notices
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 ASAP would be appreciated and will help identify issues more
quickly.
Please be aware that this application relies on a deterministic and consistent structure of the
TRaSH Guide markdown files. I have [documented guidelines][dg] for the TRaSH Guides that should help
to reduce the risk of the guide breaking the program's parsing logic, however it requires that guide
contributors follow them.
[dg]: ../wiki/TRaSH-Guide-Structural-Guidelines
### Limitations
This application 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).

@ -0,0 +1,54 @@
pool:
vmImage: windows-latest
variables:
configuration: Release
strategy:
matrix:
windows:
runtime: win-x64
linux:
runtime: linux-x64
macos:
runtime: osx-x64
steps:
-
checkout: self
-
task: UseDotNet@2
displayName: Setup .NET Core
inputs:
version: 5.0.x
-
pwsh: |
dotnet tool install --tool-path . nbgv
./nbgv cloud -a
displayName: Set build number
-
task: DotNetCoreCLI@2
displayName:
inputs:
command: build
arguments: --configuration $(configuration)
-
task: DotNetCoreCLI@2
displayName:
inputs:
command: test
arguments: --configuration $(configuration)
-
task: DotNetCoreCLI@2
displayName:
inputs:
command: publish
projects: Trash
arguments: >
--runtime $(runtime)
--configuration $(configuration)
--self-contained true
-p:PublishSingleFile=true
-p:PublishTrimmed=true
-p:IncludeNativeLibrariesForSelfExtract=true

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/modules.xml
/projectSettingsUpdater.xml
/.idea.TrashUpdater.iml
/contentModel.xml

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

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

@ -0,0 +1,31 @@
<Project>
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningLevel>9999</WarningLevel>
<TreatWarningsAsErrors />
<DebugType>embedded</DebugType>
<!-- Rider does not support `AllEnabledByDefault` yet. See:
https://youtrack.jetbrains.com/issue/RIDER-55142
-->
<!-- <AnalysisMode>AllEnabledByDefault</AnalysisMode>-->
<GitVersionBaseDirectory>$(MSBuildThisFileDirectory)</GitVersionBaseDirectory>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Nerdbank.GitVersioning" Condition=" '$(DisableNbgv)' != 'true' " />
</ItemGroup>
<ItemGroup Condition="$(ProjectName.EndsWith('.Tests'))">
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="NSubstitute.Analyzers.CSharp" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="AutofacContrib.NSubstitute" />
<PackageReference Include="GitHubActionsTestLogger" />
</ItemGroup>
<ItemGroup Condition="$(ProjectName.EndsWith('.Tests'))">
<EmbeddedResource Include="**\Data\*" />
</ItemGroup>
</Project>

@ -0,0 +1,31 @@
<Project>
<ItemGroup>
<!-- Test Packages -->
<PackageReference Update="AutofacContrib.NSubstitute" Version="7.*" />
<PackageReference Update="FluentAssertions" Version="5.*" />
<PackageReference Update="GitHubActionsTestLogger" Version="1.*" />
<PackageReference Update="Microsoft.NET.Test.Sdk" Version="16.*" />
<PackageReference Update="NSubstitute.Analyzers.CSharp" Version="1.*" />
<PackageReference Update="NSubstitute" Version="4.*" />
<PackageReference Update="NUnit.Analyzers" Version="3.*" />
<PackageReference Update="NUnit" Version="3.*" />
<PackageReference Update="NUnit3TestAdapter" Version="3.*" />
<!-- Non-Test Packages -->
<PackageReference Update="Autofac.Extensions.DependencyInjection" Version="7.*" />
<PackageReference Update="Autofac" Version="6.*" />
<PackageReference Update="CliFx" Version="2.*" />
<PackageReference Update="Flurl.Http" Version="3.*" />
<PackageReference Update="Flurl" Version="3.*" />
<PackageReference Update="Nerdbank.GitVersioning" Version="3.*">
<PrivateAssets>true</PrivateAssets>
</PackageReference>
<PackageReference Update="JetBrains.Annotations" Version="*">
<PrivateAssets>true</PrivateAssets>
</PackageReference>
<PackageReference Update="Serilog.Sinks.Console" Version="3.*" />
<PackageReference Update="Serilog" Version="2.*" />
<PackageReference Update="System.IO.Abstractions" Version="13.*" />
<PackageReference Update="YamlDotNet" Version="10.*" />
</ItemGroup>
</Project>

@ -0,0 +1,53 @@
using System;
using FluentAssertions;
using NUnit.Framework;
namespace TestLibrary.Tests
{
internal class TestFixtureMissingAttribute
{
}
[TestFixture]
public class TestDataTest
{
[Test]
public void Construction_ClassMissingAttribute_Throw()
{
// ReSharper disable once ObjectCreationAsStatement
Action act = () => new TestData<TestFixtureMissingAttribute>();
act.Should()
.Throw<ArgumentException>()
.WithMessage("*does not have the [TestFixture] attribute");
}
[Test]
public void GetResourceData_CustomDir_ReturnResourceData()
{
TestData<TestDataTest> testData = new();
testData.DataSubdirectoryName = "OtherData";
var data = testData.GetResourceData("AnotherDataFile.txt");
data.Trim().Should().Be("AnotherDataFile");
}
[Test]
public void GetResourceData_DefaultDir_ReturnResourceData()
{
TestData<TestDataTest> testData = new();
var data = testData.GetResourceData("DataFile.txt");
data.Trim().Should().Be("DataFile");
}
[Test]
public void GetResourceData_NonexistentFile_Throw()
{
TestData<TestDataTest> testData = new();
Action act = () => testData.GetResourceData("DataFileWontBeFound.txt");
act.Should()
.Throw<ArgumentException>()
.WithMessage("Embedded resource not found*");
}
}
}

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TestLibrary\TestLibrary.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="DataFileWontBeFound.txt" />
<EmbeddedResource Include="OtherData/AnotherDataFile.txt" />
</ItemGroup>
</Project>

@ -0,0 +1,14 @@
using System.IO;
using System.Text;
namespace TestLibrary
{
public static class StreamBuilder
{
public static StreamReader FromString(string data)
{
var stream = new MemoryStream(Encoding.UTF8.GetBytes(data));
return new StreamReader(stream);
}
}
}

@ -0,0 +1,41 @@
using System;
using System.IO;
using System.Reflection;
using NUnit.Framework;
namespace TestLibrary
{
public class TestData<TTestFixtureClass>
{
private readonly Assembly? _assembly;
private readonly string? _namespace;
public TestData()
{
var attributes = typeof(TTestFixtureClass).GetCustomAttributes(typeof(TestFixtureAttribute), true);
if (attributes.Length == 0)
{
throw new ArgumentException(
$"{typeof(TTestFixtureClass).Name} does not have the [TestFixture] attribute");
}
_namespace = typeof(TTestFixtureClass).Namespace;
_assembly = Assembly.GetAssembly(typeof(TTestFixtureClass));
}
public string DataSubdirectoryName { get; set; } = "Data";
public string GetResourceData(string name)
{
var resourceName = $"{_namespace}.{DataSubdirectoryName}.{name}";
using var stream = _assembly?.GetManifestResourceStream(resourceName);
if (stream == null)
{
throw new ArgumentException($"Embedded resource not found: {resourceName}");
}
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}
}

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NUnit" />
</ItemGroup>
</Project>

@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Text;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using TestLibrary;
using Trash.Config;
using Trash.Extensions;
using Trash.Sonarr;
using Trash.Sonarr.ReleaseProfile;
using YamlDotNet.Serialization.ObjectFactories;
namespace Trash.Tests.Config
{
[TestFixture]
public class ConfigurationLoaderTest
{
private TextReader GetResourceData(string file)
{
var testData = new TestData<ConfigurationLoaderTest>();
if (testData == null)
{
throw new InvalidOperationException("TestData object has not been created yet");
}
return new StringReader(testData.GetResourceData(file));
}
[Test]
public void Load_UsingStream_CorrectParsing()
{
var configLoader = new ConfigurationLoader<SonarrConfiguration>(
Substitute.For<IConfigurationProvider<SonarrConfiguration>>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory());
var configs = configLoader.LoadFromStream(GetResourceData("Load_UsingStream_CorrectParsing.yml"), "sonarr");
configs.Should()
.BeEquivalentTo(new List<SonarrConfiguration>
{
new()
{
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = "http://localhost:8989",
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new()
{
Type = ReleaseProfileType.Anime,
StrictNegativeScores = true,
Tags = new List<string> {"anime"}
},
new()
{
Type = ReleaseProfileType.Series,
StrictNegativeScores = false,
Tags = new List<string>
{
"tv",
"series"
}
}
}
}
});
}
[Test]
public void LoadMany_CorrectNumberOfIterations()
{
StreamReader MockYaml(params object[] args)
{
var str = new StringBuilder("sonarr:");
const string templateYaml = "\n - base_url: {0}";
str.Append(args.Aggregate("", (current, p) => current + templateYaml.FormatWith(p)));
return StreamBuilder.FromString(str.ToString());
}
var fs = Substitute.For<IFileSystem>();
fs.File.OpenText(Arg.Any<string>())
.Returns(MockYaml(1, 2), MockYaml(3));
var provider = Substitute.For<IConfigurationProvider<SonarrConfiguration>>();
// var objectFactory = Substitute.For<IObjectFactory>();
// objectFactory.Create(Arg.Any<Type>())
// .Returns(t => Substitute.For(new[] {(Type)t[0]}, Array.Empty<object>()));
var actualActiveConfigs = new List<SonarrConfiguration>();
provider.ActiveConfiguration = Arg.Do<SonarrConfiguration>(a => actualActiveConfigs.Add(a));
var loader = new ConfigurationLoader<SonarrConfiguration>(provider, fs, new DefaultObjectFactory());
var fakeFiles = new List<string>
{
"config1.yml",
"config2.yml"
};
var expected = new List<SonarrConfiguration>
{
new() {BaseUrl = "1"},
new() {BaseUrl = "2"},
new() {BaseUrl = "3"}
};
var actual = loader.LoadMany(fakeFiles, "sonarr").ToList();
actual.Should().BeEquivalentTo(expected);
actualActiveConfigs.Should().BeEquivalentTo(expected);
}
}
}

@ -0,0 +1,12 @@
sonarr:
- base_url: http://localhost:8989
api_key: 95283e6b156c42f3af8a9b16173f876b
release_profiles:
- type: anime
strict_negative_scores: true
tags:
- anime
- type: series
tags:
- tv
- series

@ -0,0 +1,42 @@
using System.Collections.Generic;
using FluentAssertions;
using NUnit.Framework;
using Trash.Extensions;
namespace Trash.Tests.Extensions
{
[TestFixture]
public class DictionaryExtensionsTest
{
private class MySampleValue
{
}
[Test]
public void GetOrCreate_ItemExists_ReturnExistingItem()
{
var sample = new MySampleValue();
var dict = new Dictionary<int, MySampleValue> {{100, sample}};
var theValue = dict.GetOrCreate(100);
dict.Should().HaveCount(1);
dict.Should().Contain(100, sample);
dict.Should().ContainValue(theValue);
theValue.Should().Be(sample);
}
[Test]
public void GetOrCreate_NoItemExists_ItIsCreated()
{
var dict = new Dictionary<int, MySampleValue>();
var theValue = dict.GetOrCreate(100);
dict.Should().HaveCount(1);
dict.Should().Contain(100, theValue);
}
[Test]
public void GetOrDefault_ItemExists_ReturnExistingItem()
{
}
}
}

@ -0,0 +1,19 @@
# First Release Profile
Do check mark include preferred when renaming
This score is negative [-1]
```
abc
```
# Second Release Profile
Do not check mark include preferred when renaming
This score is positive [1]
```
xyz
```

@ -0,0 +1,13 @@
# Test Release Profile
This score is negative [-1]
```
abc
```
This score is positive [0]
```
xyz
```

@ -0,0 +1,22 @@
### 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
```

@ -0,0 +1,89 @@
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using TestLibrary;
using Trash.Sonarr;
using Trash.Sonarr.ReleaseProfile;
namespace Trash.Tests.Sonarr.Guide
{
[TestFixture]
public class ReleaseProfileParserTest
{
private class Context
{
public Context()
{
Config = Substitute.For<SonarrConfiguration>();
GuideParser = new ReleaseProfileGuideParser();
}
public SonarrConfiguration Config { get; }
public ReleaseProfileGuideParser GuideParser { get; }
public TestData<ReleaseProfileParserTest> TestData { get; } = new();
}
[Test]
public void Parse_IgnoredRequiredPreferredScores()
{
var context = new Context();
context.Config.ReleaseProfiles.Add(new ReleaseProfileConfig());
var markdown = context.TestData.GetResourceData("test_parse_markdown_complete_doc.md");
var results = context.GuideParser.ParseMarkdown(context.Config.ReleaseProfiles.First(), markdown);
results.Count.Should().Be(1);
var profile = results.First().Value;
profile.Ignored.Should().BeEquivalentTo("term2", "term3");
profile.Required.Should().BeEquivalentTo("term4");
profile.Preferred.Should().ContainKey(100).WhichValue.Should().BeEquivalentTo(new List<string> {"term1"});
}
[Test]
public void Parse_IncludePreferredWhenRenaming()
{
var context = new Context();
context.Config.ReleaseProfiles.Add(new ReleaseProfileConfig());
var markdown = context.TestData.GetResourceData("include_preferred_when_renaming.md");
var results = context.GuideParser.ParseMarkdown(context.Config.ReleaseProfiles.First(), markdown);
results.Should()
.ContainKey("First Release Profile")
.WhichValue.IncludePreferredWhenRenaming.Should()
.Be(true);
results.Should()
.ContainKey("Second Release Profile")
.WhichValue.IncludePreferredWhenRenaming.Should()
.Be(false);
}
[Test]
public void Parse_StrictNegativeScores()
{
var context = new Context();
context.Config.ReleaseProfiles.Add(new ReleaseProfileConfig
{
// Pretend the user specified this option for testing purposes
StrictNegativeScores = true
});
var markdown = context.TestData.GetResourceData("strict_negative_scores.md");
var results = context.GuideParser.ParseMarkdown(context.Config.ReleaseProfiles.First(), markdown);
results.Should()
.ContainKey("Test Release Profile")
.WhichValue.Should()
.BeEquivalentTo(new
{
Required = new { },
Ignored = new List<string> {"abc"},
Preferred = new Dictionary<int, List<string>> {{0, new List<string> {"xyz"}}}
});
}
}
}

@ -0,0 +1,43 @@
using NSubstitute;
using NUnit.Framework;
using Trash.Sonarr;
using Trash.Sonarr.Api;
using Trash.Sonarr.ReleaseProfile;
namespace Trash.Tests.Sonarr
{
[TestFixture]
public class ReleaseProfileUpdaterTest
{
[Test]
public void ProcessReleaseProfile_InvalidReleaseProfiles_NoCrashNoCalls()
{
var args = Substitute.For<ISonarrCommand>();
var parser = Substitute.For<IReleaseProfileGuideParser>();
var api = Substitute.For<ISonarrApi>();
var config = Substitute.For<SonarrConfiguration>();
var logic = new ReleaseProfileUpdater(parser, api);
logic.Process(args, config);
parser.DidNotReceive().GetMarkdownData(Arg.Any<ReleaseProfileType>());
}
[Test]
public void ProcessReleaseProfile_SingleProfilePreview()
{
var parser = Substitute.For<IReleaseProfileGuideParser>();
var api = Substitute.For<ISonarrApi>();
var config = Substitute.For<SonarrConfiguration>();
var args = Substitute.For<ISonarrCommand>();
parser.GetMarkdownData(ReleaseProfileType.Anime).Returns("theMarkdown");
config.ReleaseProfiles.Add(new ReleaseProfileConfig {Type = ReleaseProfileType.Anime});
var updater = new ReleaseProfileUpdater(parser, api);
updater.Process(args, config);
parser.Received().ParseMarkdown(config.ReleaseProfiles[0], "theMarkdown");
}
}
}

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TestLibrary\TestLibrary.csproj" />
<ProjectReference Include="..\Trash\Trash.csproj" />
</ItemGroup>
</Project>

@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using Flurl.Http;
using Flurl.Http.Configuration;
using JetBrains.Annotations;
using Newtonsoft.Json;
using Serilog;
using YamlDotNet.Core;
namespace Trash.Command
{
public abstract class BaseCommand : ICommand, IBaseCommand
{
[CommandOption("preview", 'p', Description =
"Only display the processed markdown results without making any API calls.")]
public bool Preview { get; [UsedImplicitly] set; } = false;
[CommandOption("debug", 'd', Description =
"Display additional logs useful for development/debug purposes.")]
public bool Debug { get; [UsedImplicitly] set; } = false;
[CommandOption("config", 'c', Description =
"One or more YAML config files to use. All configs will be used and settings are additive. " +
"If not specified, the script will look for `trash.yml` in the same directory as the executable.")]
public List<string> Config { get; [UsedImplicitly] set; } =
new() {Path.Join(AppContext.BaseDirectory, "trash.yml")};
public async ValueTask ExecuteAsync(IConsole console)
{
SetupLogging();
SetupHttp();
try
{
await Process();
}
catch (YamlException e)
{
var inner = e.InnerException;
if (inner == null)
{
throw;
}
Log.Error("Found Unrecognized YAML Property: {ErrorMsg}", inner.Message);
Log.Error("Please remove the property quoted in the above message from your YAML file");
throw new CommandException("Exiting due to invalid configuration");
}
catch (Exception e) when (e is not CommandException)
{
Log.Error(e, "Unrecoverable Exception");
ExitDueToFailure();
}
}
private static void SetupHttp()
{
FlurlHttp.Configure(settings =>
{
var jsonSettings = new JsonSerializerSettings
{
// This is important. If any DTOs are missing members, say, if Radarr or Sonarr adds one in a future
// version, this needs to fail to indicate that a software change is required. Otherwise, we lose
// state between when we request settings, and re-apply them again with a few properties modified.
MissingMemberHandling = MissingMemberHandling.Error
};
settings.JsonSerializer = new NewtonsoftJsonSerializer(jsonSettings);
});
}
public abstract Task Process();
private void SetupLogging()
{
var logConfig = new LoggerConfiguration();
if (Debug)
{
logConfig.MinimumLevel.Debug();
}
else
{
logConfig.MinimumLevel.Information();
}
const string template = "[{Level:u3}] {Message:lj}{NewLine}{Exception}";
Log.Logger = logConfig.WriteTo.Console(outputTemplate: template).CreateLogger();
}
protected static void ExitDueToFailure()
{
throw new CommandException("Exiting due to previous exception");
}
}
}

@ -0,0 +1,8 @@
namespace Trash.Command
{
public enum ExitCode
{
Success = 0,
Failure = 1
}
}

@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace Trash.Command
{
public interface IBaseCommand
{
bool Preview { get; }
bool Debug { get; }
List<string>? Config { get; }
}
}

@ -0,0 +1,99 @@
using System.IO.Abstractions;
using System.Reflection;
using Autofac;
using Trash.Command;
using Trash.Config;
using Trash.Radarr.Api;
using Trash.Radarr.QualityDefinition;
using Trash.Sonarr.Api;
using Trash.Sonarr.QualityDefinition;
using Trash.Sonarr.ReleaseProfile;
using YamlDotNet.Serialization;
namespace Trash
{
public static class CompositionRoot
{
// private static void SetupMediator(ContainerBuilder builder)
// {
// builder
// .RegisterType<Mediator>()
// .As<IMediator>()
// .InstancePerLifetimeScope();
//
// builder.Register<ServiceFactory>(context =>
// {
// var c = context.Resolve<IComponentContext>();
// return t => c.Resolve(t);
// });
//
// builder.RegisterAssemblyTypes(typeof(CompositionRoot).GetTypeInfo().Assembly).AsImplementedInterfaces();
// }
// private static void RegisterConfiguration<T>(ContainerBuilder builder)
// where T : BaseConfiguration
// {
//
// builder.Register(ctx =>
// {
// var selector = ctx.Resolve<IConfigurationProvider<T>>();
// if (selector.ActiveConfiguration == null)
// {
// // If this exception is thrown, that means that a BaseCommand subclass has not implemented the
// // appropriate logic to set the active configuration via an IConfigurationSelector.
// throw new InvalidOperationException("No valid configuration has been selected");
// }
//
// return selector.ActiveConfiguration;
// })
// .As<BaseConfiguration>()
// .AsSelf();
// }
private static void SonarrRegistrations(ContainerBuilder builder)
{
builder.RegisterType<SonarrApi>().As<ISonarrApi>();
// Release Profile Support
builder.RegisterType<ReleaseProfileUpdater>();
builder.RegisterType<ReleaseProfileGuideParser>().As<IReleaseProfileGuideParser>();
// Quality Definition Support
builder.RegisterType<SonarrQualityDefinitionUpdater>();
builder.RegisterType<SonarrQualityDefinitionGuideParser>().As<ISonarrQualityDefinitionGuideParser>();
}
private static void RadarrRegistrations(ContainerBuilder builder)
{
builder.RegisterType<RadarrApi>().As<IRadarrApi>();
// Quality Definition Support
builder.RegisterType<RadarrQualityDefinitionUpdater>();
builder.RegisterType<RadarrQualityDefinitionGuideParser>().As<IRadarrQualityDefinitionGuideParser>();
}
public static IContainer Setup()
{
var builder = new ContainerBuilder();
builder.RegisterType<FileSystem>().As<IFileSystem>();
// Configuration
builder.RegisterType<ObjectFactory>().As<IObjectFactory>();
builder.RegisterGeneric(typeof(ConfigurationLoader<>)).As(typeof(IConfigurationLoader<>));
builder.RegisterGeneric(typeof(ConfigurationProvider<>))
.As(typeof(IConfigurationProvider<>))
.SingleInstance();
// Register all types deriving from BaseCommand. These are all of our supported subcommands.
builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
.Where(t => t.IsAssignableTo(typeof(IBaseCommand)));
SonarrRegistrations(builder);
RadarrRegistrations(builder);
// builder.RegisterSource(new AnyConcreteTypeNotAlreadyRegisteredSource());
return builder.Build();
}
}
}

@ -0,0 +1,10 @@
namespace Trash.Config
{
public abstract class BaseConfiguration
{
public string BaseUrl { get; init; } = "";
public string ApiKey { get; init; } = "";
public abstract string BuildUrl();
}
}

@ -0,0 +1,16 @@
using System;
namespace Trash.Config
{
public class ConfigurationException : Exception
{
public ConfigurationException(string propertyName, Type type)
{
PropertyName = propertyName;
Type = type;
}
public string PropertyName { get; }
public Type Type { get; }
}
}

@ -0,0 +1,74 @@
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using Trash.YamlDotNet;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Trash.Config
{
public class ConfigurationLoader<T> : IConfigurationLoader<T>
where T : BaseConfiguration
{
private readonly IConfigurationProvider<T> _configProvider;
private readonly IDeserializer _deserializer;
private readonly IFileSystem _fileSystem;
public ConfigurationLoader(IConfigurationProvider<T> configProvider, IFileSystem fileSystem,
IObjectFactory objectFactory)
{
_configProvider = configProvider;
_fileSystem = fileSystem;
_deserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
// .WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeConverter(new YamlNullableEnumTypeConverter())
.WithObjectFactory(objectFactory)
.Build();
}
public IEnumerable<T> Load(string configPath, string configSection)
{
using var stream = _fileSystem.File.OpenText(configPath);
return LoadFromStream(stream, configSection);
}
public IEnumerable<T> LoadFromStream(TextReader stream, string configSection)
{
var parser = new Parser(stream);
parser.Consume<StreamStart>();
parser.Consume<DocumentStart>();
parser.Consume<MappingStart>();
var configs = new List<T>();
while (parser.TryConsume<Scalar>(out var key))
{
if (key.Value == configSection)
{
configs = _deserializer.Deserialize<List<T>>(parser);
}
parser.SkipThisAndNestedEvents();
}
if (configs.Count == 0)
{
throw new ConfigurationException(configSection, typeof(T));
}
return configs;
}
public IEnumerable<T> LoadMany(IEnumerable<string> configFiles, string configSection)
{
foreach (var config in configFiles.SelectMany(file => Load(file, configSection)))
{
_configProvider.ActiveConfiguration = config;
yield return config;
}
}
}
}

@ -0,0 +1,8 @@
namespace Trash.Config
{
internal class ConfigurationProvider<T> : IConfigurationProvider<T>
where T : BaseConfiguration
{
public T? ActiveConfiguration { get; set; }
}
}

@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.IO;
namespace Trash.Config
{
public interface IConfigurationLoader<out T>
where T : BaseConfiguration
{
IEnumerable<T> Load(string propertyName, string configSection);
IEnumerable<T> LoadFromStream(TextReader stream, string configSection);
IEnumerable<T> LoadMany(IEnumerable<string> configFiles, string configSection);
}
}

@ -0,0 +1,8 @@
namespace Trash.Config
{
public interface IConfigurationProvider<T>
where T : BaseConfiguration
{
T? ActiveConfiguration { get; set; }
}
}

@ -0,0 +1,23 @@
using System;
using Autofac;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.ObjectFactories;
namespace Trash.Config
{
public class ObjectFactory : IObjectFactory
{
private readonly ILifetimeScope _container;
private readonly DefaultObjectFactory _defaultFactory = new();
public ObjectFactory(ILifetimeScope container)
{
_container = container;
}
public object Create(Type type)
{
return _container.IsRegistered(type) ? _container.Resolve(type) : _defaultFactory.Create(type);
}
}
}

@ -0,0 +1,31 @@
using System.Collections.Generic;
namespace Trash.Extensions
{
public static class DictionaryExtensions
{
public static TValue GetOrCreate<TKey, TValue>(this IDictionary<TKey, TValue> dict, TKey key)
where TValue : new()
{
if (!dict.TryGetValue(key, out var val))
{
val = new TValue();
dict.Add(key, val);
}
return val;
}
public static TValue GetOrDefault<TKey, TValue>(this IDictionary<TKey, TValue> dict, TKey key)
where TValue : struct
{
if (!dict.TryGetValue(key, out var val))
{
val = default;
dict.Add(key, val);
}
return val;
}
}
}

@ -0,0 +1,33 @@
using System;
using System.Globalization;
namespace Trash.Extensions
{
public static class StringExtensions
{
public static bool ContainsIgnoreCase(this string value, string searchFor)
{
return value.Contains(searchFor, StringComparison.OrdinalIgnoreCase);
}
public static bool EqualsIgnoreCase(this string value, string matchThis)
{
return value.Equals(matchThis, StringComparison.OrdinalIgnoreCase);
}
public static float ToFloat(this string value)
{
return float.Parse(value, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat);
}
public static decimal ToDecimal(this string value)
{
return decimal.Parse(value, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat);
}
public static string FormatWith(this string value, params object[] args)
{
return string.Format(value, args);
}
}
}

@ -0,0 +1,22 @@
using JetBrains.Annotations;
using YamlDotNet.Serialization;
namespace Trash.Extensions
{
public static class YamlDotNetExtensions
{
public static T? DeserializeType<T>(this IDeserializer deserializer, string data)
where T : class
{
var extractor = deserializer.Deserialize<RootExtractor<T>>(data);
return extractor.RootObject;
}
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
private class RootExtractor<T>
where T : class
{
public T? RootObject { get; }
}
}
}

@ -0,0 +1,23 @@
using System.Threading.Tasks;
using Autofac;
using CliFx;
namespace Trash
{
internal static class Program
{
private static IContainer? _container;
public static async Task<int> Main()
{
_container = CompositionRoot.Setup();
return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.SetExecutableName(ThisAssembly.AssemblyName)
.SetVersion(ThisAssembly.AssemblyInformationalVersion)
.UseTypeActivator(type => _container.Resolve(type))
.Build()
.RunAsync();
}
}
}

@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Trash.Radarr.Api.Objects;
namespace Trash.Radarr.Api
{
public interface IRadarrApi
{
Task<List<RadarrQualityDefinitionItem>> GetQualityDefinition();
Task<List<RadarrQualityDefinitionItem>> UpdateQualityDefinition(List<RadarrQualityDefinitionItem> newQuality);
}
}

@ -0,0 +1,26 @@
using JetBrains.Annotations;
namespace Trash.Radarr.Api.Objects
{
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class RadarrQualityItem
{
public int Id { get; set; }
public string Modifier { get; set; } = "";
public string Name { get; set; } = "";
public string Source { get; set; } = "";
public int Resolution { get; set; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class RadarrQualityDefinitionItem
{
public int Id { get; set; }
public RadarrQualityItem? Quality { get; set; }
public string Title { get; set; } = "";
public int Weight { get; set; }
public decimal MinSize { get; set; }
public decimal MaxSize { get; set; }
public decimal PreferredSize { get; set; }
}
}

@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Flurl;
using Flurl.Http;
using Trash.Config;
using Trash.Radarr.Api.Objects;
namespace Trash.Radarr.Api
{
public class RadarrApi : IRadarrApi
{
private readonly IConfigurationProvider<RadarrConfiguration> _config;
public RadarrApi(IConfigurationProvider<RadarrConfiguration> config)
{
_config = config;
}
public async Task<List<RadarrQualityDefinitionItem>> GetQualityDefinition()
{
return await BaseUrl()
.AppendPathSegment("qualitydefinition")
.GetJsonAsync<List<RadarrQualityDefinitionItem>>();
}
public async Task<List<RadarrQualityDefinitionItem>> UpdateQualityDefinition(
List<RadarrQualityDefinitionItem> newQuality)
{
return await BaseUrl()
.AppendPathSegment("qualityDefinition/update")
.PutJsonAsync(newQuality)
.ReceiveJson<List<RadarrQualityDefinitionItem>>();
}
private string BaseUrl()
{
if (_config.ActiveConfiguration == null)
{
throw new InvalidOperationException("No active configuration available for API method");
}
return _config.ActiveConfiguration.BuildUrl();
}
}
}

@ -0,0 +1,8 @@
using Trash.Command;
namespace Trash.Radarr
{
public interface IRadarrCommand : IBaseCommand
{
}
}

@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Trash.Radarr.QualityDefinition
{
public interface IRadarrQualityDefinitionGuideParser
{
Task<string> GetMarkdownData();
IDictionary<RadarrQualityDefinitionType, List<RadarrQualityData>> ParseMarkdown(string markdown);
}
}

@ -0,0 +1,10 @@
namespace Trash.Radarr.QualityDefinition
{
public class RadarrQualityData
{
public string Name { get; set; } = "";
public decimal Min { get; set; }
public decimal Max { get; set; }
public decimal Preferred { get; set; }
}
}

@ -0,0 +1,71 @@
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Flurl.Http;
using Trash.Extensions;
namespace Trash.Radarr.QualityDefinition
{
public class RadarrQualityDefinitionGuideParser : IRadarrQualityDefinitionGuideParser
{
private readonly Regex _regexHeader = new(@"^#+", RegexOptions.Compiled);
private readonly Regex _regexTableRow =
new(@"\| *(.*?) *\| *([\d.]+) *\| *([\d.]+) *\|", RegexOptions.Compiled);
public async Task<string> GetMarkdownData()
{
return await
"https://raw.githubusercontent.com/TRaSH-/Guides/master/docs/Radarr/V3/Radarr-Quality-Settings-File-Size.md"
.GetStringAsync();
}
public IDictionary<RadarrQualityDefinitionType, List<RadarrQualityData>> ParseMarkdown(string markdown)
{
var results = new Dictionary<RadarrQualityDefinitionType, List<RadarrQualityData>>();
List<RadarrQualityData>? table = null;
var reader = new StringReader(markdown);
for (var line = reader.ReadLine(); line != null; line = reader.ReadLine())
{
if (string.IsNullOrEmpty(line))
{
continue;
}
var match = _regexHeader.Match(line);
if (match.Success)
{
// todo: hard-coded for now since there's only one supported right now.
var type = RadarrQualityDefinitionType.Movie;
table = results.GetOrCreate(type);
// If we grab a table that isn't empty, that means for whatever reason *another* table
// in the markdown is trying to modify a previous table's data. For example, maybe there
// are two "Series" quality tables. That would be a weird edge case, but handle that
// here just in case.
if (table.Count > 0)
{
table = null;
}
}
else if (table != null)
{
match = _regexTableRow.Match(line);
if (match.Success)
{
table.Add(new RadarrQualityData
{
Name = match.Groups[1].Value,
Min = match.Groups[2].Value.ToDecimal(),
Max = match.Groups[3].Value.ToDecimal()
});
}
}
}
return results;
}
}
}

@ -0,0 +1,7 @@
namespace Trash.Radarr.QualityDefinition
{
public enum RadarrQualityDefinitionType
{
Movie
}
}

@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Serilog;
using Trash.Radarr.Api;
using Trash.Radarr.Api.Objects;
namespace Trash.Radarr.QualityDefinition
{
public class RadarrQualityDefinitionUpdater
{
private readonly IRadarrApi _api;
private readonly IRadarrQualityDefinitionGuideParser _parser;
public RadarrQualityDefinitionUpdater(IRadarrQualityDefinitionGuideParser parser, IRadarrApi api)
{
_parser = parser;
_api = api;
}
private static void PrintQualityPreview(IEnumerable<RadarrQualityData> quality)
{
Console.WriteLine("");
const string format = "{0,-20} {1,-10} {2,-10} {3,-10}";
Console.WriteLine(format, "Quality", "Min", "Max", "Preferred");
Console.WriteLine(format, "-------", "---", "---", "---------");
foreach (var q in quality)
{
Console.WriteLine(format, q.Name, q.Min, q.Max, q.Preferred);
}
Console.WriteLine("");
}
public async Task Process(IRadarrCommand args, RadarrConfiguration config)
{
Log.Information("Processing Quality Definition: {QualityDefinition}", config.QualityDefinition!.Type);
var qualityDefinitions = _parser.ParseMarkdown(await _parser.GetMarkdownData());
var selectedQuality = qualityDefinitions[config.QualityDefinition!.Type];
// Fix an out of range ratio and warn the user
if (config.QualityDefinition.PreferredRatio is < 0 or > 1)
{
var clampedRatio = Math.Clamp(config.QualityDefinition.PreferredRatio, 0, 1);
Log.Warning("Your `preferred_ratio` of {CurrentRatio} is out of range. " +
"It must be a decimal between 0.0 and 1.0. It has been clamped to {ClampedRatio}",
config.QualityDefinition.PreferredRatio, clampedRatio);
config.QualityDefinition.PreferredRatio = clampedRatio;
}
// Apply a calculated preferred size
foreach (var quality in selectedQuality)
{
quality.Preferred =
Math.Round(quality.Min + (quality.Max - quality.Min) * config.QualityDefinition.PreferredRatio, 1);
}
if (args.Preview)
{
PrintQualityPreview(selectedQuality);
return;
}
await ProcessQualityDefinition(selectedQuality);
}
private async Task ProcessQualityDefinition(IEnumerable<RadarrQualityData> guideQuality)
{
var serverQuality = await _api.GetQualityDefinition();
await UpdateQualityDefinition(serverQuality, guideQuality);
}
private async Task UpdateQualityDefinition(IReadOnlyCollection<RadarrQualityDefinitionItem> serverQuality,
IEnumerable<RadarrQualityData> guideQuality)
{
static bool QualityIsDifferent(RadarrQualityDefinitionItem a, RadarrQualityData b)
{
const decimal tolerance = 0.1m;
return
Math.Abs(a.MaxSize - b.Max) > tolerance ||
Math.Abs(a.MinSize - b.Min) > tolerance ||
Math.Abs(a.PreferredSize - b.Preferred) > tolerance;
}
var newQuality = new List<RadarrQualityDefinitionItem>();
foreach (var qualityData in guideQuality)
{
var entry = serverQuality.FirstOrDefault(q => q.Quality?.Name == qualityData.Name);
if (entry == null)
{
Log.Warning("Server lacks quality definition for {Quality}; it will be skipped", qualityData.Name);
continue;
}
if (!QualityIsDifferent(entry, qualityData))
{
continue;
}
// Not using the original list again, so it's OK to modify the definition reftype objects in-place.
entry.MinSize = qualityData.Min;
entry.MaxSize = qualityData.Max;
entry.PreferredSize = qualityData.Preferred;
newQuality.Add(entry);
Log.Debug("Setting Quality " +
"[Name: {Name}] [Source: {Source}] [Min: {Min}] [Max: {Max}] [Preferred: {Preferred}]",
entry.Quality?.Name, entry.Quality?.Source, entry.MinSize, entry.MaxSize, entry.PreferredSize);
}
await _api.UpdateQualityDefinition(newQuality);
Log.Information("Number of updated qualities: {Count}", newQuality.Count);
}
}
}

@ -0,0 +1,49 @@
using System;
using System.Threading.Tasks;
using CliFx.Attributes;
using Flurl.Http;
using JetBrains.Annotations;
using Serilog;
using Trash.Command;
using Trash.Config;
using Trash.Radarr.QualityDefinition;
namespace Trash.Radarr
{
[Command("radarr", Description = "Perform operations on a Radarr instance")]
[UsedImplicitly]
public class RadarrCommand : BaseCommand, IRadarrCommand
{
private readonly IConfigurationLoader<RadarrConfiguration> _configLoader;
private readonly Func<RadarrQualityDefinitionUpdater> _qualityUpdaterFactory;
public RadarrCommand(
IConfigurationLoader<RadarrConfiguration> configLoader,
Func<RadarrQualityDefinitionUpdater> qualityUpdaterFactory)
{
_configLoader = configLoader;
_qualityUpdaterFactory = qualityUpdaterFactory;
}
// todo: Add options to exclude parts of YAML on the fly?
public override async Task Process()
{
try
{
foreach (var config in _configLoader.LoadMany(Config, "radarr"))
{
if (config.QualityDefinition != null)
{
await _qualityUpdaterFactory().Process(this, config);
}
}
}
catch (FlurlHttpException e)
{
Log.Error(e, "HTTP error while communicating with Radarr");
ExitDueToFailure();
}
}
}
}

@ -0,0 +1,27 @@
using Flurl;
using JetBrains.Annotations;
using Trash.Config;
using Trash.Radarr.QualityDefinition;
namespace Trash.Radarr
{
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class RadarrConfiguration : BaseConfiguration
{
public QualityDefinitionConfig? QualityDefinition { get; init; }
public override string BuildUrl()
{
return BaseUrl
.AppendPathSegment("api/v3")
.SetQueryParams(new {apikey = ApiKey});
}
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityDefinitionConfig
{
public RadarrQualityDefinitionType Type { get; init; }
public decimal PreferredRatio { get; set; } = 1.0m;
}
}

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Trash.Sonarr.Api.Objects;
namespace Trash.Sonarr.Api
{
public interface ISonarrApi
{
Task<Version> GetVersion();
Task<List<SonarrTag>> GetTags();
Task<SonarrTag> CreateTag(string tag);
Task<List<SonarrReleaseProfile>> GetReleaseProfiles();
Task UpdateReleaseProfile(SonarrReleaseProfile profileToUpdate);
Task<SonarrReleaseProfile> CreateReleaseProfile(SonarrReleaseProfile newProfile);
Task<List<SonarrQualityDefinitionItem>> GetQualityDefinition();
Task<List<SonarrQualityDefinitionItem>> UpdateQualityDefinition(List<SonarrQualityDefinitionItem> newQuality);
}
}

@ -0,0 +1,24 @@
using JetBrains.Annotations;
namespace Trash.Sonarr.Api.Objects
{
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class SonarrQualityItem
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Source { get; set; } = "";
public int Resolution { get; set; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class SonarrQualityDefinitionItem
{
public int Id { get; set; }
public SonarrQualityItem? Quality { get; set; }
public string Title { get; set; } = "";
public int Weight { get; set; }
public decimal MinSize { get; set; }
public decimal MaxSize { get; set; }
}
}

@ -0,0 +1,36 @@
using System.Collections.Generic;
using JetBrains.Annotations;
using Newtonsoft.Json;
namespace Trash.Sonarr.Api.Objects
{
[UsedImplicitly(ImplicitUseKindFlags.Assign, ImplicitUseTargetFlags.Members)]
public class SonarrPreferredTerm
{
public SonarrPreferredTerm(int score, string term)
{
Term = term;
Score = score;
}
[JsonProperty("key")]
public string Term { get; set; }
[JsonProperty("value")]
public int Score { get; set; }
}
[UsedImplicitly(ImplicitUseKindFlags.Assign, ImplicitUseTargetFlags.Members)]
public class SonarrReleaseProfile
{
public int Id { get; set; }
public bool Enabled { get; set; }
public string Name { get; set; } = "";
public string Required { get; set; } = "";
public string Ignored { get; set; } = "";
public List<SonarrPreferredTerm> Preferred { get; set; } = new();
public bool IncludePreferredWhenRenaming { get; set; }
public int IndexerId { get; set; }
public List<int> Tags { get; set; } = new();
}
}

@ -0,0 +1,8 @@
namespace Trash.Sonarr.Api.Objects
{
public class SonarrTag
{
public string Label { get; set; } = "";
public int Id { get; set; }
}
}

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Flurl;
using Flurl.Http;
using Trash.Config;
using Trash.Sonarr.Api.Objects;
namespace Trash.Sonarr.Api
{
public class SonarrApi : ISonarrApi
{
private readonly IConfigurationProvider<SonarrConfiguration> _config;
public SonarrApi(IConfigurationProvider<SonarrConfiguration> config)
{
_config = config;
}
private string BaseUrl()
{
if (_config.ActiveConfiguration == null)
{
throw new InvalidOperationException("No active configuration available for API method");
}
return _config.ActiveConfiguration.BuildUrl();
}
public async Task<Version> GetVersion()
{
dynamic data = await BaseUrl()
.AppendPathSegment("system/status")
.GetJsonAsync();
return new Version(data.version);
}
public async Task<List<SonarrTag>> GetTags()
{
return await BaseUrl()
.AppendPathSegment("tag")
.GetJsonAsync<List<SonarrTag>>();
}
public async Task<SonarrTag> CreateTag(string tag)
{
return await BaseUrl()
.AppendPathSegment("tag")
.PostJsonAsync(new {label = tag})
.ReceiveJson<SonarrTag>();
}
public async Task<List<SonarrReleaseProfile>> GetReleaseProfiles()
{
return await BaseUrl()
.AppendPathSegment("releaseprofile")
.GetJsonAsync<List<SonarrReleaseProfile>>();
}
public async Task UpdateReleaseProfile(SonarrReleaseProfile profileToUpdate)
{
await BaseUrl()
.AppendPathSegment($"releaseprofile/{profileToUpdate.Id}")
.PutJsonAsync(profileToUpdate);
}
public async Task<SonarrReleaseProfile> CreateReleaseProfile(SonarrReleaseProfile newProfile)
{
return await BaseUrl()
.AppendPathSegment("releaseprofile")
.PostJsonAsync(newProfile)
.ReceiveJson<SonarrReleaseProfile>();
}
public async Task<List<SonarrQualityDefinitionItem>> GetQualityDefinition()
{
return await BaseUrl()
.AppendPathSegment("qualitydefinition")
.GetJsonAsync<List<SonarrQualityDefinitionItem>>();
}
public async Task<List<SonarrQualityDefinitionItem>> UpdateQualityDefinition(
List<SonarrQualityDefinitionItem> newQuality)
{
return await BaseUrl()
.AppendPathSegment("qualityDefinition/update")
.PutJsonAsync(newQuality)
.ReceiveJson<List<SonarrQualityDefinitionItem>>();
}
}
}

@ -0,0 +1,8 @@
using Trash.Command;
namespace Trash.Sonarr
{
public interface ISonarrCommand : IBaseCommand
{
}
}

@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Trash.Sonarr.QualityDefinition
{
public interface ISonarrQualityDefinitionGuideParser
{
Task<string> GetMarkdownData();
IDictionary<SonarrQualityDefinitionType, List<SonarrQualityData>> ParseMarkdown(string markdown);
}
}

@ -0,0 +1,9 @@
namespace Trash.Sonarr.QualityDefinition
{
public class SonarrQualityData
{
public string Name { get; set; } = "";
public decimal Min { get; set; }
public decimal Max { get; set; }
}
}

@ -0,0 +1,73 @@
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Flurl.Http;
using Trash.Extensions;
namespace Trash.Sonarr.QualityDefinition
{
public class SonarrQualityDefinitionGuideParser : ISonarrQualityDefinitionGuideParser
{
private readonly Regex _regexHeader = new(@"^#+", RegexOptions.Compiled);
private readonly Regex _regexTableRow =
new(@"\| *(.*?) *\| *([\d.]+) *\| *([\d.]+) *\|", RegexOptions.Compiled);
public async Task<string> GetMarkdownData()
{
return await
"https://raw.githubusercontent.com/TRaSH-/Guides/master/docs/Sonarr/V3/Sonarr-Quality-Settings-File-Size.md"
.GetStringAsync();
}
public IDictionary<SonarrQualityDefinitionType, List<SonarrQualityData>> ParseMarkdown(string markdown)
{
var results = new Dictionary<SonarrQualityDefinitionType, List<SonarrQualityData>>();
List<SonarrQualityData>? table = null;
var reader = new StringReader(markdown);
for (var line = reader.ReadLine(); line != null; line = reader.ReadLine())
{
if (string.IsNullOrEmpty(line))
{
continue;
}
var match = _regexHeader.Match(line);
if (match.Success)
{
var type = line.ContainsIgnoreCase("anime")
? SonarrQualityDefinitionType.Anime
: SonarrQualityDefinitionType.Series;
table = results.GetOrCreate(type);
// If we grab a table that isn't empty, that means for whatever reason *another* table
// in the markdown is trying to modify a previous table's data. For example, maybe there
// are two "Series" quality tables. That would be a weird edge case, but handle that
// here just in case.
if (table.Count > 0)
{
table = null;
}
}
else if (table != null)
{
match = _regexTableRow.Match(line);
if (match.Success)
{
table.Add(new SonarrQualityData
{
Name = match.Groups[1].Value,
Min = match.Groups[2].Value.ToDecimal(),
Max = match.Groups[3].Value.ToDecimal()
});
}
}
}
return results;
}
}
}

@ -0,0 +1,9 @@
namespace Trash.Sonarr.QualityDefinition
{
public enum SonarrQualityDefinitionType
{
Anime,
Series,
Hybrid
}
}

@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Serilog;
using Trash.Sonarr.Api;
using Trash.Sonarr.Api.Objects;
namespace Trash.Sonarr.QualityDefinition
{
public class SonarrQualityDefinitionUpdater
{
private readonly ISonarrApi _api;
private readonly ISonarrQualityDefinitionGuideParser _parser;
private readonly Regex _regexHybrid = new(@"720|1080", RegexOptions.Compiled);
public SonarrQualityDefinitionUpdater(ISonarrQualityDefinitionGuideParser parser, ISonarrApi api)
{
_parser = parser;
_api = api;
}
private List<SonarrQualityData> BuildHybridQuality(List<SonarrQualityData> anime,
List<SonarrQualityData> series)
{
// todo Verify anime & series are the same length? Probably not, because we might not care about some rows anyway.
Log.Information(
"Notice: Hybrid only functions on 720/1080 qualities and uses non-anime values for the rest (e.g. 2160)");
var hybrid = new List<SonarrQualityData>();
foreach (var left in series)
{
// Any qualities that anime doesn't care about get immediately added from Series quality
var match = _regexHybrid.Match(left.Name);
if (!match.Success)
{
Log.Debug("Using 'Series' Quality For: {QualityName}", left.Name);
hybrid.Add(left);
continue;
}
// If there's a quality in Series that Anime doesn't know about, we add the Series quality
var right = anime.FirstOrDefault(row => row.Name == left.Name);
if (right == null)
{
Log.Error("Could not find matching anime quality for series quality named {QualityName}",
left.Name);
hybrid.Add(left);
continue;
}
hybrid.Add(new SonarrQualityData
{
Name = left.Name,
Min = Math.Min(left.Min, right.Min),
Max = Math.Max(left.Max, right.Max)
});
}
return hybrid;
}
private static void PrintQualityPreview(IEnumerable<SonarrQualityData> quality)
{
Console.WriteLine("");
const string format = "{0,-20} {1,-10} {2,-10}";
Console.WriteLine(format, "Quality", "Min", "Max");
Console.WriteLine(format, "-------", "---", "---");
foreach (var q in quality)
{
Console.WriteLine(format, q.Name, q.Min, q.Max);
}
Console.WriteLine("");
}
public async Task Process(ISonarrCommand args, SonarrConfiguration config)
{
Log.Information("Processing Quality Definition: {QualityDefinition}", config.QualityDefinition);
var qualityDefinitions = _parser.ParseMarkdown(await _parser.GetMarkdownData());
List<SonarrQualityData> selectedQuality;
if (config.QualityDefinition == SonarrQualityDefinitionType.Hybrid)
{
selectedQuality = BuildHybridQuality(qualityDefinitions[SonarrQualityDefinitionType.Anime],
qualityDefinitions[SonarrQualityDefinitionType.Series]);
}
else
{
selectedQuality = qualityDefinitions[config.QualityDefinition!.Value];
}
if (args.Preview)
{
PrintQualityPreview(selectedQuality);
return;
}
await ProcessQualityDefinition(selectedQuality);
}
private async Task ProcessQualityDefinition(IEnumerable<SonarrQualityData> guideQuality)
{
var serverQuality = await _api.GetQualityDefinition();
await UpdateQualityDefinition(serverQuality, guideQuality);
}
private async Task UpdateQualityDefinition(IReadOnlyCollection<SonarrQualityDefinitionItem> serverQuality,
IEnumerable<SonarrQualityData> guideQuality)
{
static bool QualityIsDifferent(SonarrQualityDefinitionItem a, SonarrQualityData b)
{
const decimal tolerance = 0.1m;
return
Math.Abs(a.MaxSize - b.Max) > tolerance ||
Math.Abs(a.MinSize - b.Min) > tolerance;
}
// var newQuality = serverQuality.Where(q => guideQuality.Any(gq => gq.Name == q.Quality.Name));
var newQuality = new List<SonarrQualityDefinitionItem>();
foreach (var qualityData in guideQuality)
{
var entry = serverQuality.FirstOrDefault(q => q.Quality?.Name == qualityData.Name);
if (entry == null)
{
Log.Warning("Server lacks quality definition for {Quality}; it will be skipped", qualityData.Name);
continue;
}
if (!QualityIsDifferent(entry, qualityData))
{
continue;
}
// Not using the original list again, so it's OK to modify the definition reftype objects in-place.
entry.MinSize = qualityData.Min;
entry.MaxSize = qualityData.Max;
newQuality.Add(entry);
Log.Debug("Setting Quality [Name: {Name}] [Min: {Min}] [Max: {Max}]",
entry.Quality?.Name, entry.MinSize, entry.MaxSize);
}
await _api.UpdateQualityDefinition(newQuality);
Log.Information("Number of updated qualities: {Count}", newQuality.Count);
}
}
}

@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Trash.Sonarr.ReleaseProfile
{
public interface IReleaseProfileGuideParser
{
Task<string> GetMarkdownData(ReleaseProfileType profileName);
IDictionary<string, ProfileData> ParseMarkdown(ReleaseProfileConfig config, string markdown);
}
}

@ -0,0 +1,17 @@
using System.Collections.Generic;
namespace Trash.Sonarr.ReleaseProfile
{
public class ProfileData
{
public List<string> Required { get; } = new();
public List<string> Ignored { get; } = new();
public Dictionary<int, List<string>> Preferred { get; } = new();
// We use 'null' 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, or if we modify the existing setting for
// existing profiles on the server.
public bool? IncludePreferredWhenRenaming { get; set; }
}
}

@ -0,0 +1,261 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Flurl;
using Flurl.Http;
using Serilog;
using Trash.Extensions;
namespace Trash.Sonarr.ReleaseProfile
{
public class ReleaseProfileGuideParser : IReleaseProfileGuideParser
{
private readonly Dictionary<ReleaseProfileType, string> _markdownDocNames = new()
{
{ReleaseProfileType.Anime, "Sonarr-Release-Profile-RegEx-Anime"},
{ReleaseProfileType.Series, "Sonarr-Release-Profile-RegEx"}
};
private readonly (TermCategory, Regex)[] _regexCategories =
{
(TermCategory.Required, BuildRegex(@"must contain")),
(TermCategory.Ignored, BuildRegex(@"must not contain")),
(TermCategory.Preferred, BuildRegex(@"preferred"))
};
private readonly Regex _regexHeader = new(@"^(#+)\s([\w\s\d]+)\s*$", RegexOptions.Compiled);
private readonly Regex _regexHeaderReleaseProfile = BuildRegex(@"release profile");
private readonly Regex _regexScore = BuildRegex(@"score.*?\[(-?[\d]+)\]");
public async Task<string> GetMarkdownData(ReleaseProfileType profileName)
{
return await BuildUrl(profileName).GetStringAsync();
}
public IDictionary<string, ProfileData> ParseMarkdown(ReleaseProfileConfig config, string markdown)
{
var results = new Dictionary<string, ProfileData>();
var state = new ParserState();
var reader = new StringReader(markdown);
for (var line = reader.ReadLine(); line != null; line = reader.ReadLine())
{
if (string.IsNullOrEmpty(line))
{
continue;
}
// 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.BracketDepth = 1 - state.BracketDepth;
continue;
}
// Not inside brackets
if (state.BracketDepth == 0)
{
ParseMarkdownOutsideFence(line, state, results);
}
// Inside brackets
else if (state.BracketDepth == 1)
{
if (!state.IsValid)
{
Log.Debug(" - !! Inside bracket with invalid state; skipping! " +
"[Profile Name: {ProfileName}] " +
"[Category: {Category}] " + "[Score: {Score}] " + "[Line: {Line}] ",
state.ProfileName,
state.CurrentCategory, state.Score, line);
}
else
{
ParseMarkdownInsideFence(config, line, state, results);
}
}
}
Log.Debug("\n");
return results;
}
private static Regex BuildRegex(string regex)
{
return new(regex, RegexOptions.Compiled | RegexOptions.IgnoreCase);
}
private Url BuildUrl(ReleaseProfileType profileName)
{
return "https://raw.githubusercontent.com/TRaSH-/Guides/master/docs/Sonarr/V3".AppendPathSegment(
$"{_markdownDocNames[profileName]}.md");
}
private void ParseMarkdownInsideFence(ReleaseProfileConfig config, string line, ParserState state,
IDictionary<string, ProfileData> results)
{
// ProfileName is verified for validity prior to this method being invoked.
// The actual check occurs in the call to ParserState.IsValid.
var profile = results.GetOrCreate(state.ProfileName!);
// Sometimes a comma is present at the end of these lines, 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.TrimEnd(',');
switch (state.CurrentCategory)
{
case TermCategory.Preferred:
{
Log.Debug(" + Capture Term " + "[Category: {CurrentCategory}] " + "[Score: {Score}] " +
"[Strict: {StrictNegativeScores}] " + "[Term: {Line}]", state.CurrentCategory,
state.Score,
config.StrictNegativeScores, line);
if (config.StrictNegativeScores && state.Score < 0)
{
profile.Ignored.Add(line);
}
else
{
// Score is already checked for null prior to the method being invoked.
var prefList = profile.Preferred.GetOrCreate(state.Score!.Value);
prefList.Add(line);
}
break;
}
case TermCategory.Ignored:
{
profile.Ignored.Add(line);
Log.Debug(" + Capture Term [Category: {Category}] [Term: {Line}]", state.CurrentCategory, line);
break;
}
case TermCategory.Required:
{
profile.Required.Add(line);
Log.Debug(" + Capture Term [Category: {Category}] [Term: {Line}]", state.CurrentCategory, line);
break;
}
default:
{
throw new ArgumentOutOfRangeException($"Unknown term category: {state.CurrentCategory}");
}
}
}
private void ParseMarkdownOutsideFence(string line, ParserState state, IDictionary<string, ProfileData> results)
{
// Header Processing
var match = _regexHeader.Match(line);
if (match.Success)
{
var headerDepth = match.Groups[1].Length;
var headerText = match.Groups[2].Value;
Log.Debug("> Parsing Header [Text: {HeaderText}] [Depth: {HeaderDepth}]", headerText, headerDepth);
// Profile name (always reset previous state here)
if (_regexHeaderReleaseProfile.Match(headerText).Success)
{
state.Reset();
state.ProfileName = headerText;
state.CurrentHeaderDepth = headerDepth;
Log.Debug(" - New Profile [Text: {HeaderText}]", headerText);
return;
}
if (headerDepth <= state.CurrentHeaderDepth)
{
Log.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 (string.IsNullOrEmpty(state.ProfileName))
{
return;
}
var profile = results.GetOrCreate(state.ProfileName);
if (line.ContainsIgnoreCase("include preferred"))
{
profile.IncludePreferredWhenRenaming = !line.ContainsIgnoreCase("not");
Log.Debug(" - 'Include Preferred' found [Value: {IncludePreferredWhenRenaming}] [Line: {Line}]",
profile.IncludePreferredWhenRenaming, 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.
var category = ParseCategory(line);
if (category != null)
{
state.CurrentCategory = category.Value;
Log.Debug(" - Category Set [Name: {Category}] [Line: {Line}]", category, line);
// DO NOT RETURN HERE!
// The category and score are sometimes in the same sentence (line); continue processing the line!
// return;
}
match = _regexScore.Match(line);
if (match.Success)
{
state.Score = int.Parse(match.Groups[1].Value);
Log.Debug(" - Score [Value: {Score}]", state.Score);
}
}
private TermCategory? ParseCategory(string line)
{
foreach (var (category, regex) in _regexCategories)
{
var match = regex.Match(line);
if (match.Success)
{
return category;
}
}
return null;
}
private enum TermCategory
{
Required,
Ignored,
Preferred
}
private class ParserState
{
public ParserState()
{
Reset();
}
public string? ProfileName { get; set; }
public int? Score { get; set; }
public TermCategory CurrentCategory { get; set; }
public int BracketDepth { get; set; }
public int CurrentHeaderDepth { get; set; }
public bool IsValid => ProfileName != null && (CurrentCategory != TermCategory.Preferred || Score != null);
public void Reset()
{
ProfileName = null;
Score = null;
CurrentCategory = TermCategory.Preferred;
BracketDepth = 0;
CurrentHeaderDepth = -1;
}
}
}
}

@ -0,0 +1,8 @@
namespace Trash.Sonarr.ReleaseProfile
{
public enum ReleaseProfileType
{
Anime,
Series
}
}

@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Exceptions;
using Serilog;
using Trash.Extensions;
using Trash.Sonarr.Api;
using Trash.Sonarr.Api.Objects;
namespace Trash.Sonarr.ReleaseProfile
{
public class ReleaseProfileUpdater
{
private readonly ISonarrApi _api;
private readonly IReleaseProfileGuideParser _parser;
public ReleaseProfileUpdater(IReleaseProfileGuideParser parser, ISonarrApi api)
{
_parser = parser;
_api = api;
}
private async Task DoVersionEnforcement()
{
// 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.
var minimumVersion = new Version("3.0.4.1098");
var version = await _api.GetVersion();
if (version < minimumVersion)
{
Log.Error("Your Sonarr version {CurrentVersion} does not meet the minimum " +
"required version of {MinimumVersion} to use this program", version, minimumVersion);
throw new CommandException("Exiting due to version incompatibility");
}
}
private async Task CreateMissingTags(ICollection<SonarrTag> sonarrTags, IEnumerable<string> configTags)
{
var missingTags = configTags.Where(t => !sonarrTags.Any(t2 => t2.Label.EqualsIgnoreCase(t)));
foreach (var tag in missingTags)
{
Log.Debug("Creating Tag: {Tag}", tag);
var newTag = await _api.CreateTag(tag);
sonarrTags.Add(newTag);
}
}
private string BuildProfileTitle(ReleaseProfileType profileType, string profileName)
{
var titleType = profileType.ToString();
return $"[Trash] {titleType} - {profileName}";
}
private static SonarrReleaseProfile? GetProfileToUpdate(List<SonarrReleaseProfile> profiles, string profileName)
{
return profiles.FirstOrDefault(p => p.Name == profileName);
}
private static void SetupProfileRequestObject(SonarrReleaseProfile profileToUpdate, ProfileData profile,
List<int> tagIds)
{
profileToUpdate.Preferred = profile.Preferred
.SelectMany(kvp => kvp.Value.Select(term => new SonarrPreferredTerm(kvp.Key, term)))
.ToList();
profileToUpdate.Ignored = string.Join(',', profile.Ignored);
profileToUpdate.Required = string.Join(',', profile.Required);
// Null means the guide didn't specify a value for this, so we leave the existing setting intact.
if (profile.IncludePreferredWhenRenaming != null)
{
profileToUpdate.IncludePreferredWhenRenaming = profile.IncludePreferredWhenRenaming.Value;
}
profileToUpdate.Tags = tagIds;
}
private async Task UpdateExistingProfile(SonarrReleaseProfile profileToUpdate, ProfileData profile,
List<int> tagIds)
{
Log.Debug("Update existing profile with id {ProfileId}", profileToUpdate.Id);
SetupProfileRequestObject(profileToUpdate, profile, tagIds);
await _api.UpdateReleaseProfile(profileToUpdate);
}
private async Task CreateNewProfile(string title, ProfileData profile, List<int> tagIds)
{
var newProfile = new SonarrReleaseProfile
{
Name = title,
Enabled = true
};
SetupProfileRequestObject(newProfile, profile, tagIds);
await _api.CreateReleaseProfile(newProfile);
}
private async Task ProcessReleaseProfiles(IDictionary<string, ProfileData> profiles,
ReleaseProfileConfig profile)
{
await DoVersionEnforcement();
List<int> tagIds = new();
// 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.
if (profile.Tags.Count > 0)
{
var sonarrTags = await _api.GetTags();
await CreateMissingTags(sonarrTags, profile.Tags);
tagIds = sonarrTags.Where(t => profile.Tags.Any(ct => ct.EqualsIgnoreCase(t.Label)))
.Select(t => t.Id)
.ToList();
}
// Obtain all of the existing release profiles first. If any were previously created by our program
// 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).
var existingProfiles = await _api.GetReleaseProfiles();
foreach (var (name, profileData) in profiles)
{
var title = BuildProfileTitle(profile.Type, name);
var profileToUpdate = GetProfileToUpdate(existingProfiles, title);
if (profileToUpdate != null)
{
Log.Information("Update existing profile: {ProfileName}", title);
await UpdateExistingProfile(profileToUpdate, profileData, tagIds);
}
else
{
Log.Information("Create new profile: {ProfileName}", title);
await CreateNewProfile(title, profileData, tagIds);
}
}
}
public async Task Process(ISonarrCommand args, SonarrConfiguration config)
{
foreach (var profile in config.ReleaseProfiles)
{
Log.Information("Processing Release Profile: {ProfileName}", profile.Type);
var markdown = await _parser.GetMarkdownData(profile.Type);
var profiles = Utils.FilterProfiles(_parser.ParseMarkdown(profile, markdown));
if (args.Preview)
{
Utils.PrintTermsAndScores(profiles);
continue;
}
await ProcessReleaseProfiles(profiles, profile);
}
}
}
}

@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Trash.Sonarr.ReleaseProfile
{
using ProfileDataCollection = IDictionary<string, ProfileData>;
public static class Utils
{
public static ProfileDataCollection FilterProfiles(ProfileDataCollection profiles)
{
static bool IsEmpty(ProfileData data)
{
return data.Required.Count == 0 && data.Ignored.Count == 0 && data.Preferred.Count == 0;
}
// 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.
return profiles
.Where(kv => !IsEmpty(kv.Value))
.ToDictionary(kv => kv.Key, kv => kv.Value);
}
public static void PrintTermsAndScores(ProfileDataCollection profiles)
{
Console.WriteLine("");
foreach (var (name, profile) in profiles)
{
Console.WriteLine(name);
if (profile.IncludePreferredWhenRenaming != null)
{
Console.WriteLine(" Include Preferred when Renaming?");
Console.WriteLine(" " +
(profile.IncludePreferredWhenRenaming.Value ? "CHECKED" : "NOT CHECKED"));
Console.WriteLine("");
}
static void PrintTerms(string title, IReadOnlyCollection<string> terms)
{
if (terms.Count == 0)
{
return;
}
Console.WriteLine($" {title}:");
foreach (var term in terms)
{
Console.WriteLine($" {term}");
}
Console.WriteLine("");
}
PrintTerms("Must Contain", profile.Required);
PrintTerms("Must Not Contain", profile.Ignored);
if (profile.Preferred.Count > 0)
{
Console.WriteLine(" Preferred:");
foreach (var (score, terms) in profile.Preferred)
{
foreach (var term in terms)
{
Console.WriteLine($" {score,-10} {term}");
}
}
}
Console.WriteLine("");
}
}
}
}

@ -0,0 +1,60 @@
using System;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Exceptions;
using Flurl.Http;
using JetBrains.Annotations;
using Serilog;
using Trash.Command;
using Trash.Config;
using Trash.Sonarr.QualityDefinition;
using Trash.Sonarr.ReleaseProfile;
using YamlDotNet.Core;
namespace Trash.Sonarr
{
[Command("sonarr", Description = "Perform operations on a Sonarr instance")]
[UsedImplicitly]
public class SonarrCommand : BaseCommand, ISonarrCommand
{
private readonly IConfigurationLoader<SonarrConfiguration> _configLoader;
private readonly Func<ReleaseProfileUpdater> _profileUpdaterFactory;
private readonly Func<SonarrQualityDefinitionUpdater> _qualityUpdaterFactory;
public SonarrCommand(
IConfigurationLoader<SonarrConfiguration> configLoader,
Func<ReleaseProfileUpdater> profileUpdaterFactory,
Func<SonarrQualityDefinitionUpdater> qualityUpdaterFactory)
{
_configLoader = configLoader;
_profileUpdaterFactory = profileUpdaterFactory;
_qualityUpdaterFactory = qualityUpdaterFactory;
}
// todo: Add options to exclude parts of YAML on the fly?
public override async Task Process()
{
try
{
foreach (var config in _configLoader.LoadMany(Config, "sonarr"))
{
if (config.ReleaseProfiles.Count > 0)
{
await _profileUpdaterFactory().Process(this, config);
}
if (config.QualityDefinition.HasValue)
{
await _qualityUpdaterFactory().Process(this, config);
}
}
}
catch (FlurlHttpException e)
{
Log.Error(e, "HTTP error while communicating with Sonarr");
ExitDueToFailure();
}
}
}
}

@ -0,0 +1,31 @@
using System.Collections.Generic;
using Flurl;
using JetBrains.Annotations;
using Trash.Config;
using Trash.Sonarr.QualityDefinition;
using Trash.Sonarr.ReleaseProfile;
namespace Trash.Sonarr
{
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class SonarrConfiguration : BaseConfiguration
{
public List<ReleaseProfileConfig> ReleaseProfiles { get; init; } = new();
public SonarrQualityDefinitionType? QualityDefinition { get; init; }
public override string BuildUrl()
{
return BaseUrl
.AppendPathSegment("api/v3")
.SetQueryParams(new {apikey = ApiKey});
}
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class ReleaseProfileConfig
{
public ReleaseProfileType Type { get; init; }
public bool StrictNegativeScores { get; init; }
public List<string> Tags { get; init; } = new();
}
}

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Trash</RootNamespace>
<AssemblyName>trash</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Flurl" />
<PackageReference Include="Flurl.Http" />
<PackageReference Include="JetBrains.Annotations" />
<PackageReference Include="CliFx" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Autofac" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" />
<PackageReference Include="YamlDotNet" />
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>
</Project>

@ -0,0 +1,73 @@
using System;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;
namespace Trash.YamlDotNet
{
// A workaround for nullable enums in YamlDotNet taken from:
// https://github.com/aaubry/YamlDotNet/issues/544#issuecomment-778062351
internal class YamlNullableEnumTypeConverter : IYamlTypeConverter
{
public bool Accepts(Type type)
{
return Nullable.GetUnderlyingType(type)?.IsEnum ?? false;
}
public object? ReadYaml(IParser parser, Type type)
{
type = Nullable.GetUnderlyingType(type) ??
throw new ArgumentException("Expected nullable enum type for ReadYaml");
if (parser.Accept<NodeEvent>(out var @event))
{
if (NodeIsNull(@event))
{
parser.SkipThisAndNestedEvents();
return null;
}
}
var scalar = parser.Consume<Scalar>();
try
{
return Enum.Parse(type, scalar.Value, true);
}
catch (Exception ex)
{
throw new Exception($"Invalid value: \"{scalar.Value}\" for {type.Name}", ex);
}
}
public void WriteYaml(IEmitter emitter, object? value, Type type)
{
type = Nullable.GetUnderlyingType(type) ??
throw new ArgumentException("Expected nullable enum type for WriteYaml");
if (value != null)
{
var toWrite = Enum.GetName(type, value) ??
throw new InvalidOperationException($"Invalid value {value} for enum: {type}");
emitter.Emit(new Scalar(null!, null!, toWrite, ScalarStyle.Any, true, false));
}
}
private static bool NodeIsNull(NodeEvent nodeEvent)
{
// http://yaml.org/type/null.html
if (nodeEvent.Tag == "tag:yaml.org,2002:null")
{
return true;
}
if (nodeEvent is Scalar scalar && scalar.Style == ScalarStyle.Plain)
{
var value = scalar.Value;
return value is "" or "~" or "null" or "Null" or "NULL";
}
return false;
}
}
}

@ -0,0 +1,45 @@
sonarr:
- base_url: http://localhost:8989
api_key: f7e74ba6c80046e39e076a27af5a8444
# Quality definitions from the guide to sync to Sonarr. Choice: anime, series, hybrid
quality_definition: hybrid
# Release profiles from the guide to sync to Sonarr. Types: anime, series
release_profiles:
- type: anime
strict_negative_scores: true
tags:
- anime
- type: series
strict_negative_scores: false
tags:
- tv
radarr:
- base_url: http://localhost:7878
api_key: bf99da49d0b0488ea34e4464aa63a0e5
# Which quality definition in the guide to sync to Radarr. Only choice right now is 'movie'
quality_definition:
type: movie
# A ratio that determines the preferred quality, when needed. Default is 1.0.
# Used to calculated the interpolated value between the min and max value for each table row.
preferred_ratio: 0.5
# Default quality profiles used if templates/singles/groups do not override it
# quality_profiles:
# - Movies
#
# templates: # Templates are taken FIRST
# - name: Remux-1080p
# quality_profiles:
# - Movies
# - Kids Movies
# custom_formats: # Singles and groups override values from the templates
# - name: Misc # Add the whole group (does nothing because in this case, `Remux-1080p` already adds it)
# - name: Misc/Multi # Multi exists in the template, but NO SCORE because the guide doesn't mention one. This adds in a score manually
# score: -100
#custom_formats:
# - Movie Versions # Adds all CFs since this names a "group" / "collection"
# - Movie Versions.Hybrid # Add single CF

@ -0,0 +1,43 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Trash", "Trash\Trash.csproj", "{CD5C6F99-C587-4B7C-86AE-550FA4A5594A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Trash.Tests", "Trash.Tests\Trash.Tests.csproj", "{217D5972-4BB7-4343-9043-C30BD9A1811E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestLibrary", "TestLibrary\TestLibrary.csproj", "{49F28A82-468F-4C48-9A59-D41B8FE26D6E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestLibrary.Tests", "TestLibrary.Tests\TestLibrary.Tests.csproj", "{BF105B2F-8E13-48AD-BF72-DF7EFEB018B6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Props", "Props", "{305C2AC5-803F-41B3-92D8-4AD2B2E3E130}"
ProjectSection(SolutionItems) = preProject
Directory.Build.props = Directory.Build.props
version.json = version.json
Directory.Build.targets = Directory.Build.targets
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{CD5C6F99-C587-4B7C-86AE-550FA4A5594A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CD5C6F99-C587-4B7C-86AE-550FA4A5594A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CD5C6F99-C587-4B7C-86AE-550FA4A5594A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CD5C6F99-C587-4B7C-86AE-550FA4A5594A}.Release|Any CPU.Build.0 = Release|Any CPU
{217D5972-4BB7-4343-9043-C30BD9A1811E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{217D5972-4BB7-4343-9043-C30BD9A1811E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{217D5972-4BB7-4343-9043-C30BD9A1811E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{217D5972-4BB7-4343-9043-C30BD9A1811E}.Release|Any CPU.Build.0 = Release|Any CPU
{49F28A82-468F-4C48-9A59-D41B8FE26D6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{49F28A82-468F-4C48-9A59-D41B8FE26D6E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{49F28A82-468F-4C48-9A59-D41B8FE26D6E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{49F28A82-468F-4C48-9A59-D41B8FE26D6E}.Release|Any CPU.Build.0 = Release|Any CPU
{BF105B2F-8E13-48AD-BF72-DF7EFEB018B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BF105B2F-8E13-48AD-BF72-DF7EFEB018B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BF105B2F-8E13-48AD-BF72-DF7EFEB018B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BF105B2F-8E13-48AD-BF72-DF7EFEB018B6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
EndGlobalSection
EndGlobal

@ -0,0 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=Radarr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Sonarr/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

@ -0,0 +1,17 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
"version": "1.0.0",
"publicReleaseRefSpec": [
"^refs/heads/release/\\d+\\.\\d+",
"^refs/tags/v\\d+\\.\\d+"
],
"cloudBuild": {
"buildNumber": {
"enabled": true
}
},
"release": {
"branchName": "release/{version}",
"versionIncrement": "build"
}
}

@ -0,0 +1,4 @@
{
"extends": "../.markdownlint.json",
"first-line-heading": false
}

@ -0,0 +1,92 @@
Command line interface documentation for the `Trash` executable.
## Subcommands
Each service (Sonarr, Radarr) has a subcommand that must be specified in order to perform operations
related to that service, such as parsing relevant TRaSH guides and invoking API endpoints to modify
settings on that instance. As always, the `--help` option may be specified following a subcommand to
see more information directly in your terminal.
- `sonarr`: Update release profiles and quality definitions on configured Sonarr instances.
- `radarr`: Update custom formats and quality definitions on configured Radarr instances.
## Common Arguments
These are optional arguments shared by *all* subcommands.
### `--config`
One or more paths to YAML configuration files. Only the relevant configuration section for the
specified subcommand will be read from each file. If this argument is not specified, a single
default configuration file named `trash.yml` will be used. It must be in the same directory as the
`trash` executable.
**Command Line Examples**:
```bash
# Default Config (trash.yml)
trash sonarr
# Single Config
trash sonarr --config ../myconfig.yml
# Multiple Config
trash sonarr --config ../myconfig1.yml "files/my config 2.yml"
```
### `--preview`
Performs a "dry run" by parsing the guide and printing the parsed data in a readable format to the
user. This does *not* perform any API calls to Radarr or Sonarr. You may want to run a preview if
you'd like to see if the guide is parsed correctly before updating your instance.
Example output for Sonarr Release Profile parsing
```txt
First Release Profile
Include Preferred when Renaming?
CHECKED
Must Not Contain:
/(\[EMBER\]|-EMBER\b|DaddySubs)/i
Preferred:
100 /\b(amzn|amazon)\b(?=[ ._-]web[ ._-]?(dl|rip)\b)/i
90 /\b(dsnp|dsny|disney)\b(?=[ ._-]web[ ._-]?(dl|rip)\b)/i
Second Release Profile
Include Preferred when Renaming?
NOT CHECKED
Preferred:
180 /(-deflate|-inflate)\b/i
150 /(-AJP69|-BTN|-CasStudio|-CtrlHD|-KiNGS)\b/i
150 /(-monkee|-NTb|-NTG|-QOQ|-RTN)\b/i
```
Example output for Sonarr Quality Definition parsing
```txt
Quality Min Max
------- --- ---
HDTV-720p 2.3 67.5
HDTV-1080p 2.3 137.3
WEBRip-720p 4.3 137.3
WEBDL-720p 4.3 137.3
Bluray-720p 4.3 137.3
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 69.1 350
WEBRip-2160p 69.1 350
WEBDL-2160p 69.1 350
Bluray-2160p 94.6 400
Bluray-2160p Remux 204.4 400
```
### `--debug`
By default, Info, Warning and Error log levels are displayed in the console. This option enables
Debug level logs to be displayed. This is designed for debugging and development purposes and
generally will be too noisy for normal program usage.

@ -0,0 +1,213 @@
Reference documentation for the YAML documentation.
## Summary
The Trash Updater program utilizes YAML for its configuration files. The configuration can be set up
multiple ways, offering a lot of flexibility:
- You may use one or more YAML files simultaneously, allowing you to divide your configuration
properties up in such a way that you can control what gets updated based on which files you
specify.
- Each YAML file may have one or more service configurations. This means you can have one file
define settings for just Sonarr, Radarr, or both services. The program will only read the
configuration from the file relevant for the specific service subcommand you specified (e.g.
`trash sonarr` will only read the Sonarr config in the file, even if Radarr config is present)
> **Remember**: If you do not specify the `--config` argument, the program will look for `trash.yml`
> in the same directory where the executable lives.
## YAML Reference
### Sonarr
```yml
sonarr:
- base_url: http://localhost:8989
api_key: f7e74ba6c80046e39e076a27af5a8444
# Quality definitions from the guide to sync to Sonarr.
quality_definition: hybrid
# Release profiles from the guide to sync to Sonarr.
release_profiles:
- type: anime
strict_negative_scores: true
tags:
- anime
- type: series
strict_negative_scores: false
tags:
- tv
```
- `base_url` (Required)<br>
The base URL of your Sonarr instance. Basically this is the URL you bookmark to get to the front
page.
- `api_key` (Required)<br>
The API key that Trash Updater should use to synchronize settings to your instance. You can obtain
your API key by going to `Sonarr > Settings > General` and copy & paste the "API Key" under the
"Security" group/header.
- `quality_definition` (Optional)<br>
The quality definition [from the TRaSH Guide's Quality Settings page][sonarr_quality] that should
be parsed and uploaded to Sonarr. Only the below values are permitted here.
- `anime`: Represents the "Sonarr Quality Definitions" table specifically for Anime
- `series`: Represents the "Sonarr Quality Definitions" table intended for normal TV Series.
Sometimes referred to as non-anime.
- `hybrid`: A combination of both the `anime` and `series` tables that is calculated by comparing
each row and taking both the smallest minimum and largest maximum values. The purpose of the
Hybrid type is to build the most permissive quality definition that the guide will allow. It's a
good idea to use this one if you want more releases to be blocked by your release profiles
instead of quality.
- `release_profiles` (Optional)<br>
A list of release profiles to parse from the guide. Each object in this list supports the below
properties.
- `type` (Required): Must be one of the following values:
- `anime`: Parse the [Anime Release Profile][sonarr_profile_anime] page from the TRaSH Guide.
- `series`: Parse the [WEB-DL Release Profile][sonarr_profile_series] page from the TRaSH Guide.
- `strict_negative_scores` (Optional): Enables preferred term scores less than 0 to be instead
treated as "Must Not Contain" (ignored) terms. For example, if something is "Preferred" with a
score of `-10`, it will instead be put in the "Must Not Contains" section of the uploaded
release profile. Must be `true` or `false`. The default value is `false` if omitted.
- `tags` (Optional): A list of one or more strings representing tags that will be applied to this
release profile. Tags are created in Sonarr if they do not exist. All tags on an existing
release profile (if present) are removed and replaced with only the tags in this list. If no
tags are specified, no tags will be set on the release profile.
[sonarr_quality]: https://trash-guides.info/Sonarr/V3/Sonarr-Quality-Settings-File-Size/
[sonarr_profile_anime]: https://trash-guides.info/Sonarr/V3/Sonarr-Release-Profile-RegEx-Anime/
[sonarr_profile_series]: https://trash-guides.info/Sonarr/V3/Sonarr-Release-Profile-RegEx/
### Radarr
```yml
radarr:
- base_url: http://localhost:7878
api_key: bf99da49d0b0488ea34e4464aa63a0e5
# Which quality definition in the guide to sync to Radarr.
quality_definition:
type: movie
preferred_ratio: 0.5
```
- `base_url` (Required)<br>
The base URL of your Radarr instance. Basically this is the URL you bookmark to get to the front
page.
- `api_key` (Required)<br>
The API key that Trash Updater should use to synchronize settings to your instance. You can obtain
your API key by going to `Radarr > Settings > General` and copy & paste the "API Key" under the
"Security" group/header.
- `quality_definition` (Optional)<br>
Specify information related to Radarr quality definition processing here. Only the following child
properties are permitted.
- `type` (Required): The quality definition from the [Radarr Quality Settings (File
Size)][radarr_quality] page in the TRaSH Guides that should be parsed and uploaded to Radarr.
Only the below values are permitted here.
- `movie`: Currently the only supported type. Represents the only table on that page and is
intended for general use with all movies in Radarr.
- `preferred_ratio` (Optional) A value `0.0` to `1.0` that represents the percentage
(interpolated) position of that middle slider you see when you enable advanced settings on the
Quality Definitions page in Radarr. A value of `0.0` means the preferred quality will match the
minimum quality. Likewise, `1.0` will match the maximum quality. A value such as `0.5` will keep
it halfway between the two.
If not specified, the default value is `1.0`. Any value less than `0` or greater than `1` will
result in a warning log printed and the value will be clamped.
[radarr_quality]: https://trash-guides.info/Radarr/V3/Radarr-Quality-Settings-File-Size/
## Examples
Various scenarios supported using the flexible configuration support.
### Update as much as possible in both Sonarr and Radarr with a single config
Create a single configuration file (use the default `trash.yml` if you want to simplify your CLI
usage by not being required to specify `--config`) and put all of the configuration in there, like
this:
```yml
sonarr:
- base_url: http://localhost:8989
api_key: f7e74ba6c80046e39e076a27af5a8444
quality_definition: hybrid
release_profiles:
- type: anime
strict_negative_scores: true
tags:
- anime
- type: series
strict_negative_scores: false
tags:
- tv
radarr:
- base_url: http://localhost:7878
api_key: bf99da49d0b0488ea34e4464aa63a0e5
quality_definition:
type: movie
preferred_ratio: 0.5
```
Even though it's all in one file, Radarr settings are ignored when you run `trash sonarr` and vice
versa. To update both, just chain them together in your terminal, like so:
```bash
trash sonarr && trash radarr
```
This scenario is pretty ideal for a cron job you have running regularly and you want it to update
everything possible in one go.
### Selectively update different parts of Sonarr
Say you want to update Sonarr release profiles from the guide, but not the quality definitions.
There's no command line option to control this, so how do you do it?
Simply create two YAML files:
`sonarr-release-profiles.yml`:
```yml
sonarr:
- base_url: http://localhost:8989
api_key: f7e74ba6c80046e39e076a27af5a8444
release_profiles:
- type: anime
tags:
- anime
```
`sonarr-quality-definition.yml`:
```yml
sonarr:
- base_url: http://localhost:8989
api_key: f7e74ba6c80046e39e076a27af5a8444
quality_definition: hybrid
```
Then run the following command:
```bash
trash sonarr --config sonarr-release-profiles.yml
```
This will only update release profiles since you have essentially moved the `quality_definition`
property to its own file. When you want to update both, you just specify both files the next time
you run the program:
```bash
trash sonarr --config sonarr-release-profiles.yml sonarr-quality-definition.yml
```

@ -0,0 +1,15 @@
Pages of Interest:
- [[Command Line Reference]]
- [[Configuration Reference]]
- [[TRaSH Guide Structural Guidelines]]
See the "Pages" list on the right side of this page for the complete list of wiki pages.
## Contributing to the Wiki
This wiki is auto-generated from the main repository. If you want to contribute to the documentation
here, please clone the main repo and edit files in the [wiki directory][1]. Pull request the changes
and when they are merged, a workflow will run that updates the wiki.
[1]: https://github.com/rcdailey/trash-updater/tree/master/wiki

@ -0,0 +1,55 @@
With the introduction of version 1.0 of Trash Updater, I am leaving the old Python script behind. I
decided to rewrite the entire application in C# .NET mainly for two reasons:
1. I prefer using and am more comfortable with C#
1. The application started becoming too large and complicated for Python, in my humble opinion.
The rewritten version isn't completely identical to the Python script, unfortunately. The purpose of
this page is to document all of the differences so you can learn the new command line and migrate
your configuration over.
## Command Line Differences
The biggest differences are:
- Nearly all the old CLI options are gone. You no longer have the option of providing something on
the command line *or* in the YAML config. Everything must be put in the YAML configuration now!
See [[Configuration Reference]] for details.
- The subcommands are different. Instead of specifying `profile` or `guide` now, you instead mention
the service you're using, such as `radarr` or `sonarr`. See [[Command Line Reference]] for
details.
## Configuration Differences
The YAML structure is mostly identical. I recommend you head over to the [[Configuration Reference]]
page and get familiar with the whole schema. But I'll point out a few differences to look out for
here.
### Sonarr
Changed:
- Everything under the top-level `sonarr:` property is now in a list. That means just make the first
line prefixed with a `-`. This is the list format in YAML. There are actual examples in the
reference linked above.
- `profile` is now `release_profile`
- `base_uri` is now `base_url` (the `i` at the end became an `L`)
Added:
- Property named `strict_negative_scores` has been added to the `release_profile` objects (since
it's no longer specified via CLI).
- `quality_definition` has been added under `sonarr`.
### Radarr
Changed:
- Everything under the top-level `radarr:` property is now in a list. That means just make the first
line prefixed with a `-`. This is the list format in YAML. There are actual examples in the
reference linked above.
Added:
- `quality_definition` has been added under `radarr`.

@ -0,0 +1,100 @@
In order for the `trash.py` script to remain as stable as possible between updates to the TRaSH
guides, the following structural guidelines are provided. This document also serves as documentation
on how the python script is implemented currently.
# Definitions
* **Term**<br>
A phrase that is included in Sonarr release profiles under either the "Preferred", "Must Contain",
or "Must Not Contain" sections. In the TRaSH guides these are regular expressions.
* **Ignored**<br>
The API term for "Must Not Contain"
* **Required**<br>
The API term for "Must Contain"
* **Category**<br>
Refers to any of the different "sections" in a release profile where terms may be stored. Includes
"Must Not Contain" (ignored), "Must Contain" (required), and "Preferred".
* **Mention**<br>
This generally refers to any human-readable way of stating something that the script relies on for
parsing purposes.
# Structural Guidelines
Different types of TRaSH guides are parsed in their own unique way, mostly because the data set is
different. In order to ensure the script continues to be reliable, it's important that the structure
of the guides do not change. The following sections outline various guidelines to help achieve this
goal.
Note that all parsing happens directly on the markdown files themselves from the TRaSH github
repository. Those files are processed one line at a time. Guidelines will apply on a per-line basis,
unless otherwise stated.
## Sonarr Release Profiles
1. **Headers define release profiles.**
A header with the phrase `Release Profile` in it will start a new release profile. The header
name may contain other keywords before or after that phrase, such as `First Release Profile`.
This header name in its entirety will be used as part of the release profile name when the data
is pushed to Sonarr.
1. **Fenced code blocks must *only* contain ignored, required, or preferred terms.**
Between headers, fenced code blocks indicate the terms that will be captured and pushed to Sonarr
for any given type of category (required, preferred, or ignored). There may be more than one
fenced code block, and each fenced code block may have more than one line inside of it. Each line
inside of a fenced code block is treated as 1 single term. Commas at the end of each line are
removed, if they are present.
1. **For preferred terms, a score must be mentioned prior to the first fenced code block.**
Each separate line in the markdown file is inspected for the word `score` followed by a number
inside square brackets, such as `[100]`. If found, the score between the brackets is captured and
applied to any future terms found within fenced code blocks. Between fenced code blocks under the
same heading, a new score using these same rules may be mentioned to change it again.
Terms mentioned prior to a score being set are discarded.
1. **Categories shall be specified before the first fenced code block.**
Categories are technically optional; if one is never explicitly mentioned in the guide, the
default is "Preferred". Depending on the category, certain requirements change. At the moment, if
"Preferred" is used, this also requires a score. However "Must Not Contain" and "Must Contain" do
not require a score.
A category must mentioned as one of the following phrases (case insensitive):
* `Preferred`
* `Must Not Contain`
* `Must Contain`
These phrases may appear in nested headers, normal lines, and may even appear inside the same
line that defines a score (e.g. `Insert these as "Preferred" with a score of [100]`).
1. **"Include Preferred when Renaming" may be optionally set via mention.**
If you wish to control the checked/unchecked state of the "Include Preferred when Renaming"
option in a release profile, simply mention the phrase `include preferred` (case-insensitive) on
any single line. This marks it as "CHECKED". If it also finds the word `not` on that same line,
it will instead be marked "UNCHECKED".
This is optional and the default is always "UNCHECKED".
### Release Profile Naming
The script procedurally generates a name for release profiles it creates. For the following example:
```txt
[Trash] Anime - First Release Profile
```
The name is generated as follows:
* `Anime` comes from the guide type (could be `WEB-DL`)
* `First Release Profile` is directly from one of the headers in the anime guide
* `[Trash]` is used by the script to mean "This release profile is controlled by the script". This
is to separate it from any manual ones the user has defined, which the script will not touch.
Loading…
Cancel
Save