├── .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 |
--------------------------------------------------------------------------------