From bb5c74e0cb1be826bec727cd3cfce7af3fc9b1e6 Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Wed, 10 Mar 2021 17:22:38 -0600 Subject: [PATCH] Complete rewrite using C# .NET Core 5 --- .github/workflows/build.yml | 127 +++++ .github/workflows/draft-new-release.yml | 78 +++ .github/workflows/wiki.yml | 25 + .gitignore | 466 ++++++++++++++++++ .idea/.idea.TrashUpdater/.idea/.gitignore | 8 + .idea/.idea.TrashUpdater/.idea/encodings.xml | 4 + .../.idea.TrashUpdater/.idea/indexLayout.xml | 8 + .idea/.idea.TrashUpdater/.idea/vcs.xml | 6 + .idea/inspectionProfiles/Project_Default.xml | 7 + .idea/vcs.xml | 6 + .markdownlint.json | 11 + CHANGELOG.md | 34 ++ LICENSE | 21 + Publish.ps1 | 15 + README.md | 109 ++++ azure-pipelines/build.yml | 54 ++ src/.idea/.idea.TrashUpdater/.idea/.gitignore | 8 + src/.idea/.idea.TrashUpdater/.idea/.name | 1 + .../.idea.TrashUpdater/.idea/encodings.xml | 4 + .../.idea.TrashUpdater/.idea/indexLayout.xml | 8 + src/.idea/.idea.TrashUpdater/.idea/vcs.xml | 6 + src/Directory.Build.props | 31 ++ src/Directory.Build.targets | 31 ++ src/TestLibrary.Tests/Data/DataFile.txt | 1 + src/TestLibrary.Tests/DataFileWontBeFound.txt | 1 + .../OtherData/AnotherDataFile.txt | 1 + src/TestLibrary.Tests/TestDataTest.cs | 53 ++ .../TestLibrary.Tests.csproj | 14 + src/TestLibrary/StreamBuilder.cs | 14 + src/TestLibrary/TestData.cs | 41 ++ src/TestLibrary/TestLibrary.csproj | 7 + .../Config/ConfigurationLoaderTest.cs | 117 +++++ .../Data/Load_UsingStream_CorrectParsing.yml | 12 + .../Extensions/DictionaryExtensionsTest.cs | 42 ++ .../Data/include_preferred_when_renaming.md | 19 + .../Guide/Data/strict_negative_scores.md | 13 + .../Data/test_parse_markdown_complete_doc.md | 22 + .../Sonarr/Guide/ReleaseProfileParserTest.cs | 89 ++++ .../Sonarr/ReleaseProfileUpdaterTest.cs | 43 ++ src/Trash.Tests/Trash.Tests.csproj | 10 + src/Trash/Command/BaseCommand.cs | 100 ++++ src/Trash/Command/ExitCode.cs | 8 + src/Trash/Command/IBaseCommand.cs | 11 + src/Trash/CompositionRoot.cs | 99 ++++ src/Trash/Config/BaseConfiguration.cs | 10 + src/Trash/Config/ConfigurationException.cs | 16 + src/Trash/Config/ConfigurationLoader.cs | 74 +++ src/Trash/Config/ConfigurationProvider.cs | 8 + src/Trash/Config/IConfigurationLoader.cs | 13 + src/Trash/Config/IConfigurationProvider.cs | 8 + src/Trash/Config/ObjectFactory.cs | 23 + src/Trash/Extensions/DictionaryExtensions.cs | 31 ++ src/Trash/Extensions/StringExtensions.cs | 33 ++ src/Trash/Extensions/YamlDotNetExtensions.cs | 22 + src/Trash/Program.cs | 23 + src/Trash/Radarr/Api/IRadarrApi.cs | 12 + .../Objects/RadarrQualityDefinitionItem.cs | 26 + src/Trash/Radarr/Api/RadarrApi.cs | 46 ++ src/Trash/Radarr/IRadarrCommand.cs | 8 + .../IRadarrQualityDefinitionGuideParser.cs | 11 + .../QualityDefinition/RadarrQualityData.cs | 10 + .../RadarrQualityDefinitionGuideParser.cs | 71 +++ .../RadarrQualityDefinitionType.cs | 7 + .../RadarrQualityDefinitionUpdater.cs | 119 +++++ src/Trash/Radarr/RadarrCommand.cs | 49 ++ src/Trash/Radarr/RadarrConfiguration.cs | 27 + src/Trash/Sonarr/Api/ISonarrApi.cs | 19 + .../Objects/SonarrQualityDefinitionItem.cs | 24 + .../Api/Objects/SonarrReleaseProfile.cs | 36 ++ src/Trash/Sonarr/Api/Objects/SonarrTag.cs | 8 + src/Trash/Sonarr/Api/SonarrApi.cs | 91 ++++ src/Trash/Sonarr/ISonarrCommand.cs | 8 + .../ISonarrQualityDefinitionGuideParser.cs | 11 + .../QualityDefinition/SonarrQualityData.cs | 9 + .../SonarrQualityDefinitionGuideParser.cs | 73 +++ .../SonarrQualityDefinitionType.cs | 9 + .../SonarrQualityDefinitionUpdater.cs | 150 ++++++ .../IReleaseProfileGuideParser.cs | 11 + .../Sonarr/ReleaseProfile/ProfileData.cs | 17 + .../ReleaseProfileGuideParser.cs | 261 ++++++++++ .../ReleaseProfile/ReleaseProfileType.cs | 8 + .../ReleaseProfile/ReleaseProfileUpdater.cs | 159 ++++++ src/Trash/Sonarr/ReleaseProfile/Utils.cs | 77 +++ src/Trash/Sonarr/SonarrCommand.cs | 60 +++ src/Trash/Sonarr/SonarrConfiguration.cs | 31 ++ src/Trash/Trash.csproj | 20 + .../YamlNullableEnumTypeConverter.cs | 73 +++ src/Trash/trash.yml | 45 ++ src/TrashUpdater.sln | 43 ++ src/TrashUpdater.sln.DotSettings | 3 + version.json | 17 + wiki/.markdownlint.json | 4 + wiki/Command-Line-Reference.md | 92 ++++ wiki/Configuration-Reference.md | 213 ++++++++ wiki/Home.md | 15 + wiki/Python-Migration-Guide.md | 55 +++ wiki/TRaSH-Guide-Structural-Guidelines.md | 100 ++++ 97 files changed, 4174 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/draft-new-release.yml create mode 100644 .github/workflows/wiki.yml create mode 100644 .gitignore create mode 100644 .idea/.idea.TrashUpdater/.idea/.gitignore create mode 100644 .idea/.idea.TrashUpdater/.idea/encodings.xml create mode 100644 .idea/.idea.TrashUpdater/.idea/indexLayout.xml create mode 100644 .idea/.idea.TrashUpdater/.idea/vcs.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/vcs.xml create mode 100644 .markdownlint.json create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 Publish.ps1 create mode 100644 README.md create mode 100644 azure-pipelines/build.yml create mode 100644 src/.idea/.idea.TrashUpdater/.idea/.gitignore create mode 100644 src/.idea/.idea.TrashUpdater/.idea/.name create mode 100644 src/.idea/.idea.TrashUpdater/.idea/encodings.xml create mode 100644 src/.idea/.idea.TrashUpdater/.idea/indexLayout.xml create mode 100644 src/.idea/.idea.TrashUpdater/.idea/vcs.xml create mode 100644 src/Directory.Build.props create mode 100644 src/Directory.Build.targets create mode 100644 src/TestLibrary.Tests/Data/DataFile.txt create mode 100644 src/TestLibrary.Tests/DataFileWontBeFound.txt create mode 100644 src/TestLibrary.Tests/OtherData/AnotherDataFile.txt create mode 100644 src/TestLibrary.Tests/TestDataTest.cs create mode 100644 src/TestLibrary.Tests/TestLibrary.Tests.csproj create mode 100644 src/TestLibrary/StreamBuilder.cs create mode 100644 src/TestLibrary/TestData.cs create mode 100644 src/TestLibrary/TestLibrary.csproj create mode 100644 src/Trash.Tests/Config/ConfigurationLoaderTest.cs create mode 100644 src/Trash.Tests/Config/Data/Load_UsingStream_CorrectParsing.yml create mode 100644 src/Trash.Tests/Extensions/DictionaryExtensionsTest.cs create mode 100644 src/Trash.Tests/Sonarr/Guide/Data/include_preferred_when_renaming.md create mode 100644 src/Trash.Tests/Sonarr/Guide/Data/strict_negative_scores.md create mode 100644 src/Trash.Tests/Sonarr/Guide/Data/test_parse_markdown_complete_doc.md create mode 100644 src/Trash.Tests/Sonarr/Guide/ReleaseProfileParserTest.cs create mode 100644 src/Trash.Tests/Sonarr/ReleaseProfileUpdaterTest.cs create mode 100644 src/Trash.Tests/Trash.Tests.csproj create mode 100644 src/Trash/Command/BaseCommand.cs create mode 100644 src/Trash/Command/ExitCode.cs create mode 100644 src/Trash/Command/IBaseCommand.cs create mode 100644 src/Trash/CompositionRoot.cs create mode 100644 src/Trash/Config/BaseConfiguration.cs create mode 100644 src/Trash/Config/ConfigurationException.cs create mode 100644 src/Trash/Config/ConfigurationLoader.cs create mode 100644 src/Trash/Config/ConfigurationProvider.cs create mode 100644 src/Trash/Config/IConfigurationLoader.cs create mode 100644 src/Trash/Config/IConfigurationProvider.cs create mode 100644 src/Trash/Config/ObjectFactory.cs create mode 100644 src/Trash/Extensions/DictionaryExtensions.cs create mode 100644 src/Trash/Extensions/StringExtensions.cs create mode 100644 src/Trash/Extensions/YamlDotNetExtensions.cs create mode 100644 src/Trash/Program.cs create mode 100644 src/Trash/Radarr/Api/IRadarrApi.cs create mode 100644 src/Trash/Radarr/Api/Objects/RadarrQualityDefinitionItem.cs create mode 100644 src/Trash/Radarr/Api/RadarrApi.cs create mode 100644 src/Trash/Radarr/IRadarrCommand.cs create mode 100644 src/Trash/Radarr/QualityDefinition/IRadarrQualityDefinitionGuideParser.cs create mode 100644 src/Trash/Radarr/QualityDefinition/RadarrQualityData.cs create mode 100644 src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionGuideParser.cs create mode 100644 src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionType.cs create mode 100644 src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionUpdater.cs create mode 100644 src/Trash/Radarr/RadarrCommand.cs create mode 100644 src/Trash/Radarr/RadarrConfiguration.cs create mode 100644 src/Trash/Sonarr/Api/ISonarrApi.cs create mode 100644 src/Trash/Sonarr/Api/Objects/SonarrQualityDefinitionItem.cs create mode 100644 src/Trash/Sonarr/Api/Objects/SonarrReleaseProfile.cs create mode 100644 src/Trash/Sonarr/Api/Objects/SonarrTag.cs create mode 100644 src/Trash/Sonarr/Api/SonarrApi.cs create mode 100644 src/Trash/Sonarr/ISonarrCommand.cs create mode 100644 src/Trash/Sonarr/QualityDefinition/ISonarrQualityDefinitionGuideParser.cs create mode 100644 src/Trash/Sonarr/QualityDefinition/SonarrQualityData.cs create mode 100644 src/Trash/Sonarr/QualityDefinition/SonarrQualityDefinitionGuideParser.cs create mode 100644 src/Trash/Sonarr/QualityDefinition/SonarrQualityDefinitionType.cs create mode 100644 src/Trash/Sonarr/QualityDefinition/SonarrQualityDefinitionUpdater.cs create mode 100644 src/Trash/Sonarr/ReleaseProfile/IReleaseProfileGuideParser.cs create mode 100644 src/Trash/Sonarr/ReleaseProfile/ProfileData.cs create mode 100644 src/Trash/Sonarr/ReleaseProfile/ReleaseProfileGuideParser.cs create mode 100644 src/Trash/Sonarr/ReleaseProfile/ReleaseProfileType.cs create mode 100644 src/Trash/Sonarr/ReleaseProfile/ReleaseProfileUpdater.cs create mode 100644 src/Trash/Sonarr/ReleaseProfile/Utils.cs create mode 100644 src/Trash/Sonarr/SonarrCommand.cs create mode 100644 src/Trash/Sonarr/SonarrConfiguration.cs create mode 100644 src/Trash/Trash.csproj create mode 100644 src/Trash/YamlDotNet/YamlNullableEnumTypeConverter.cs create mode 100644 src/Trash/trash.yml create mode 100644 src/TrashUpdater.sln create mode 100644 src/TrashUpdater.sln.DotSettings create mode 100644 version.json create mode 100644 wiki/.markdownlint.json create mode 100644 wiki/Command-Line-Reference.md create mode 100644 wiki/Configuration-Reference.md create mode 100644 wiki/Home.md create mode 100644 wiki/Python-Migration-Guide.md create mode 100644 wiki/TRaSH-Guide-Structural-Guidelines.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..c674463f --- /dev/null +++ b/.github/workflows/build.yml @@ -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 != '' }} diff --git a/.github/workflows/draft-new-release.yml b/.github/workflows/draft-new-release.yml new file mode 100644 index 00000000..4a63d7c4 --- /dev/null +++ b/.github/workflows/draft-new-release.yml @@ -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' + }) diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml new file mode 100644 index 00000000..e7d81399 --- /dev/null +++ b/.github/workflows/wiki.yml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c5e66d1b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.idea/.idea.TrashUpdater/.idea/.gitignore b/.idea/.idea.TrashUpdater/.idea/.gitignore new file mode 100644 index 00000000..ea439e0d --- /dev/null +++ b/.idea/.idea.TrashUpdater/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/.idea.TrashUpdater.iml +/modules.xml +/contentModel.xml +/projectSettingsUpdater.xml diff --git a/.idea/.idea.TrashUpdater/.idea/encodings.xml b/.idea/.idea.TrashUpdater/.idea/encodings.xml new file mode 100644 index 00000000..df87cf95 --- /dev/null +++ b/.idea/.idea.TrashUpdater/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.TrashUpdater/.idea/indexLayout.xml b/.idea/.idea.TrashUpdater/.idea/indexLayout.xml new file mode 100644 index 00000000..7b08163c --- /dev/null +++ b/.idea/.idea.TrashUpdater/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.TrashUpdater/.idea/vcs.xml b/.idea/.idea.TrashUpdater/.idea/vcs.xml new file mode 100644 index 00000000..94a25f7f --- /dev/null +++ b/.idea/.idea.TrashUpdater/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..12f87bc2 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..94a25f7f --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 00000000..60b5550b --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,11 @@ +{ + "default": true, + "line-length": { + "line_length": 100, + "tables": false, + "code_blocks": false + }, + "no-inline-html": { + "allowed_elements": ["br"] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..be0cafdd --- /dev/null +++ b/CHANGELOG.md @@ -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. + + +[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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..42c87b9e --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Publish.ps1 b/Publish.ps1 new file mode 100644 index 00000000..ce8bf66e --- /dev/null +++ b/Publish.ps1 @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 00000000..d15c0ea7 --- /dev/null +++ b/README.md @@ -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). diff --git a/azure-pipelines/build.yml b/azure-pipelines/build.yml new file mode 100644 index 00000000..e8cbc84c --- /dev/null +++ b/azure-pipelines/build.yml @@ -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 + \ No newline at end of file diff --git a/src/.idea/.idea.TrashUpdater/.idea/.gitignore b/src/.idea/.idea.TrashUpdater/.idea/.gitignore new file mode 100644 index 00000000..b58e4759 --- /dev/null +++ b/src/.idea/.idea.TrashUpdater/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/projectSettingsUpdater.xml +/.idea.TrashUpdater.iml +/contentModel.xml diff --git a/src/.idea/.idea.TrashUpdater/.idea/.name b/src/.idea/.idea.TrashUpdater/.idea/.name new file mode 100644 index 00000000..953afe3d --- /dev/null +++ b/src/.idea/.idea.TrashUpdater/.idea/.name @@ -0,0 +1 @@ +TrashUpdater \ No newline at end of file diff --git a/src/.idea/.idea.TrashUpdater/.idea/encodings.xml b/src/.idea/.idea.TrashUpdater/.idea/encodings.xml new file mode 100644 index 00000000..df87cf95 --- /dev/null +++ b/src/.idea/.idea.TrashUpdater/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/.idea/.idea.TrashUpdater/.idea/indexLayout.xml b/src/.idea/.idea.TrashUpdater/.idea/indexLayout.xml new file mode 100644 index 00000000..7b08163c --- /dev/null +++ b/src/.idea/.idea.TrashUpdater/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/.idea/.idea.TrashUpdater/.idea/vcs.xml b/src/.idea/.idea.TrashUpdater/.idea/vcs.xml new file mode 100644 index 00000000..6c0b8635 --- /dev/null +++ b/src/.idea/.idea.TrashUpdater/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 00000000..c7dd259f --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,31 @@ + + + net5.0 + enable + 9999 + + embedded + + + $(MSBuildThisFileDirectory) + + + + + + + + + + + + + + + + + + + diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets new file mode 100644 index 00000000..ff50ca22 --- /dev/null +++ b/src/Directory.Build.targets @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + true + + + true + + + + + + + diff --git a/src/TestLibrary.Tests/Data/DataFile.txt b/src/TestLibrary.Tests/Data/DataFile.txt new file mode 100644 index 00000000..833889bd --- /dev/null +++ b/src/TestLibrary.Tests/Data/DataFile.txt @@ -0,0 +1 @@ +DataFile diff --git a/src/TestLibrary.Tests/DataFileWontBeFound.txt b/src/TestLibrary.Tests/DataFileWontBeFound.txt new file mode 100644 index 00000000..9a67e972 --- /dev/null +++ b/src/TestLibrary.Tests/DataFileWontBeFound.txt @@ -0,0 +1 @@ +DataFileWontBeFound diff --git a/src/TestLibrary.Tests/OtherData/AnotherDataFile.txt b/src/TestLibrary.Tests/OtherData/AnotherDataFile.txt new file mode 100644 index 00000000..a523bba2 --- /dev/null +++ b/src/TestLibrary.Tests/OtherData/AnotherDataFile.txt @@ -0,0 +1 @@ +AnotherDataFile diff --git a/src/TestLibrary.Tests/TestDataTest.cs b/src/TestLibrary.Tests/TestDataTest.cs new file mode 100644 index 00000000..353abb3b --- /dev/null +++ b/src/TestLibrary.Tests/TestDataTest.cs @@ -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(); + + act.Should() + .Throw() + .WithMessage("*does not have the [TestFixture] attribute"); + } + + [Test] + public void GetResourceData_CustomDir_ReturnResourceData() + { + TestData testData = new(); + testData.DataSubdirectoryName = "OtherData"; + var data = testData.GetResourceData("AnotherDataFile.txt"); + data.Trim().Should().Be("AnotherDataFile"); + } + + [Test] + public void GetResourceData_DefaultDir_ReturnResourceData() + { + TestData testData = new(); + var data = testData.GetResourceData("DataFile.txt"); + data.Trim().Should().Be("DataFile"); + } + + [Test] + public void GetResourceData_NonexistentFile_Throw() + { + TestData testData = new(); + Action act = () => testData.GetResourceData("DataFileWontBeFound.txt"); + + act.Should() + .Throw() + .WithMessage("Embedded resource not found*"); + } + } +} diff --git a/src/TestLibrary.Tests/TestLibrary.Tests.csproj b/src/TestLibrary.Tests/TestLibrary.Tests.csproj new file mode 100644 index 00000000..474578da --- /dev/null +++ b/src/TestLibrary.Tests/TestLibrary.Tests.csproj @@ -0,0 +1,14 @@ + + + false + + + + + + + + + + + diff --git a/src/TestLibrary/StreamBuilder.cs b/src/TestLibrary/StreamBuilder.cs new file mode 100644 index 00000000..d0bc7338 --- /dev/null +++ b/src/TestLibrary/StreamBuilder.cs @@ -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); + } + } +} diff --git a/src/TestLibrary/TestData.cs b/src/TestLibrary/TestData.cs new file mode 100644 index 00000000..91f86fbd --- /dev/null +++ b/src/TestLibrary/TestData.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using System.Reflection; +using NUnit.Framework; + +namespace TestLibrary +{ + public class TestData + { + 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(); + } + } +} diff --git a/src/TestLibrary/TestLibrary.csproj b/src/TestLibrary/TestLibrary.csproj new file mode 100644 index 00000000..d7877722 --- /dev/null +++ b/src/TestLibrary/TestLibrary.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Trash.Tests/Config/ConfigurationLoaderTest.cs b/src/Trash.Tests/Config/ConfigurationLoaderTest.cs new file mode 100644 index 00000000..cf705d05 --- /dev/null +++ b/src/Trash.Tests/Config/ConfigurationLoaderTest.cs @@ -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(); + 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( + Substitute.For>(), + Substitute.For(), + new DefaultObjectFactory()); + + var configs = configLoader.LoadFromStream(GetResourceData("Load_UsingStream_CorrectParsing.yml"), "sonarr"); + + configs.Should() + .BeEquivalentTo(new List + { + new() + { + ApiKey = "95283e6b156c42f3af8a9b16173f876b", + BaseUrl = "http://localhost:8989", + ReleaseProfiles = new List + { + new() + { + Type = ReleaseProfileType.Anime, + StrictNegativeScores = true, + Tags = new List {"anime"} + }, + new() + { + Type = ReleaseProfileType.Series, + StrictNegativeScores = false, + Tags = new List + { + "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(); + fs.File.OpenText(Arg.Any()) + .Returns(MockYaml(1, 2), MockYaml(3)); + + var provider = Substitute.For>(); + // var objectFactory = Substitute.For(); + // objectFactory.Create(Arg.Any()) + // .Returns(t => Substitute.For(new[] {(Type)t[0]}, Array.Empty())); + + var actualActiveConfigs = new List(); + provider.ActiveConfiguration = Arg.Do(a => actualActiveConfigs.Add(a)); + + var loader = new ConfigurationLoader(provider, fs, new DefaultObjectFactory()); + + var fakeFiles = new List + { + "config1.yml", + "config2.yml" + }; + + var expected = new List + { + new() {BaseUrl = "1"}, + new() {BaseUrl = "2"}, + new() {BaseUrl = "3"} + }; + + var actual = loader.LoadMany(fakeFiles, "sonarr").ToList(); + + actual.Should().BeEquivalentTo(expected); + actualActiveConfigs.Should().BeEquivalentTo(expected); + } + } +} diff --git a/src/Trash.Tests/Config/Data/Load_UsingStream_CorrectParsing.yml b/src/Trash.Tests/Config/Data/Load_UsingStream_CorrectParsing.yml new file mode 100644 index 00000000..8a167c41 --- /dev/null +++ b/src/Trash.Tests/Config/Data/Load_UsingStream_CorrectParsing.yml @@ -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 diff --git a/src/Trash.Tests/Extensions/DictionaryExtensionsTest.cs b/src/Trash.Tests/Extensions/DictionaryExtensionsTest.cs new file mode 100644 index 00000000..f3e8fabe --- /dev/null +++ b/src/Trash.Tests/Extensions/DictionaryExtensionsTest.cs @@ -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 {{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(); + var theValue = dict.GetOrCreate(100); + dict.Should().HaveCount(1); + dict.Should().Contain(100, theValue); + } + + [Test] + public void GetOrDefault_ItemExists_ReturnExistingItem() + { + } + } +} diff --git a/src/Trash.Tests/Sonarr/Guide/Data/include_preferred_when_renaming.md b/src/Trash.Tests/Sonarr/Guide/Data/include_preferred_when_renaming.md new file mode 100644 index 00000000..8e0590ad --- /dev/null +++ b/src/Trash.Tests/Sonarr/Guide/Data/include_preferred_when_renaming.md @@ -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 +``` diff --git a/src/Trash.Tests/Sonarr/Guide/Data/strict_negative_scores.md b/src/Trash.Tests/Sonarr/Guide/Data/strict_negative_scores.md new file mode 100644 index 00000000..cc566ede --- /dev/null +++ b/src/Trash.Tests/Sonarr/Guide/Data/strict_negative_scores.md @@ -0,0 +1,13 @@ +# Test Release Profile + +This score is negative [-1] + +``` +abc +``` + +This score is positive [0] + +``` +xyz +``` diff --git a/src/Trash.Tests/Sonarr/Guide/Data/test_parse_markdown_complete_doc.md b/src/Trash.Tests/Sonarr/Guide/Data/test_parse_markdown_complete_doc.md new file mode 100644 index 00000000..f5ff2902 --- /dev/null +++ b/src/Trash.Tests/Sonarr/Guide/Data/test_parse_markdown_complete_doc.md @@ -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 +``` \ No newline at end of file diff --git a/src/Trash.Tests/Sonarr/Guide/ReleaseProfileParserTest.cs b/src/Trash.Tests/Sonarr/Guide/ReleaseProfileParserTest.cs new file mode 100644 index 00000000..d10ea511 --- /dev/null +++ b/src/Trash.Tests/Sonarr/Guide/ReleaseProfileParserTest.cs @@ -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(); + GuideParser = new ReleaseProfileGuideParser(); + } + + public SonarrConfiguration Config { get; } + public ReleaseProfileGuideParser GuideParser { get; } + public TestData 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 {"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 {"abc"}, + Preferred = new Dictionary> {{0, new List {"xyz"}}} + }); + } + } +} diff --git a/src/Trash.Tests/Sonarr/ReleaseProfileUpdaterTest.cs b/src/Trash.Tests/Sonarr/ReleaseProfileUpdaterTest.cs new file mode 100644 index 00000000..fb1afc76 --- /dev/null +++ b/src/Trash.Tests/Sonarr/ReleaseProfileUpdaterTest.cs @@ -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(); + var parser = Substitute.For(); + var api = Substitute.For(); + var config = Substitute.For(); + + var logic = new ReleaseProfileUpdater(parser, api); + logic.Process(args, config); + + parser.DidNotReceive().GetMarkdownData(Arg.Any()); + } + + [Test] + public void ProcessReleaseProfile_SingleProfilePreview() + { + var parser = Substitute.For(); + var api = Substitute.For(); + var config = Substitute.For(); + var args = Substitute.For(); + + 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"); + } + } +} diff --git a/src/Trash.Tests/Trash.Tests.csproj b/src/Trash.Tests/Trash.Tests.csproj new file mode 100644 index 00000000..15dfca36 --- /dev/null +++ b/src/Trash.Tests/Trash.Tests.csproj @@ -0,0 +1,10 @@ + + + false + + + + + + + diff --git a/src/Trash/Command/BaseCommand.cs b/src/Trash/Command/BaseCommand.cs new file mode 100644 index 00000000..9c2609d0 --- /dev/null +++ b/src/Trash/Command/BaseCommand.cs @@ -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 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"); + } + } +} diff --git a/src/Trash/Command/ExitCode.cs b/src/Trash/Command/ExitCode.cs new file mode 100644 index 00000000..144451af --- /dev/null +++ b/src/Trash/Command/ExitCode.cs @@ -0,0 +1,8 @@ +namespace Trash.Command +{ + public enum ExitCode + { + Success = 0, + Failure = 1 + } +} diff --git a/src/Trash/Command/IBaseCommand.cs b/src/Trash/Command/IBaseCommand.cs new file mode 100644 index 00000000..bd5269af --- /dev/null +++ b/src/Trash/Command/IBaseCommand.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Trash.Command +{ + public interface IBaseCommand + { + bool Preview { get; } + bool Debug { get; } + List? Config { get; } + } +} diff --git a/src/Trash/CompositionRoot.cs b/src/Trash/CompositionRoot.cs new file mode 100644 index 00000000..f6639fcc --- /dev/null +++ b/src/Trash/CompositionRoot.cs @@ -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() + // .As() + // .InstancePerLifetimeScope(); + // + // builder.Register(context => + // { + // var c = context.Resolve(); + // return t => c.Resolve(t); + // }); + // + // builder.RegisterAssemblyTypes(typeof(CompositionRoot).GetTypeInfo().Assembly).AsImplementedInterfaces(); + // } + + // private static void RegisterConfiguration(ContainerBuilder builder) + // where T : BaseConfiguration + // { + // + // builder.Register(ctx => + // { + // var selector = ctx.Resolve>(); + // 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() + // .AsSelf(); + // } + + private static void SonarrRegistrations(ContainerBuilder builder) + { + builder.RegisterType().As(); + + // Release Profile Support + builder.RegisterType(); + builder.RegisterType().As(); + + // Quality Definition Support + builder.RegisterType(); + builder.RegisterType().As(); + } + + private static void RadarrRegistrations(ContainerBuilder builder) + { + builder.RegisterType().As(); + + // Quality Definition Support + builder.RegisterType(); + builder.RegisterType().As(); + } + + public static IContainer Setup() + { + var builder = new ContainerBuilder(); + + builder.RegisterType().As(); + + // Configuration + builder.RegisterType().As(); + 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(); + } + } +} diff --git a/src/Trash/Config/BaseConfiguration.cs b/src/Trash/Config/BaseConfiguration.cs new file mode 100644 index 00000000..7c282aee --- /dev/null +++ b/src/Trash/Config/BaseConfiguration.cs @@ -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(); + } +} diff --git a/src/Trash/Config/ConfigurationException.cs b/src/Trash/Config/ConfigurationException.cs new file mode 100644 index 00000000..919a1d72 --- /dev/null +++ b/src/Trash/Config/ConfigurationException.cs @@ -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; } + } +} diff --git a/src/Trash/Config/ConfigurationLoader.cs b/src/Trash/Config/ConfigurationLoader.cs new file mode 100644 index 00000000..6d734cf1 --- /dev/null +++ b/src/Trash/Config/ConfigurationLoader.cs @@ -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 : IConfigurationLoader + where T : BaseConfiguration + { + private readonly IConfigurationProvider _configProvider; + private readonly IDeserializer _deserializer; + private readonly IFileSystem _fileSystem; + + public ConfigurationLoader(IConfigurationProvider 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 Load(string configPath, string configSection) + { + using var stream = _fileSystem.File.OpenText(configPath); + return LoadFromStream(stream, configSection); + } + + public IEnumerable LoadFromStream(TextReader stream, string configSection) + { + var parser = new Parser(stream); + parser.Consume(); + parser.Consume(); + parser.Consume(); + + var configs = new List(); + while (parser.TryConsume(out var key)) + { + if (key.Value == configSection) + { + configs = _deserializer.Deserialize>(parser); + } + + parser.SkipThisAndNestedEvents(); + } + + if (configs.Count == 0) + { + throw new ConfigurationException(configSection, typeof(T)); + } + + return configs; + } + + public IEnumerable LoadMany(IEnumerable configFiles, string configSection) + { + foreach (var config in configFiles.SelectMany(file => Load(file, configSection))) + { + _configProvider.ActiveConfiguration = config; + yield return config; + } + } + } +} diff --git a/src/Trash/Config/ConfigurationProvider.cs b/src/Trash/Config/ConfigurationProvider.cs new file mode 100644 index 00000000..95e6ab11 --- /dev/null +++ b/src/Trash/Config/ConfigurationProvider.cs @@ -0,0 +1,8 @@ +namespace Trash.Config +{ + internal class ConfigurationProvider : IConfigurationProvider + where T : BaseConfiguration + { + public T? ActiveConfiguration { get; set; } + } +} diff --git a/src/Trash/Config/IConfigurationLoader.cs b/src/Trash/Config/IConfigurationLoader.cs new file mode 100644 index 00000000..1c1c4de9 --- /dev/null +++ b/src/Trash/Config/IConfigurationLoader.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.IO; + +namespace Trash.Config +{ + public interface IConfigurationLoader + where T : BaseConfiguration + { + IEnumerable Load(string propertyName, string configSection); + IEnumerable LoadFromStream(TextReader stream, string configSection); + IEnumerable LoadMany(IEnumerable configFiles, string configSection); + } +} diff --git a/src/Trash/Config/IConfigurationProvider.cs b/src/Trash/Config/IConfigurationProvider.cs new file mode 100644 index 00000000..c4f248a6 --- /dev/null +++ b/src/Trash/Config/IConfigurationProvider.cs @@ -0,0 +1,8 @@ +namespace Trash.Config +{ + public interface IConfigurationProvider + where T : BaseConfiguration + { + T? ActiveConfiguration { get; set; } + } +} diff --git a/src/Trash/Config/ObjectFactory.cs b/src/Trash/Config/ObjectFactory.cs new file mode 100644 index 00000000..2d610b45 --- /dev/null +++ b/src/Trash/Config/ObjectFactory.cs @@ -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); + } + } +} diff --git a/src/Trash/Extensions/DictionaryExtensions.cs b/src/Trash/Extensions/DictionaryExtensions.cs new file mode 100644 index 00000000..918992f5 --- /dev/null +++ b/src/Trash/Extensions/DictionaryExtensions.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace Trash.Extensions +{ + public static class DictionaryExtensions + { + public static TValue GetOrCreate(this IDictionary 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(this IDictionary dict, TKey key) + where TValue : struct + { + if (!dict.TryGetValue(key, out var val)) + { + val = default; + dict.Add(key, val); + } + + return val; + } + } +} diff --git a/src/Trash/Extensions/StringExtensions.cs b/src/Trash/Extensions/StringExtensions.cs new file mode 100644 index 00000000..4ff424c8 --- /dev/null +++ b/src/Trash/Extensions/StringExtensions.cs @@ -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); + } + } +} diff --git a/src/Trash/Extensions/YamlDotNetExtensions.cs b/src/Trash/Extensions/YamlDotNetExtensions.cs new file mode 100644 index 00000000..ed8a8389 --- /dev/null +++ b/src/Trash/Extensions/YamlDotNetExtensions.cs @@ -0,0 +1,22 @@ +using JetBrains.Annotations; +using YamlDotNet.Serialization; + +namespace Trash.Extensions +{ + public static class YamlDotNetExtensions + { + public static T? DeserializeType(this IDeserializer deserializer, string data) + where T : class + { + var extractor = deserializer.Deserialize>(data); + return extractor.RootObject; + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private class RootExtractor + where T : class + { + public T? RootObject { get; } + } + } +} diff --git a/src/Trash/Program.cs b/src/Trash/Program.cs new file mode 100644 index 00000000..51e579f0 --- /dev/null +++ b/src/Trash/Program.cs @@ -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 Main() + { + _container = CompositionRoot.Setup(); + return await new CliApplicationBuilder() + .AddCommandsFromThisAssembly() + .SetExecutableName(ThisAssembly.AssemblyName) + .SetVersion(ThisAssembly.AssemblyInformationalVersion) + .UseTypeActivator(type => _container.Resolve(type)) + .Build() + .RunAsync(); + } + } +} diff --git a/src/Trash/Radarr/Api/IRadarrApi.cs b/src/Trash/Radarr/Api/IRadarrApi.cs new file mode 100644 index 00000000..8e993563 --- /dev/null +++ b/src/Trash/Radarr/Api/IRadarrApi.cs @@ -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> GetQualityDefinition(); + Task> UpdateQualityDefinition(List newQuality); + } +} diff --git a/src/Trash/Radarr/Api/Objects/RadarrQualityDefinitionItem.cs b/src/Trash/Radarr/Api/Objects/RadarrQualityDefinitionItem.cs new file mode 100644 index 00000000..9cd7e6c4 --- /dev/null +++ b/src/Trash/Radarr/Api/Objects/RadarrQualityDefinitionItem.cs @@ -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; } + } +} diff --git a/src/Trash/Radarr/Api/RadarrApi.cs b/src/Trash/Radarr/Api/RadarrApi.cs new file mode 100644 index 00000000..8b2a9c94 --- /dev/null +++ b/src/Trash/Radarr/Api/RadarrApi.cs @@ -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 _config; + + public RadarrApi(IConfigurationProvider config) + { + _config = config; + } + + public async Task> GetQualityDefinition() + { + return await BaseUrl() + .AppendPathSegment("qualitydefinition") + .GetJsonAsync>(); + } + + public async Task> UpdateQualityDefinition( + List newQuality) + { + return await BaseUrl() + .AppendPathSegment("qualityDefinition/update") + .PutJsonAsync(newQuality) + .ReceiveJson>(); + } + + private string BaseUrl() + { + if (_config.ActiveConfiguration == null) + { + throw new InvalidOperationException("No active configuration available for API method"); + } + + return _config.ActiveConfiguration.BuildUrl(); + } + } +} diff --git a/src/Trash/Radarr/IRadarrCommand.cs b/src/Trash/Radarr/IRadarrCommand.cs new file mode 100644 index 00000000..b946aeb1 --- /dev/null +++ b/src/Trash/Radarr/IRadarrCommand.cs @@ -0,0 +1,8 @@ +using Trash.Command; + +namespace Trash.Radarr +{ + public interface IRadarrCommand : IBaseCommand + { + } +} diff --git a/src/Trash/Radarr/QualityDefinition/IRadarrQualityDefinitionGuideParser.cs b/src/Trash/Radarr/QualityDefinition/IRadarrQualityDefinitionGuideParser.cs new file mode 100644 index 00000000..f1981bb3 --- /dev/null +++ b/src/Trash/Radarr/QualityDefinition/IRadarrQualityDefinitionGuideParser.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Trash.Radarr.QualityDefinition +{ + public interface IRadarrQualityDefinitionGuideParser + { + Task GetMarkdownData(); + IDictionary> ParseMarkdown(string markdown); + } +} diff --git a/src/Trash/Radarr/QualityDefinition/RadarrQualityData.cs b/src/Trash/Radarr/QualityDefinition/RadarrQualityData.cs new file mode 100644 index 00000000..4b160792 --- /dev/null +++ b/src/Trash/Radarr/QualityDefinition/RadarrQualityData.cs @@ -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; } + } +} diff --git a/src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionGuideParser.cs b/src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionGuideParser.cs new file mode 100644 index 00000000..5ba3aefc --- /dev/null +++ b/src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionGuideParser.cs @@ -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 GetMarkdownData() + { + return await + "https://raw.githubusercontent.com/TRaSH-/Guides/master/docs/Radarr/V3/Radarr-Quality-Settings-File-Size.md" + .GetStringAsync(); + } + + public IDictionary> ParseMarkdown(string markdown) + { + var results = new Dictionary>(); + List? 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; + } + } +} diff --git a/src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionType.cs b/src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionType.cs new file mode 100644 index 00000000..6df551f7 --- /dev/null +++ b/src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionType.cs @@ -0,0 +1,7 @@ +namespace Trash.Radarr.QualityDefinition +{ + public enum RadarrQualityDefinitionType + { + Movie + } +} diff --git a/src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionUpdater.cs b/src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionUpdater.cs new file mode 100644 index 00000000..73362736 --- /dev/null +++ b/src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionUpdater.cs @@ -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 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 guideQuality) + { + var serverQuality = await _api.GetQualityDefinition(); + await UpdateQualityDefinition(serverQuality, guideQuality); + } + + private async Task UpdateQualityDefinition(IReadOnlyCollection serverQuality, + IEnumerable 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(); + 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); + } + } +} diff --git a/src/Trash/Radarr/RadarrCommand.cs b/src/Trash/Radarr/RadarrCommand.cs new file mode 100644 index 00000000..be39006d --- /dev/null +++ b/src/Trash/Radarr/RadarrCommand.cs @@ -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 _configLoader; + private readonly Func _qualityUpdaterFactory; + + public RadarrCommand( + IConfigurationLoader configLoader, + Func 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(); + } + } + } +} diff --git a/src/Trash/Radarr/RadarrConfiguration.cs b/src/Trash/Radarr/RadarrConfiguration.cs new file mode 100644 index 00000000..57654bfa --- /dev/null +++ b/src/Trash/Radarr/RadarrConfiguration.cs @@ -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; + } +} diff --git a/src/Trash/Sonarr/Api/ISonarrApi.cs b/src/Trash/Sonarr/Api/ISonarrApi.cs new file mode 100644 index 00000000..35127f67 --- /dev/null +++ b/src/Trash/Sonarr/Api/ISonarrApi.cs @@ -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 GetVersion(); + Task> GetTags(); + Task CreateTag(string tag); + Task> GetReleaseProfiles(); + Task UpdateReleaseProfile(SonarrReleaseProfile profileToUpdate); + Task CreateReleaseProfile(SonarrReleaseProfile newProfile); + Task> GetQualityDefinition(); + Task> UpdateQualityDefinition(List newQuality); + } +} diff --git a/src/Trash/Sonarr/Api/Objects/SonarrQualityDefinitionItem.cs b/src/Trash/Sonarr/Api/Objects/SonarrQualityDefinitionItem.cs new file mode 100644 index 00000000..e2153651 --- /dev/null +++ b/src/Trash/Sonarr/Api/Objects/SonarrQualityDefinitionItem.cs @@ -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; } + } +} diff --git a/src/Trash/Sonarr/Api/Objects/SonarrReleaseProfile.cs b/src/Trash/Sonarr/Api/Objects/SonarrReleaseProfile.cs new file mode 100644 index 00000000..7b90263a --- /dev/null +++ b/src/Trash/Sonarr/Api/Objects/SonarrReleaseProfile.cs @@ -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 Preferred { get; set; } = new(); + public bool IncludePreferredWhenRenaming { get; set; } + public int IndexerId { get; set; } + public List Tags { get; set; } = new(); + } +} diff --git a/src/Trash/Sonarr/Api/Objects/SonarrTag.cs b/src/Trash/Sonarr/Api/Objects/SonarrTag.cs new file mode 100644 index 00000000..025de1c7 --- /dev/null +++ b/src/Trash/Sonarr/Api/Objects/SonarrTag.cs @@ -0,0 +1,8 @@ +namespace Trash.Sonarr.Api.Objects +{ + public class SonarrTag + { + public string Label { get; set; } = ""; + public int Id { get; set; } + } +} diff --git a/src/Trash/Sonarr/Api/SonarrApi.cs b/src/Trash/Sonarr/Api/SonarrApi.cs new file mode 100644 index 00000000..95c9e48b --- /dev/null +++ b/src/Trash/Sonarr/Api/SonarrApi.cs @@ -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 _config; + + public SonarrApi(IConfigurationProvider 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 GetVersion() + { + dynamic data = await BaseUrl() + .AppendPathSegment("system/status") + .GetJsonAsync(); + return new Version(data.version); + } + + public async Task> GetTags() + { + return await BaseUrl() + .AppendPathSegment("tag") + .GetJsonAsync>(); + } + + public async Task CreateTag(string tag) + { + return await BaseUrl() + .AppendPathSegment("tag") + .PostJsonAsync(new {label = tag}) + .ReceiveJson(); + } + + public async Task> GetReleaseProfiles() + { + return await BaseUrl() + .AppendPathSegment("releaseprofile") + .GetJsonAsync>(); + } + + public async Task UpdateReleaseProfile(SonarrReleaseProfile profileToUpdate) + { + await BaseUrl() + .AppendPathSegment($"releaseprofile/{profileToUpdate.Id}") + .PutJsonAsync(profileToUpdate); + } + + public async Task CreateReleaseProfile(SonarrReleaseProfile newProfile) + { + return await BaseUrl() + .AppendPathSegment("releaseprofile") + .PostJsonAsync(newProfile) + .ReceiveJson(); + } + + public async Task> GetQualityDefinition() + { + return await BaseUrl() + .AppendPathSegment("qualitydefinition") + .GetJsonAsync>(); + } + + public async Task> UpdateQualityDefinition( + List newQuality) + { + return await BaseUrl() + .AppendPathSegment("qualityDefinition/update") + .PutJsonAsync(newQuality) + .ReceiveJson>(); + } + } +} diff --git a/src/Trash/Sonarr/ISonarrCommand.cs b/src/Trash/Sonarr/ISonarrCommand.cs new file mode 100644 index 00000000..b6a94431 --- /dev/null +++ b/src/Trash/Sonarr/ISonarrCommand.cs @@ -0,0 +1,8 @@ +using Trash.Command; + +namespace Trash.Sonarr +{ + public interface ISonarrCommand : IBaseCommand + { + } +} diff --git a/src/Trash/Sonarr/QualityDefinition/ISonarrQualityDefinitionGuideParser.cs b/src/Trash/Sonarr/QualityDefinition/ISonarrQualityDefinitionGuideParser.cs new file mode 100644 index 00000000..d6880978 --- /dev/null +++ b/src/Trash/Sonarr/QualityDefinition/ISonarrQualityDefinitionGuideParser.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Trash.Sonarr.QualityDefinition +{ + public interface ISonarrQualityDefinitionGuideParser + { + Task GetMarkdownData(); + IDictionary> ParseMarkdown(string markdown); + } +} diff --git a/src/Trash/Sonarr/QualityDefinition/SonarrQualityData.cs b/src/Trash/Sonarr/QualityDefinition/SonarrQualityData.cs new file mode 100644 index 00000000..4fe7a8d1 --- /dev/null +++ b/src/Trash/Sonarr/QualityDefinition/SonarrQualityData.cs @@ -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; } + } +} diff --git a/src/Trash/Sonarr/QualityDefinition/SonarrQualityDefinitionGuideParser.cs b/src/Trash/Sonarr/QualityDefinition/SonarrQualityDefinitionGuideParser.cs new file mode 100644 index 00000000..c8537f22 --- /dev/null +++ b/src/Trash/Sonarr/QualityDefinition/SonarrQualityDefinitionGuideParser.cs @@ -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 GetMarkdownData() + { + return await + "https://raw.githubusercontent.com/TRaSH-/Guides/master/docs/Sonarr/V3/Sonarr-Quality-Settings-File-Size.md" + .GetStringAsync(); + } + + public IDictionary> ParseMarkdown(string markdown) + { + var results = new Dictionary>(); + List? 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; + } + } +} diff --git a/src/Trash/Sonarr/QualityDefinition/SonarrQualityDefinitionType.cs b/src/Trash/Sonarr/QualityDefinition/SonarrQualityDefinitionType.cs new file mode 100644 index 00000000..8af7dbb4 --- /dev/null +++ b/src/Trash/Sonarr/QualityDefinition/SonarrQualityDefinitionType.cs @@ -0,0 +1,9 @@ +namespace Trash.Sonarr.QualityDefinition +{ + public enum SonarrQualityDefinitionType + { + Anime, + Series, + Hybrid + } +} diff --git a/src/Trash/Sonarr/QualityDefinition/SonarrQualityDefinitionUpdater.cs b/src/Trash/Sonarr/QualityDefinition/SonarrQualityDefinitionUpdater.cs new file mode 100644 index 00000000..bfbaaaf0 --- /dev/null +++ b/src/Trash/Sonarr/QualityDefinition/SonarrQualityDefinitionUpdater.cs @@ -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 BuildHybridQuality(List anime, + List 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(); + 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 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 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 guideQuality) + { + var serverQuality = await _api.GetQualityDefinition(); + await UpdateQualityDefinition(serverQuality, guideQuality); + } + + private async Task UpdateQualityDefinition(IReadOnlyCollection serverQuality, + IEnumerable 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(); + 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); + } + } +} diff --git a/src/Trash/Sonarr/ReleaseProfile/IReleaseProfileGuideParser.cs b/src/Trash/Sonarr/ReleaseProfile/IReleaseProfileGuideParser.cs new file mode 100644 index 00000000..75f2860a --- /dev/null +++ b/src/Trash/Sonarr/ReleaseProfile/IReleaseProfileGuideParser.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Trash.Sonarr.ReleaseProfile +{ + public interface IReleaseProfileGuideParser + { + Task GetMarkdownData(ReleaseProfileType profileName); + IDictionary ParseMarkdown(ReleaseProfileConfig config, string markdown); + } +} diff --git a/src/Trash/Sonarr/ReleaseProfile/ProfileData.cs b/src/Trash/Sonarr/ReleaseProfile/ProfileData.cs new file mode 100644 index 00000000..7dc2813c --- /dev/null +++ b/src/Trash/Sonarr/ReleaseProfile/ProfileData.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace Trash.Sonarr.ReleaseProfile +{ + public class ProfileData + { + public List Required { get; } = new(); + public List Ignored { get; } = new(); + public Dictionary> 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; } + } +} diff --git a/src/Trash/Sonarr/ReleaseProfile/ReleaseProfileGuideParser.cs b/src/Trash/Sonarr/ReleaseProfile/ReleaseProfileGuideParser.cs new file mode 100644 index 00000000..fd05083d --- /dev/null +++ b/src/Trash/Sonarr/ReleaseProfile/ReleaseProfileGuideParser.cs @@ -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 _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 GetMarkdownData(ReleaseProfileType profileName) + { + return await BuildUrl(profileName).GetStringAsync(); + } + + public IDictionary ParseMarkdown(ReleaseProfileConfig config, string markdown) + { + var results = new Dictionary(); + 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 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 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; + } + } + } +} diff --git a/src/Trash/Sonarr/ReleaseProfile/ReleaseProfileType.cs b/src/Trash/Sonarr/ReleaseProfile/ReleaseProfileType.cs new file mode 100644 index 00000000..40296749 --- /dev/null +++ b/src/Trash/Sonarr/ReleaseProfile/ReleaseProfileType.cs @@ -0,0 +1,8 @@ +namespace Trash.Sonarr.ReleaseProfile +{ + public enum ReleaseProfileType + { + Anime, + Series + } +} diff --git a/src/Trash/Sonarr/ReleaseProfile/ReleaseProfileUpdater.cs b/src/Trash/Sonarr/ReleaseProfile/ReleaseProfileUpdater.cs new file mode 100644 index 00000000..e44d3d79 --- /dev/null +++ b/src/Trash/Sonarr/ReleaseProfile/ReleaseProfileUpdater.cs @@ -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 sonarrTags, IEnumerable 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 profiles, string profileName) + { + return profiles.FirstOrDefault(p => p.Name == profileName); + } + + private static void SetupProfileRequestObject(SonarrReleaseProfile profileToUpdate, ProfileData profile, + List 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 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 tagIds) + { + var newProfile = new SonarrReleaseProfile + { + Name = title, + Enabled = true + }; + + SetupProfileRequestObject(newProfile, profile, tagIds); + await _api.CreateReleaseProfile(newProfile); + } + + private async Task ProcessReleaseProfiles(IDictionary profiles, + ReleaseProfileConfig profile) + { + await DoVersionEnforcement(); + + List 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); + } + } + } +} diff --git a/src/Trash/Sonarr/ReleaseProfile/Utils.cs b/src/Trash/Sonarr/ReleaseProfile/Utils.cs new file mode 100644 index 00000000..06b85ec2 --- /dev/null +++ b/src/Trash/Sonarr/ReleaseProfile/Utils.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Trash.Sonarr.ReleaseProfile +{ + using ProfileDataCollection = IDictionary; + + 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 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(""); + } + } + } +} diff --git a/src/Trash/Sonarr/SonarrCommand.cs b/src/Trash/Sonarr/SonarrCommand.cs new file mode 100644 index 00000000..fa12af0f --- /dev/null +++ b/src/Trash/Sonarr/SonarrCommand.cs @@ -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 _configLoader; + private readonly Func _profileUpdaterFactory; + private readonly Func _qualityUpdaterFactory; + + public SonarrCommand( + IConfigurationLoader configLoader, + Func profileUpdaterFactory, + Func 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(); + } + } + } +} diff --git a/src/Trash/Sonarr/SonarrConfiguration.cs b/src/Trash/Sonarr/SonarrConfiguration.cs new file mode 100644 index 00000000..e7461233 --- /dev/null +++ b/src/Trash/Sonarr/SonarrConfiguration.cs @@ -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 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 Tags { get; init; } = new(); + } +} diff --git a/src/Trash/Trash.csproj b/src/Trash/Trash.csproj new file mode 100644 index 00000000..3c49be89 --- /dev/null +++ b/src/Trash/Trash.csproj @@ -0,0 +1,20 @@ + + + Exe + Trash + trash + + + + + + + + + + + + + + + diff --git a/src/Trash/YamlDotNet/YamlNullableEnumTypeConverter.cs b/src/Trash/YamlDotNet/YamlNullableEnumTypeConverter.cs new file mode 100644 index 00000000..ec48776b --- /dev/null +++ b/src/Trash/YamlDotNet/YamlNullableEnumTypeConverter.cs @@ -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(out var @event)) + { + if (NodeIsNull(@event)) + { + parser.SkipThisAndNestedEvents(); + return null; + } + } + + var scalar = parser.Consume(); + 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; + } + } +} diff --git a/src/Trash/trash.yml b/src/Trash/trash.yml new file mode 100644 index 00000000..a8f4db78 --- /dev/null +++ b/src/Trash/trash.yml @@ -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 diff --git a/src/TrashUpdater.sln b/src/TrashUpdater.sln new file mode 100644 index 00000000..c62d4613 --- /dev/null +++ b/src/TrashUpdater.sln @@ -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 diff --git a/src/TrashUpdater.sln.DotSettings b/src/TrashUpdater.sln.DotSettings new file mode 100644 index 00000000..664a827f --- /dev/null +++ b/src/TrashUpdater.sln.DotSettings @@ -0,0 +1,3 @@ + + True + True \ No newline at end of file diff --git a/version.json b/version.json new file mode 100644 index 00000000..5883d5dc --- /dev/null +++ b/version.json @@ -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" + } +} \ No newline at end of file diff --git a/wiki/.markdownlint.json b/wiki/.markdownlint.json new file mode 100644 index 00000000..a41cef07 --- /dev/null +++ b/wiki/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "extends": "../.markdownlint.json", + "first-line-heading": false +} diff --git a/wiki/Command-Line-Reference.md b/wiki/Command-Line-Reference.md new file mode 100644 index 00000000..3fd77b6c --- /dev/null +++ b/wiki/Command-Line-Reference.md @@ -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. diff --git a/wiki/Configuration-Reference.md b/wiki/Configuration-Reference.md new file mode 100644 index 00000000..b0df1a29 --- /dev/null +++ b/wiki/Configuration-Reference.md @@ -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)
+ The base URL of your Sonarr instance. Basically this is the URL you bookmark to get to the front + page. + +- `api_key` (Required)
+ 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)
+ 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)
+ 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)
+ The base URL of your Radarr instance. Basically this is the URL you bookmark to get to the front + page. + +- `api_key` (Required)
+ 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)
+ 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 +``` diff --git a/wiki/Home.md b/wiki/Home.md new file mode 100644 index 00000000..9c73dd0d --- /dev/null +++ b/wiki/Home.md @@ -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 diff --git a/wiki/Python-Migration-Guide.md b/wiki/Python-Migration-Guide.md new file mode 100644 index 00000000..b78894ee --- /dev/null +++ b/wiki/Python-Migration-Guide.md @@ -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`. diff --git a/wiki/TRaSH-Guide-Structural-Guidelines.md b/wiki/TRaSH-Guide-Structural-Guidelines.md new file mode 100644 index 00000000..fcecd10a --- /dev/null +++ b/wiki/TRaSH-Guide-Structural-Guidelines.md @@ -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**
+ 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**
+ The API term for "Must Not Contain" + +* **Required**
+ The API term for "Must Contain" + +* **Category**
+ 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**
+ 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.