├── .DS_Store ├── .github ├── .DS_Store └── workflows │ └── ci.yml ├── SonarrAutoImport ├── .DS_Store ├── SonarrAutoImport.csproj ├── Program.cs ├── Settings.cs ├── LogHandler.cs └── SonarrImporter.cs ├── global.json ├── .idea └── .idea.SonarrAutoImport │ └── .idea │ ├── encodings.xml │ ├── vcs.xml │ ├── indexLayout.xml │ └── .gitignore ├── publish.sh ├── SonarrAutoImport.sln ├── Settings.json ├── README.md └── .gitignore /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Webreaper/SonarrAutoImport/HEAD/.DS_Store -------------------------------------------------------------------------------- /.github/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Webreaper/SonarrAutoImport/HEAD/.github/.DS_Store -------------------------------------------------------------------------------- /SonarrAutoImport/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Webreaper/SonarrAutoImport/HEAD/SonarrAutoImport/.DS_Store -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "9.0.0", 4 | "rollForward": "latestMajor", 5 | "allowPrerelease": true 6 | } 7 | } -------------------------------------------------------------------------------- /.idea/.idea.SonarrAutoImport/.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/.idea.SonarrAutoImport/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/.idea.SonarrAutoImport/.idea/indexLayout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | dotnet publish -r win-x64 -c Release /p:PublishSingleFile=true /p:PublishTrimmed=false /p:Version=1.5.0 2 | dotnet publish -r osx-x64 -c Release /p:PublishSingleFile=true /p:PublishTrimmed=false /p:Version=1.5.0 3 | dotnet publish -r linux-x64 -c Release /p:PublishSingleFile=true /p:PublishTrimmed=false /p:Version=1.5.0 4 | -------------------------------------------------------------------------------- /.idea/.idea.SonarrAutoImport/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Rider ignored files 5 | /modules.xml 6 | /.idea.SonarrAutoImport.iml 7 | /contentModel.xml 8 | /projectSettingsUpdater.xml 9 | # Editor-based HTTP Client requests 10 | /httpRequests/ 11 | # Datasource local storage ignored files 12 | /dataSources/ 13 | /dataSources.local.xml 14 | -------------------------------------------------------------------------------- /SonarrAutoImport/SonarrAutoImport.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net9.0 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /SonarrAutoImport.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SonarrAutoImport", "SonarrAutoImport\SonarrAutoImport.csproj", "{28315B8A-CC85-421E-9C0D-C21ED96DC0AE}" 5 | EndProject 6 | Global 7 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 8 | Debug|Any CPU = Debug|Any CPU 9 | Release|Any CPU = Release|Any CPU 10 | EndGlobalSection 11 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 12 | {28315B8A-CC85-421E-9C0D-C21ED96DC0AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 13 | {28315B8A-CC85-421E-9C0D-C21ED96DC0AE}.Debug|Any CPU.Build.0 = Debug|Any CPU 14 | {28315B8A-CC85-421E-9C0D-C21ED96DC0AE}.Release|Any CPU.ActiveCfg = Release|Any CPU 15 | {28315B8A-CC85-421E-9C0D-C21ED96DC0AE}.Release|Any CPU.Build.0 = Release|Any CPU 16 | EndGlobalSection 17 | EndGlobal 18 | -------------------------------------------------------------------------------- /Settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "radarr": { 3 | "url" : "http://192.168.1.30:7878", 4 | "apiKey" : "87a3763f384241c2a33dfeea625681c5", 5 | "mappingPath" : "/downloads/", 6 | "downloadsFolder" : "/volume1/video/FilmDownloads", 7 | "importMode" : "Move", 8 | "timeoutSecs" : "5" 9 | }, 10 | "sonarr": { 11 | "url" : "http://192.168.1.30:8989", 12 | "apiKey" : "8727e07296064e369Xcb05d3c11b9532", 13 | "mappingPath" : "/downloads/", 14 | "downloadsFolder" : "/volume1/video/Downloads", 15 | "importMode" : "Copy", 16 | "timeoutSecs" : "5", 17 | "trimFolders" : "true", 18 | "transforms" : [ 19 | { 20 | "search" : "Gardeners World 2019", 21 | "replace" : "Gardeners World Series 52" 22 | }, 23 | { 24 | "search" : "Beechgrove 2019", 25 | "replace" : "The Beechgrove Garden Series 41" 26 | }, 27 | { 28 | "search" : "Natural World 2019-2020", 29 | "replace" : "Natural World Series 39" 30 | }, 31 | { 32 | "search" : "Natural World 2016-2017", 33 | "replace" : "Natural World Series 36" 34 | }, 35 | { 36 | "search" : "Location.Location.Location.S31E", 37 | "replace" : "Location.Location.Location.S33E" 38 | }, 39 | { 40 | "search" : "Autumnwatch 2019 - ", 41 | "replace" : "Autumnwatch S2019E" 42 | }, 43 | { 44 | "search" : "Series (\\d+) - ", 45 | "replace" : "S$1E" 46 | } 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /SonarrAutoImport/Program.cs: -------------------------------------------------------------------------------- 1 | 2 | using System; 3 | using System.IO; 4 | using CommandLine; 5 | using SonarrAuto.Logging; 6 | 7 | namespace SonarrAuto 8 | { 9 | class Program 10 | { 11 | 12 | public class Options 13 | { 14 | [Option('v', "verbose", HelpText = "Run logging in Verbose Mode")] 15 | public bool Verbose { get; set; } 16 | 17 | [Option('d', "dry-run", Required = false, Default = false, HelpText = "Dry run - change nothing.")] 18 | public bool DryRun { get; set; } 19 | 20 | [Value(0, MetaName = "Settings Path", HelpText = "Path to settings JSON file (default = app dir)", Required = false)] 21 | public string SettingsPath { get; set; } = "Settings.json"; 22 | }; 23 | 24 | static void Main(string[] args) 25 | { 26 | LogHandler.InitLogs(); 27 | 28 | Parser.Default.ParseArguments(args) 29 | .WithParsed( o => { RunProcess(o); }); 30 | } 31 | 32 | private static void RunProcess(Options o) 33 | { 34 | var settings = Settings.Read(o.SettingsPath); 35 | 36 | if (settings != null) 37 | { 38 | var importer = new Importer(); 39 | 40 | if (settings.lidarr != null) 41 | { 42 | LogHandler.Log("Processing music for Lidarr..."); 43 | importer.ProcessLidarr(settings.lidarr, o.DryRun, o.Verbose, "DownloadedAlbumsScan"); 44 | } 45 | if (settings.sonarr != null) 46 | { 47 | LogHandler.Log("Processing videos for Sonarr..."); 48 | importer.ProcessService(settings.sonarr, o.DryRun, o.Verbose, "DownloadedEpisodesScan"); 49 | } 50 | if (settings.radarr != null) 51 | { 52 | LogHandler.Log("Processing videos for Radarr..."); 53 | importer.ProcessService(settings.radarr, o.DryRun, o.Verbose, "DownloadedMoviesScan"); 54 | } 55 | } 56 | else 57 | LogHandler.LogError($"Settings not found: {o.SettingsPath}"); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /SonarrAutoImport/Settings.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Runtime.Serialization; 5 | using System.Runtime.Serialization.Json; 6 | using System.Text; 7 | 8 | namespace SonarrAuto 9 | { 10 | [DataContract] 11 | public class Transform 12 | { 13 | [DataMember] 14 | public int order { get; set; } 15 | [DataMember] 16 | public string search { get; set; } 17 | [DataMember] 18 | public string replace { get; set; } 19 | } 20 | 21 | [DataContract] 22 | public class ServiceSettings 23 | { 24 | [DataMember] 25 | public string url { get; set; } 26 | [DataMember] 27 | public string downloadsFolder { get; set; } 28 | [DataMember] 29 | public string mappingPath { get; set; } 30 | [DataMember] 31 | public string apiKey { get; set; } 32 | [DataMember] 33 | public List transforms { get; set; } 34 | [DataMember] 35 | public string importMode { get; set; } = "Move"; 36 | [DataMember] 37 | public int timeoutSecs { get; set; } 38 | [DataMember] 39 | public bool trimFolders { get; set; } 40 | } 41 | 42 | [DataContract] 43 | public class Settings 44 | { 45 | [DataMember] 46 | public ServiceSettings sonarr { get; set; } 47 | [DataMember] 48 | public ServiceSettings radarr { get; set; } 49 | [DataMember] 50 | public ServiceSettings lidarr { get; set; } 51 | 52 | [DataMember] 53 | public string logLocation { get; set; } 54 | 55 | public static Settings Read(string path) 56 | { 57 | if (File.Exists(path)) 58 | { 59 | 60 | string json = File.ReadAllText(path); 61 | 62 | var settings = deserialiseJson(json); 63 | 64 | return settings; 65 | } 66 | else 67 | { 68 | return null; 69 | } 70 | } 71 | 72 | private static T deserialiseJson(string json) 73 | { 74 | var instance = Activator.CreateInstance(); 75 | using (var ms = new MemoryStream(Encoding.Unicode.GetBytes(json))) 76 | { 77 | var serializer = new DataContractJsonSerializer(instance.GetType()); 78 | return (T)serializer.ReadObject(ms); 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /SonarrAutoImport/LogHandler.cs: -------------------------------------------------------------------------------- 1 | using Serilog; 2 | using Serilog.Core; 3 | using Serilog.Events; 4 | 5 | namespace SonarrAuto.Logging 6 | { 7 | public static class LogHandler 8 | { 9 | public static bool Verbose { get; set; } = false; 10 | public static bool Trace { get; set; } = false; 11 | private static Logger logger; 12 | private static LoggingLevelSwitch logLevel = new LoggingLevelSwitch(); 13 | private const string template = "[{Timestamp:HH:mm:ss.fff}-{ThreadID}-{Level:u3}] {Message:lj}{NewLine}{Exception}"; 14 | public static Logger Logger { get; } 15 | 16 | public static Logger InitLogs() 17 | { 18 | logLevel.MinimumLevel = Serilog.Events.LogEventLevel.Information; 19 | 20 | if (Verbose) 21 | logLevel.MinimumLevel = Serilog.Events.LogEventLevel.Verbose; 22 | 23 | if (Trace) 24 | logLevel.MinimumLevel = Serilog.Events.LogEventLevel.Debug; 25 | 26 | logger = new LoggerConfiguration() 27 | .MinimumLevel.ControlledBy(logLevel) 28 | .WriteTo.Console(outputTemplate: template) 29 | .WriteTo.File("SonarrImport-.log", outputTemplate: template, 30 | rollingInterval: RollingInterval.Day, 31 | fileSizeLimitBytes: 104857600) 32 | .CreateLogger(); 33 | 34 | logger.Information("=== Sonarr Auto Import Log Started ==="); 35 | logger.Information("LogLevel: {0}", logLevel.MinimumLevel); 36 | 37 | return logger; 38 | } 39 | 40 | public static void EnableDebugLogging(bool enable) 41 | { 42 | if (enable) 43 | logLevel.MinimumLevel = Serilog.Events.LogEventLevel.Debug; 44 | else 45 | logLevel.MinimumLevel = Serilog.Events.LogEventLevel.Verbose; 46 | 47 | logger.Information("LogLevel: {0}", logLevel.MinimumLevel); 48 | } 49 | 50 | public static void LogError(string fmt, params object[] args) 51 | { 52 | logger.Error(fmt, args); 53 | } 54 | 55 | public static void LogWarning(string fmt, params object[] args) 56 | { 57 | logger.Warning(fmt, args); 58 | } 59 | 60 | public static void LogVerbose(string fmt, params object[] args) 61 | { 62 | logger.Verbose(fmt, args); 63 | } 64 | 65 | public static void LogTrace(string fmt, params object[] args) 66 | { 67 | logger.Debug(fmt, args); 68 | } 69 | 70 | public static void Log(string fmt, params object[] args) 71 | { 72 | logger.Information(fmt, args); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SonarrAutoImport 2 | Scan video files and submit them to import into Sonarr & Radarr, Drone-factory style. 3 | 4 | ## Why? 5 | Sonarr's 'Quick Import' is supposed to auto-import files downloaded via other channels that the Download Clients configured in settings. However, it only works if you pass it the full path/folder to a single episode. 6 | I run get_iplayer as a BBC PVR, and want episodes to be auto-imported by Sonarr where possible, so they get moved into Plex without me having to run a Manual Import. 7 | So this utility scans the folder for video files, and for each one found, calls the API to trigger an automatic import. It won't work for all files, since some may be misnamed, but it will automate some of them. 8 | 9 | ## Usage: 10 | Usage has changed as of v1.1. The tool will look for a file Settings.json in the working folder or same folder as the executable. The Json file includes the sonarr and radarr config (either is optional) and the transform rules for Sonarr. 11 | 12 | An example Settings file can be found [here](https://github.com/Webreaper/SonarrAutoImport/blob/master/Settings.json). 13 | 14 | There are also some optional params: 15 | * -v Enables verbose logging (--v for windows users) 16 | * -dry-run will scan the folder for video files, but not call the Sonarr API (--dry-run for windows users) 17 | 18 | ## Sonarr Episode Filename transforms 19 | 20 | For each file that is scanned by the tool for import into Sonarr, prior to running the Sonarr import, all transforms will be run on the filename. Regex is supported. So for example, I have the following: 21 | 22 | ``` 23 | "search" : "Series (\\d+) - ", 24 | "replace" : "S$1E" 25 | ``` 26 | 27 | Which will convert: 28 | 29 | ```Poldark Series 5 - 04.Episode 04.mp4``` 30 | 31 | to 32 | 33 | ```Poldark S5E04.Episode 04.mp4``` 34 | 35 | which will then be correctly imported and scanned by Sonarr. Another example, showing how multiple transforms are applied in-order: 36 | 37 | ``` 38 | "search" : "Gardeners World 2019", 39 | "replace" : "Gardeners World Series 52" 40 | ``` 41 | which, combined with the first tranfrom above, will convert: 42 | 43 | ```Gardeners World 2019 - 24.Episode 24.mp4``` 44 | 45 | to 46 | 47 | ```Gardeners World Series 52 - 24.Episode 24.mp4``` 48 | 49 | to 50 | 51 | ```Gardeners World S52E24.Episode 24.mp4``` 52 | 53 | This makes up for the irritating fact that Sonarr doesn't support user-defined series renames and relies on them being reported centrally and then a) accepted and b) updated in a timely fashion - neither of which are guaranteed. 54 | 55 | ### **downloadsFolder vs. mappingPath** 56 | - **downloadsFolder**: This is the path to the media files that need to be processed from the perspective of the SonarrAutoImport script. 57 | - **mappingPath**: This is the path to the media files from the perspective of Sonarr. 58 | 59 | These two paths should point to the same location. However, in some environments (such as Docker), the same destination might require different paths. Make sure both paths are correctly configured based on the context of your setup. 60 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v2 16 | 17 | - name: Setup .NET 18 | uses: actions/setup-dotnet@v1 19 | with: 20 | dotnet-version: 9.0.x 21 | 22 | - name: Install dependencies 23 | run: dotnet restore 24 | 25 | - name: Build win-x64 26 | run: dotnet publish SonarrAutoImport.sln -c Release --runtime win-x64 --self-contained -o SonarrAutoImport-win-x64 /p:PublishSingleFile=true /p:PublishTrimmed=false /p:AssemblyVersion=1.0.${{ github.run_number }} 27 | 28 | - name: Build linux-x64 29 | run: dotnet publish SonarrAutoImport.sln -c Release --runtime linux-x64 --self-contained -o SonarrAutoImport-linux-x64 /p:PublishSingleFile=true /p:PublishTrimmed=false /p:AssemblyVersion=1.0.${{ github.run_number }} 30 | 31 | - name: Build linux-musl-x64 32 | run: dotnet publish SonarrAutoImport.sln -c Release --runtime linux-musl-x64 --self-contained -o SonarrAutoImport-linux-musl-x64 /p:PublishSingleFile=true /p:PublishTrimmed=false /p:AssemblyVersion=1.0.${{ github.run_number }} 33 | 34 | - name: Build linux-arm64 35 | run: dotnet publish SonarrAutoImport.sln -c Release --runtime linux-arm64 --self-contained -o SonarrAutoImport-linux-arm64 /p:PublishSingleFile=true /p:PublishTrimmed=false /p:AssemblyVersion=1.0.${{ github.run_number }} 36 | 37 | - name: Build linux-musl-arm64 38 | run: dotnet publish SonarrAutoImport.sln -c Release --runtime linux-musl-arm64 --self-contained -o SonarrAutoImport-linux-musl-arm64 /p:PublishSingleFile=true /p:PublishTrimmed=false /p:AssemblyVersion=1.0.${{ github.run_number }} 39 | 40 | - name: Build osx-x64 41 | run: dotnet publish SonarrAutoImport.sln -c Release --runtime osx-x64 --self-contained -o SonarrAutoImport-osx-x64 /p:PublishSingleFile=true /p:PublishTrimmed=false /p:AssemblyVersion=1.0.${{ github.run_number }} 42 | 43 | - name: Pack executables 44 | shell: bash 45 | run: | 46 | 7z a -tzip "./artifacts/SonarrAutoImport-win-x64.zip" "./SonarrAutoImport-win-x64/*" 47 | tar czvf "./artifacts/SonarrAutoImport-linux-x64.tar.gz" "SonarrAutoImport-linux-x64" 48 | tar czvf "./artifacts/SonarrAutoImport-linux-musl-x64.tar.gz" "SonarrAutoImport-linux-musl-x64" 49 | tar czvf "./artifacts/SonarrAutoImport-linux-arm64.tar.gz" "SonarrAutoImport-linux-arm64" 50 | tar czvf "./artifacts/SonarrAutoImport-linux-musl-arm64.tar.gz" "SonarrAutoImport-linux-musl-arm64" 51 | tar czvf "./artifacts/SonarrAutoImport-osx-x64.tar.gz" "SonarrAutoImport-osx-x64" 52 | rm -r "SonarrAutoImport-win-x64" 53 | rm -r "SonarrAutoImport-linux-x64" 54 | rm -r "SonarrAutoImport-linux-musl-x64" 55 | rm -r "SonarrAutoImport-linux-arm64" 56 | rm -r "SonarrAutoImport-linux-musl-arm64" 57 | rm -r "SonarrAutoImport-osx-x64" 58 | 59 | - name: Create the Release 60 | id: create_release 61 | if: ${{ github.event_name == 'push' }} 62 | uses: actions/create-release@v1.1.3 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 65 | with: 66 | tag_name: 1.0.${{ github.run_number }} 67 | release_name: Release 1.0.${{ github.run_number }} 68 | draft: false 69 | 70 | - name: Upload SonarrAutoImport-win-x64.zip 71 | if: ${{ github.event_name == 'push' }} 72 | uses: actions/upload-release-asset@v1.0.2 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | with: 76 | upload_url: ${{ steps.create_release.outputs.upload_url }} 77 | asset_path: ./artifacts/SonarrAutoImport-win-x64.zip 78 | asset_name: SonarrAutoImport-win-x64.zip 79 | asset_content_type: application/zip 80 | 81 | - name: Upload SonarrAutoImport-linux-x64.zip 82 | if: ${{ github.event_name == 'push' }} 83 | uses: actions/upload-release-asset@v1.0.2 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | with: 87 | upload_url: ${{ steps.create_release.outputs.upload_url }} 88 | asset_path: ./artifacts/SonarrAutoImport-linux-x64.tar.gz 89 | asset_name: SonarrAutoImport-linux-x64.tar.gz 90 | asset_content_type: application/gzip 91 | 92 | - name: Upload SonarrAutoImport-linux-musl-x64.zip 93 | if: ${{ github.event_name == 'push' }} 94 | uses: actions/upload-release-asset@v1.0.2 95 | env: 96 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 97 | with: 98 | upload_url: ${{ steps.create_release.outputs.upload_url }} 99 | asset_path: ./artifacts/SonarrAutoImport-linux-musl-x64.tar.gz 100 | asset_name: SonarrAutoImport-linux-musl-x64.tar.gz 101 | asset_content_type: application/gzip 102 | 103 | - name: Upload SonarrAutoImport-linux-arm64.zip 104 | if: ${{ github.event_name == 'push' }} 105 | uses: actions/upload-release-asset@v1.0.2 106 | env: 107 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 108 | with: 109 | upload_url: ${{ steps.create_release.outputs.upload_url }} 110 | asset_path: ./artifacts/SonarrAutoImport-linux-arm64.tar.gz 111 | asset_name: SonarrAutoImport-linux-arm64.tar.gz 112 | asset_content_type: application/gzip 113 | 114 | - name: Upload SonarrAutoImport-linux-musl-arm64.zip 115 | if: ${{ github.event_name == 'push' }} 116 | uses: actions/upload-release-asset@v1.0.2 117 | env: 118 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 119 | with: 120 | upload_url: ${{ steps.create_release.outputs.upload_url }} 121 | asset_path: ./artifacts/SonarrAutoImport-linux-musl-arm64.tar.gz 122 | asset_name: SonarrAutoImport-linux-musl-arm64.tar.gz 123 | asset_content_type: application/gzip 124 | 125 | - name: Upload SonarrAutoImport-osx-x64.zip 126 | if: ${{ github.event_name == 'push' }} 127 | uses: actions/upload-release-asset@v1.0.2 128 | env: 129 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 130 | with: 131 | upload_url: ${{ steps.create_release.outputs.upload_url }} 132 | asset_path: ./artifacts/SonarrAutoImport-osx-x64.tar.gz 133 | asset_name: SonarrAutoImport-osx-x64.tar.gz 134 | asset_content_type: application/gzip 135 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | 33 | # Visual Studio 2015/2017 cache/options directory 34 | .vs/ 35 | # Uncomment if you have tasks that create the project's static files in wwwroot 36 | #wwwroot/ 37 | 38 | # Visual Studio 2017 auto generated files 39 | Generated\ Files/ 40 | 41 | # MSTest test Results 42 | [Tt]est[Rr]esult*/ 43 | [Bb]uild[Ll]og.* 44 | 45 | # NUnit 46 | *.VisualState.xml 47 | TestResult.xml 48 | nunit-*.xml 49 | 50 | # Build Results of an ATL Project 51 | [Dd]ebugPS/ 52 | [Rr]eleasePS/ 53 | dlldata.c 54 | 55 | # Benchmark Results 56 | BenchmarkDotNet.Artifacts/ 57 | 58 | # .NET Core 59 | project.lock.json 60 | project.fragment.lock.json 61 | artifacts/ 62 | 63 | # StyleCop 64 | StyleCopReport.xml 65 | 66 | # Files built by Visual Studio 67 | *_i.c 68 | *_p.c 69 | *_h.h 70 | *.ilk 71 | *.meta 72 | *.obj 73 | *.iobj 74 | *.pch 75 | *.pdb 76 | *.ipdb 77 | *.pgc 78 | *.pgd 79 | *.rsp 80 | *.sbr 81 | *.tlb 82 | *.tli 83 | *.tlh 84 | *.tmp 85 | *.tmp_proj 86 | *_wpftmp.csproj 87 | *.log 88 | *.vspscc 89 | *.vssscc 90 | .builds 91 | *.pidb 92 | *.svclog 93 | *.scc 94 | 95 | # Chutzpah Test files 96 | _Chutzpah* 97 | 98 | # Visual C++ cache files 99 | ipch/ 100 | *.aps 101 | *.ncb 102 | *.opendb 103 | *.opensdf 104 | *.sdf 105 | *.cachefile 106 | *.VC.db 107 | *.VC.VC.opendb 108 | 109 | # Visual Studio profiler 110 | *.psess 111 | *.vsp 112 | *.vspx 113 | *.sap 114 | 115 | # Visual Studio Trace Files 116 | *.e2e 117 | 118 | # TFS 2012 Local Workspace 119 | $tf/ 120 | 121 | # Guidance Automation Toolkit 122 | *.gpState 123 | 124 | # ReSharper is a .NET coding add-in 125 | _ReSharper*/ 126 | *.[Rr]e[Ss]harper 127 | *.DotSettings.user 128 | 129 | # JustCode is a .NET coding add-in 130 | .JustCode 131 | 132 | # TeamCity is a build add-in 133 | _TeamCity* 134 | 135 | # DotCover is a Code Coverage Tool 136 | *.dotCover 137 | 138 | # AxoCover is a Code Coverage Tool 139 | .axoCover/* 140 | !.axoCover/settings.json 141 | 142 | # Visual Studio code coverage results 143 | *.coverage 144 | *.coveragexml 145 | 146 | # NCrunch 147 | _NCrunch_* 148 | .*crunch*.local.xml 149 | nCrunchTemp_* 150 | 151 | # MightyMoose 152 | *.mm.* 153 | AutoTest.Net/ 154 | 155 | # Web workbench (sass) 156 | .sass-cache/ 157 | 158 | # Installshield output folder 159 | [Ee]xpress/ 160 | 161 | # DocProject is a documentation generator add-in 162 | DocProject/buildhelp/ 163 | DocProject/Help/*.HxT 164 | DocProject/Help/*.HxC 165 | DocProject/Help/*.hhc 166 | DocProject/Help/*.hhk 167 | DocProject/Help/*.hhp 168 | DocProject/Help/Html2 169 | DocProject/Help/html 170 | 171 | # Click-Once directory 172 | publish/ 173 | 174 | # Publish Web Output 175 | *.[Pp]ublish.xml 176 | *.azurePubxml 177 | # Note: Comment the next line if you want to checkin your web deploy settings, 178 | # but database connection strings (with potential passwords) will be unencrypted 179 | *.pubxml 180 | *.publishproj 181 | 182 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 183 | # checkin your Azure Web App publish settings, but sensitive information contained 184 | # in these scripts will be unencrypted 185 | PublishScripts/ 186 | 187 | # NuGet Packages 188 | *.nupkg 189 | # NuGet Symbol Packages 190 | *.snupkg 191 | # The packages folder can be ignored because of Package Restore 192 | **/[Pp]ackages/* 193 | # except build/, which is used as an MSBuild target. 194 | !**/[Pp]ackages/build/ 195 | # Uncomment if necessary however generally it will be regenerated when needed 196 | #!**/[Pp]ackages/repositories.config 197 | # NuGet v3's project.json files produces more ignorable files 198 | *.nuget.props 199 | *.nuget.targets 200 | 201 | # Microsoft Azure Build Output 202 | csx/ 203 | *.build.csdef 204 | 205 | # Microsoft Azure Emulator 206 | ecf/ 207 | rcf/ 208 | 209 | # Windows Store app package directories and files 210 | AppPackages/ 211 | BundleArtifacts/ 212 | Package.StoreAssociation.xml 213 | _pkginfo.txt 214 | *.appx 215 | *.appxbundle 216 | *.appxupload 217 | 218 | # Visual Studio cache files 219 | # files ending in .cache can be ignored 220 | *.[Cc]ache 221 | # but keep track of directories ending in .cache 222 | !?*.[Cc]ache/ 223 | 224 | # Others 225 | ClientBin/ 226 | ~$* 227 | *~ 228 | *.dbmdl 229 | *.dbproj.schemaview 230 | *.jfm 231 | *.pfx 232 | *.publishsettings 233 | orleans.codegen.cs 234 | 235 | # Including strong name files can present a security risk 236 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 237 | #*.snk 238 | 239 | # Since there are multiple workflows, uncomment next line to ignore bower_components 240 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 241 | #bower_components/ 242 | 243 | # RIA/Silverlight projects 244 | Generated_Code/ 245 | 246 | # Backup & report files from converting an old project file 247 | # to a newer Visual Studio version. Backup files are not needed, 248 | # because we have git ;-) 249 | _UpgradeReport_Files/ 250 | Backup*/ 251 | UpgradeLog*.XML 252 | UpgradeLog*.htm 253 | ServiceFabricBackup/ 254 | *.rptproj.bak 255 | 256 | # SQL Server files 257 | *.mdf 258 | *.ldf 259 | *.ndf 260 | 261 | # Business Intelligence projects 262 | *.rdl.data 263 | *.bim.layout 264 | *.bim_*.settings 265 | *.rptproj.rsuser 266 | *- [Bb]ackup.rdl 267 | *- [Bb]ackup ([0-9]).rdl 268 | *- [Bb]ackup ([0-9][0-9]).rdl 269 | 270 | # Microsoft Fakes 271 | FakesAssemblies/ 272 | 273 | # GhostDoc plugin setting file 274 | *.GhostDoc.xml 275 | 276 | # Node.js Tools for Visual Studio 277 | .ntvs_analysis.dat 278 | node_modules/ 279 | 280 | # Visual Studio 6 build log 281 | *.plg 282 | 283 | # Visual Studio 6 workspace options file 284 | *.opt 285 | 286 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 287 | *.vbw 288 | 289 | # Visual Studio LightSwitch build output 290 | **/*.HTMLClient/GeneratedArtifacts 291 | **/*.DesktopClient/GeneratedArtifacts 292 | **/*.DesktopClient/ModelManifest.xml 293 | **/*.Server/GeneratedArtifacts 294 | **/*.Server/ModelManifest.xml 295 | _Pvt_Extensions 296 | 297 | # Paket dependency manager 298 | .paket/paket.exe 299 | paket-files/ 300 | 301 | # FAKE - F# Make 302 | .fake/ 303 | 304 | # CodeRush personal settings 305 | .cr/personal 306 | 307 | # Python Tools for Visual Studio (PTVS) 308 | __pycache__/ 309 | *.pyc 310 | 311 | # Cake - Uncomment if you are using it 312 | # tools/** 313 | # !tools/packages.config 314 | 315 | # Tabs Studio 316 | *.tss 317 | 318 | # Telerik's JustMock configuration file 319 | *.jmconfig 320 | 321 | # BizTalk build output 322 | *.btp.cs 323 | *.btm.cs 324 | *.odx.cs 325 | *.xsd.cs 326 | 327 | # OpenCover UI analysis results 328 | OpenCover/ 329 | 330 | # Azure Stream Analytics local run output 331 | ASALocalRun/ 332 | 333 | # MSBuild Binary and Structured Log 334 | *.binlog 335 | 336 | # NVidia Nsight GPU debugger configuration file 337 | *.nvuser 338 | 339 | # MFractors (Xamarin productivity tool) working folder 340 | .mfractor/ 341 | 342 | # Local History for Visual Studio 343 | .localhistory/ 344 | 345 | # BeatPulse healthcheck temp database 346 | healthchecksdb 347 | 348 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 349 | MigrationBackup/ 350 | 351 | # Ionide (cross platform F# VS Code tools) working folder 352 | .ionide/ -------------------------------------------------------------------------------- /SonarrAutoImport/SonarrImporter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.IO; 4 | using System.Collections.Generic; 5 | using System.Runtime.Serialization; 6 | using System.Text.RegularExpressions; 7 | using RestSharp; 8 | using System.Threading; 9 | 10 | namespace SonarrAuto 11 | { 12 | public class Importer 13 | { 14 | private static string[] movieExtensions = { 15 | ".mkv", ".avi", ".wmv", ".mov", ".amv", 16 | ".mp4", ".m4a", ".m4v", ".f4v", ".f4a", ".m4b", ".m4r", ".f4b", 17 | ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv" 18 | }; 19 | 20 | private static string[] musicExtensions = { 21 | ".mp3", ".flac", ".opus", ".m4a", ".wav", ".wma" 22 | }; 23 | 24 | private static string[] discNames = 25 | { 26 | "cd1", "cd2", "cd3", "cd4", "cd5", 27 | "disk1", "disk2", "disk3", "disk4", "disk5", 28 | "disc1", "disc2", "disc3", "disc4", "disc5" 29 | }; 30 | 31 | [DataContract(Name = "payload")] 32 | public class PayLoad 33 | { 34 | public string path; 35 | public string name; // API command name 36 | public string importMode = "Move"; 37 | public string downloadClientId = "SonarrAutoImporter"; 38 | } 39 | 40 | [DataContract(Name = "transform")] 41 | public class SonarrTransform 42 | { 43 | public string search; 44 | public string replace; 45 | } 46 | 47 | private void Log(string fmt, params object[] args) 48 | { 49 | Logging.LogHandler.Log(fmt, args); 50 | } 51 | 52 | private void LogError(string fmt, params object[] args) 53 | { 54 | Logging.LogHandler.LogError(fmt, args); 55 | } 56 | 57 | private string TransformFileName(List transforms, string path, bool verbose) 58 | { 59 | string fName = Path.GetFileName(path); 60 | string newfName = fName; 61 | 62 | if (transforms != null && transforms.Any()) 63 | { 64 | Log($" Running {transforms.Count()} transforms on {path}..."); 65 | 66 | foreach (var transform in transforms) 67 | { 68 | newfName = Regex.Replace(newfName, transform.search, transform.replace, RegexOptions.IgnoreCase); 69 | Logging.LogHandler.LogVerbose(" - Transform {0} => {1}", fName, newfName); 70 | } 71 | 72 | if (string.Compare(fName, newfName, StringComparison.OrdinalIgnoreCase) != 0) 73 | Log("Filename transformed: {0} => {1}", fName, newfName); 74 | } 75 | else 76 | Log("No transforms configured."); 77 | return newfName; 78 | } 79 | 80 | private string MoveFile(string fullPathName, string newFileName) 81 | { 82 | string folder = Path.GetDirectoryName(fullPathName); 83 | string oldFileName = Path.GetFileName(fullPathName); 84 | 85 | if (string.Compare(oldFileName, newFileName, StringComparison.OrdinalIgnoreCase) != 0) 86 | { 87 | string newPath = Path.Combine(folder, newFileName); 88 | Log("Transforming file '{0}' to '{1}'", oldFileName, newFileName); 89 | try 90 | { 91 | File.Move(fullPathName, newPath, false); 92 | return newPath; 93 | } 94 | catch (Exception ex) 95 | { 96 | LogError("Unable to rename file {0}: {1}", fullPathName, ex.Message); 97 | } 98 | } 99 | 100 | return fullPathName; 101 | } 102 | 103 | /// 104 | /// Skip .partial files, and also any file where the last write time is any time 105 | /// the last 5 mins. 106 | /// 107 | /// 108 | /// 109 | private bool IsPartialDownload( FileInfo file ) 110 | { 111 | if (file.Name.Contains(".partial.", StringComparison.OrdinalIgnoreCase)) 112 | return true; 113 | 114 | var age = DateTime.UtcNow - file.LastWriteTimeUtc; 115 | 116 | if (Math.Abs(age.TotalMinutes) < 5) 117 | return true; 118 | 119 | return false; 120 | } 121 | 122 | public void ProcessService( ServiceSettings settings, bool dryRun, bool verbose, string apiCommand) 123 | { 124 | DirectoryInfo baseDir = new DirectoryInfo(settings.downloadsFolder); 125 | 126 | if (settings.importMode != "Copy" && settings.importMode != "Move") 127 | { 128 | Log($"Invalid importMode '{settings.importMode}' in settings. Defaulting to 'Move'"); 129 | settings.importMode = "Move"; 130 | } 131 | 132 | Log("Starting video processing for: {0}", baseDir); 133 | if (verbose) 134 | { 135 | Log(" Base Url: {0}", settings.url); 136 | Log(" API Key: {0}", settings.apiKey); 137 | Log(" Mapping: {0}", settings.mappingPath); 138 | Log(" Timeout: {0}", settings.timeoutSecs); 139 | Log(" CopyMode: {0}", settings.importMode); 140 | Log(" Dry Run: {0}", dryRun); 141 | } 142 | 143 | if (baseDir.Exists) 144 | { 145 | var allFiles = baseDir.GetFiles("*.*", SearchOption.AllDirectories).ToList(); 146 | var movieFiles = allFiles.Where(x => movieExtensions.Contains(x.Extension, StringComparer.OrdinalIgnoreCase)) 147 | .Where(x => !IsPartialDownload( x ) ) 148 | .ToList(); 149 | 150 | if (movieFiles.Any()) 151 | { 152 | Log("Processing {0} video files...", movieFiles.Count()); 153 | 154 | bool success = true; 155 | 156 | foreach (var file in movieFiles) 157 | { 158 | string videoFullPath = file.FullName; 159 | 160 | string newFileName = TransformFileName(settings.transforms, videoFullPath, verbose); 161 | 162 | if (!dryRun) 163 | { 164 | videoFullPath = MoveFile(file.FullName, newFileName); 165 | } 166 | 167 | string path = TranslatePath(settings.downloadsFolder, videoFullPath, settings.mappingPath); 168 | 169 | if (!dryRun) 170 | { 171 | if (!QuickImport(path, settings, verbose, apiCommand)) 172 | success = false; 173 | } 174 | else 175 | Log(" => {0}", path); 176 | 177 | if (settings.timeoutSecs != 0) 178 | { 179 | Log( $"Sleeping for {settings.timeoutSecs} seconds..."); 180 | Thread.Sleep(settings.timeoutSecs * 1000); 181 | } 182 | } 183 | 184 | if( success ) 185 | Log("All processing completed successfully."); 186 | else 187 | Log("Processing completed with errors."); 188 | } 189 | else 190 | Log("No videos found. Nothing to do!"); 191 | 192 | if (settings.trimFolders) 193 | { 194 | Log($"Trimming empty folders in {baseDir.FullName}"); 195 | TrimEmptyFolders(baseDir, movieExtensions); 196 | } 197 | } 198 | else 199 | Log($"Folder {baseDir} was not found. Check configuration."); 200 | } 201 | 202 | private string GetAlbumFolder( DirectoryInfo dir ) 203 | { 204 | string albumFolder = dir.FullName; 205 | 206 | if( discNames.Any( x => dir.Name.StartsWith( x, StringComparison.OrdinalIgnoreCase ) ) ) 207 | { 208 | albumFolder = dir.Parent.FullName; 209 | } 210 | 211 | return albumFolder; 212 | } 213 | 214 | 215 | public void ProcessLidarr(ServiceSettings settings, bool dryRun, bool verbose, string apiCommand) 216 | { 217 | DirectoryInfo baseDir = new DirectoryInfo(settings.downloadsFolder); 218 | 219 | if (settings.importMode != "Copy" && settings.importMode != "Move") 220 | { 221 | Log($"Invalid importMode '{settings.importMode}' in settings. Defaulting to 'Move'"); 222 | settings.importMode = "Move"; 223 | } 224 | 225 | Log("Starting audio processing for: {0}", baseDir); 226 | if (verbose) 227 | { 228 | Log(" Base Url: {0}", settings.url); 229 | Log(" API Key: {0}", settings.apiKey); 230 | Log(" Mapping: {0}", settings.mappingPath); 231 | Log(" Timeout: {0}", settings.timeoutSecs); 232 | Log(" CopyMode: {0}", settings.importMode); 233 | Log(" Dry Run: {0}", dryRun); 234 | } 235 | 236 | if (baseDir.Exists) 237 | { 238 | var allFiles = baseDir.GetFiles("*.*", SearchOption.AllDirectories).ToList(); 239 | 240 | var albumFolders = allFiles.Where(x => musicExtensions.Contains(x.Extension, StringComparer.OrdinalIgnoreCase)) 241 | .Where(x => !IsPartialDownload(x)) 242 | .Select(x => GetAlbumFolder(x.Directory)) 243 | .Distinct() 244 | .ToList(); 245 | 246 | if (albumFolders.Any()) 247 | { 248 | Log("Processing {0} album folders...", albumFolders.Count()); 249 | 250 | foreach (var folder in albumFolders) 251 | { 252 | string path = TranslatePath(settings.downloadsFolder, folder, settings.mappingPath); 253 | 254 | if (!dryRun) 255 | { 256 | QuickImport(path, settings, verbose, apiCommand, true); 257 | } 258 | else 259 | Log(" => {0}", path); 260 | 261 | if (settings.timeoutSecs != 0) 262 | { 263 | Log($"Sleeping for {settings.timeoutSecs} seconds..."); 264 | Thread.Sleep(settings.timeoutSecs * 1000); 265 | } 266 | } 267 | 268 | Log("All processing complete."); 269 | } 270 | else 271 | Log("No albums found. Nothing to do!"); 272 | 273 | if (settings.trimFolders) 274 | { 275 | Log($"Trimming empty folders in {baseDir.FullName}"); 276 | TrimEmptyFolders(baseDir, musicExtensions); 277 | } 278 | } 279 | else 280 | Log($"Folder {baseDir} was not found. Check configuration."); 281 | } 282 | 283 | private void TrimEmptyFolders(DirectoryInfo baseDir, string[] extensions) 284 | { 285 | if ((baseDir.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) 286 | return; 287 | 288 | if (baseDir.Name.StartsWith(".") || baseDir.Name.StartsWith("@")) 289 | return; 290 | 291 | var allFolders = baseDir.GetDirectories("*.*", SearchOption.AllDirectories); 292 | 293 | foreach (var folder in allFolders) 294 | { 295 | try 296 | { 297 | TrimEmptyFolders(folder, extensions); 298 | 299 | var unwantedFiles = folder.GetFiles() 300 | .Where(x => !extensions.Contains(x.Extension, StringComparer.OrdinalIgnoreCase) 301 | && !x.Name.StartsWith( ".") 302 | && (x.Attributes & FileAttributes.Hidden) != FileAttributes.Hidden) 303 | .ToList(); 304 | 305 | unwantedFiles.ForEach(x => 306 | { 307 | Log($" Deleting non-video file: {x.FullName}"); 308 | x.Delete(); 309 | }); 310 | 311 | if (folder.Exists && !folder.GetFiles().Any() && !folder.GetDirectories().Any() ) 312 | { 313 | Log($"Removing empty folder: {folder.FullName}"); 314 | folder.Delete(); 315 | } 316 | } 317 | catch (Exception ex) 318 | { 319 | Log($"Unexpected exception during folder trim: {ex.Message}"); 320 | } 321 | } 322 | } 323 | 324 | private string TranslatePath(string baseFolder, string fullName, string mapFolder) 325 | { 326 | string path = Path.GetFullPath(fullName); 327 | string localPath = path.Remove(0, baseFolder.Length); 328 | if (localPath.StartsWith(Path.DirectorySeparatorChar)) 329 | localPath = localPath.Remove(0, 1); 330 | return Path.Combine(mapFolder, localPath); 331 | } 332 | 333 | private bool QuickImport(string remotePath, ServiceSettings service, bool verbose, string apiCommand, bool isLidarr = false) 334 | { 335 | try 336 | { 337 | RestClient client = new RestClient(service.url); 338 | var payload = new PayLoad { path = remotePath, name = apiCommand, importMode = service.importMode }; 339 | 340 | var request = new RestRequest(Method.POST); 341 | 342 | request.Resource = isLidarr ? "api/v1/command" : "api/v3/command"; 343 | request.RequestFormat = DataFormat.Json; 344 | request.AddJsonBody(payload); 345 | request.AddHeader("User-Agent", "Sonarr Auto-Import"); 346 | request.AddHeader("X-Api-Key", service.apiKey); 347 | 348 | var response = client.Execute(request); 349 | Log(" - Executed Service command for {0}", remotePath); 350 | 351 | if (!response.IsSuccessful) 352 | LogError($"Request failed: status {response.StatusCode}"); 353 | 354 | if (!response.IsSuccessful || verbose) 355 | { 356 | Log(response.Content); 357 | } 358 | 359 | return response.IsSuccessful; 360 | } 361 | catch (Exception e) 362 | { 363 | LogError("Exception: {0}", e); 364 | } 365 | 366 | return false; 367 | } 368 | } 369 | } 370 | --------------------------------------------------------------------------------