diff --git a/.gitignore b/.gitignore index 52be4a5..abeac84 100644 --- a/.gitignore +++ b/.gitignore @@ -335,3 +335,4 @@ ASALocalRun/ /src/Elzik.Mecon.Console/Properties/launchSettings.json /src/Elzik.Mecon.Console/appsettings.development.json /Local Build/output +/Build/output diff --git a/Build/publish.ps1 b/Build/publish.ps1 index a11288d..a8f219f 100644 --- a/Build/publish.ps1 +++ b/Build/publish.ps1 @@ -2,11 +2,11 @@ $runtimes = "linux-x64", "win-x64", "osx-x64" foreach ($runtime in $runtimes) { - dotnet publish ..\src\Elzik.Mecon.Console\Elzik.Mecon.Console.csproj ` + dotnet publish $PSScriptRoot\..\src\Elzik.Mecon.Console\Elzik.Mecon.Console.csproj ` -p:PublishSingleFile=true ` -r $runtime ` -c Release ` --self-contained true ` -p:PublishTrimmed=true ` - -o ".\output\$runtime" + -o $PSScriptRoot\output\$runtime } \ No newline at end of file diff --git a/README.md b/README.md index 8853528..03e7ffe 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,79 @@ # mecon -![Build](https://img.shields.io/github/workflow/status/elzik/mecon/Continuous%20Integration?color=95BE1A) -![Publish](https://img.shields.io/github/workflow/status/elzik/mecon/Publish?color=95BE1A&label=publish) -![Coverage](https://raw.githubusercontent.com/gist/elzik/527882e89a938dc78f61a08c300edec4/raw/c145ab2e9e64bcf0feae9e9f1005010cff4503b7/mecon-code-coverage-main.svg) +[![Build](https://img.shields.io/github/workflow/status/elzik/mecon/Continuous%20Integration?color=95BE1A)](https://github.com/elzik/mecon/actions/workflows/continuous-integration.yml) +[![Publish](https://img.shields.io/github/workflow/status/elzik/mecon/Publish?color=95BE1A&label=publish)](https://github.com/elzik/mecon/actions/workflows/publish.yml) +[![Coverage](https://raw.githubusercontent.com/gist/elzik/527882e89a938dc78f61a08c300edec4/raw/c145ab2e9e64bcf0feae9e9f1005010cff4503b7/mecon-code-coverage-main.svg)]() +[![Code quality](https://img.shields.io/codacy/grade/e2387c03324b46b88f61467312dea645?color=95BE1A)](https://app.codacy.com/gh/elzik/mecon/dashboard) +[![License](https://img.shields.io/github/license/elzik/mecon)](https://github.com/elzik/mecon/blob/regex-filters/COPYING) +[![Release](https://img.shields.io/github/v/release/elzik/mecon?include_prereleases)](https://github.com/elzik/mecon/releases) ## Introduction **Me**dia R**econ**ciler, or simply _mecon_, is a cross-platform command line tool which reconciles media within a directory with media in a Plex library. It helps answer simple questions such as: -- Given a list of files in a directory, which ones have failed to have been added to a Plex library? -- Given a list of files in a directory, which ones exist in a Plex library? +- Given a list of files in a directory, which ones have failed to have been added to a Plex library? +- Given a list of files in a directory, which ones exist in a Plex library? ## Example Usage without Configuration ### Example 1 Display help text documenting reconcilation options: -``` +```console mecon reconcile --help ``` ### Example 2 Scan all files in the specified directory (`-d /path`) and list all files that are not found (`-L`) in a Plex TV or Movie library using the specified Plex server (`-p `) and [your Plex auth token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) (`-t `): -``` +```console mecon -d /Films -p http://192.168.0.12:32400 -t -L ``` ### Example 3 As Example 2 however, only scan `*.mkv` files in the specified directory (`-e mkv`): -``` +```console mecon -d /Films -e mkv -p http://192.168.0.12:32400 -t -L ``` ### Example 4 As Example 2 however, don't specify a directory, simply scan the current directory: -``` +```console mecon -p http://192.168.0.12:32400 -t -L ``` ### Example 5 As Example 2 however, only seach Plex libraries that contain movies (`-m movie`): -``` +```console mecon -d /Films -p http://192.168.0.12:32400 -t -m movie -L ``` +### Example 6 +As Example 2 however, perform filename filter to only display files that do not contain the word "sample": +``` console +mecon -d /Films -p http://192.168.0.12:32400 -f '(?i)^(?!.*sample).*$' -L +``` + ## Mecon Options -- **`reconcile --help`** +- **`reconcile --help`** Displays help for general mecon reconciliation usage. -- **`-p|--plex-host `** - Specifies the Plex server to use when reconciling media on disk with media in Plex libraries. This URL may be specified with or without a port as necessary. e.g. `-p http://loacalhost:32400` -- **`-t|--plex-token `** - Specifies the Plex server authentication token. See the [Plex documentation for explanation on how to find your token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). -- **`-d|--directory `** - Specifies the path on the file system that should be scanned. If neither this nor the `-n` option is supplied, the current working directory will be scanned. The scanning performed will be recursive unless the `-r false` option is supplied. e.g. `-d /Video/Films/` -- **`-e|--file-extensions `** - Provide a list of file extensions to scan for in the file system. This can be used to improve performance or simplify output where only specific file extensions are of interest. The extensions are supplied as a comma separated list without dot prefixes. If this option is ommited, all filetypes will be included during scanning. e.g `-e mkv,mp4,ts` -- **`-n|--directory-definition-name `** - Where a preconfigured directory definition exists, it can be used as the directory for scanning by specifying its name rather than having to explicitly specify the directory and any list of file extensions. If neither this nor the `-d` option is supplied, the current working directory will be scanned. The scanning performed will be recursive unless the `-r false` option is supplied. e.g. `-n Films` -- **`-r|--recurse`** - By default, scanning of filesystem directories is recursive. This can be turned off and made non-recursive using `-r false` or the default behaviour of enabling recursion can be made explicit using `-r true`. -- **`-m|--media-types`** - Comma-separated list of Plex library media types that should be reconciled against to avoid searching through libraries that contain other media types. Possible options are 'Movie' or 'TvShow'. This option is only valid when the -d option (--directory-definition-name) is supplied. If this is omitted, libraries of all media types will be reconciled against. e.g. `-m movies` +- **`-p|--plex-host `** Specifies the Plex server to use when reconciling media on disk with media in Plex libraries. This URL may be specified with or without a port as necessary. e.g. `-p http://loacalhost:32400` +- **`-t|--plex-token `** Specifies the Plex server authentication token. See the [Plex documentation for explanation on how to find your token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). +- **`-d|--directory `** Specifies the path on the file system that should be scanned. If neither this nor the `-n` option is supplied, the current working directory will be scanned. The scanning performed will be recursive unless the `-r false` option is supplied. e.g. `-d /Video/Films/` +- **`-e|--file-extensions `** Provide a list of file extensions to scan for in the file system. This can be used to improve performance or simplify output where only specific file extensions are of interest. The extensions are supplied as a comma separated list without dot prefixes. If this option is ommited, all filetypes will be included during scanning. e.g `-e mkv,mp4,ts` +- **`-n|--directory-definition-name `** Where a preconfigured directory definition exists, it can be used as the directory for scanning by specifying its name rather than having to explicitly specify the directory and any list of file extensions. If neither this nor the `-d` option is supplied, the current working directory will be scanned. The scanning performed will be recursive unless the `-r false` option is supplied. e.g. `-n Films` +- **`-r|--recurse`** By default, scanning of filesystem directories is recursive. This can be turned off and made non-recursive using `-r false` or the default behaviour of enabling recursion can be made explicit using `-r true`. +- **`-m|--media-types`** Comma-separated list of Plex library media types that should be reconciled against to avoid searching through libraries that contain other media types. Possible options are 'Movie' or 'TvShow'. This option is only valid when the -d option (--directory-definition-name) is supplied. If this is omitted, libraries of all media types will be reconciled against. e.g. `-m movies` +- **`-f|--regex-match-filter `** When scanning the file system, filter to only show files where the filepath matches a regular expression. For example, by specifying `'(?i)^(?!.*sample).*$'`, the list of files scanned will be filtered to i only shows files that do not contain the word "sample". ## Output Options -In addition to the reconciliation options above, at least one output option must be supplied to control what is returned by mecon. -- **`-L|--missing-from-library`** - Output a list of files that are present in the filesystem but missing from the any Plex library. The list could represent: - - Files that the Plex scanner failed to add for some reason. - - Files that were removed from the Plex library and _may_ no longer be needed on the file system. -- **`-l|--present-in-library`** - Output a list of files that are present in the filesystem and also present in a Plex library. The list could represent: - - Files that you believe shouldn't have been added to Plex and need investigating. - - Files that have been added to the wrong Plex library when used in conjuction with the `-m` option. +In addition to the reconciliation options above, at least one library option must be supplied to control what is returned by mecon. +- **`-L|--missing-from-library`** Output a list of files that are present in the filesystem but missing from the any Plex library. The list could represent: + - Files that the Plex scanner failed to add for some reason. + - Files that were removed from the Plex library and _may_ no longer be needed on the file system. + +- **`-l|--present-in-library`** Output a list of files that are present in the filesystem and also present in a Plex library. The list could represent: + - Files that you believe shouldn't have been added to Plex and need investigating. + - Files that have been added to the wrong Plex library when used in conjuction with the `-m` option. ## Configuration -Some options do not change very often and you may like to set them permanently rather than entering them every time on the command line. To do this, they can be pre-configured with using environment variables or in an appsettings.json file in the same directory as the mecon binary. Ensure that the case for any settings is correct and that environment variables parts are separated by double underscores (`__`). In the case that a setting is configured or provided on the command line more than once there is an order of precedence where an option on the command line will trump all other configuration: -1. appsettings.json -2. Environment variable -3. Command line option +Some options do not change very often and you may like to set them permanently rather than entering them every time on the command line. To do this, they can be pre-configured using environment variables or in an appsettings.json file in the same directory as the mecon binary. Ensure that the case for any settings is correct and that environment variable parts are separated by double underscores (`__`). In the case that a setting is configured or provided more than once, there is an order of precedence where an option on the command line will trump all other configuration: +1. appsettings.json +2. Environment variable +3. Command line option ### Command Line Options also Available as Config |Command Line Option| Environment Variable | appsettings.json | @@ -79,16 +81,18 @@ Some options do not change very often and you may like to set them permanently r | -p\|--plex-host | Mecon__Plex__BaseUrl= | "Plex": {"BaseUrl": ""} | | -t\|--plex-token | Mecon__Plex__AuthToken= | "Plex": {"AuthToken": ""} | ### Directory Definitions -Since the directories that you wish to scan are likely to be reused over time, it is possible to define directory definitions that specify not only a path to scan but also the file extensions to scan for, the library media types to reconcile against and whether the scanning should be recursive. Each directory definition is then given a name that can be passed on the command line using the `-n` option. The directory definitions can be defined in either environment variables or an appsettngs.json file in the same directory as the mecon binary. The example below shows two directory definitions. One for storing movies and one for storing television shows: +Since the directories that you wish to scan are likely to be reused over time, it is possible to define directory definitions that specify not only a path to scan but also the file extensions to scan for, the library media types to reconcile against, whether the scanning should be recursive and a regular expression filter. Each directory definition is then given a name that can be passed on the command line using the `-n|--directory-definition-name` option. The directory definitions can be defined in either environment variables or an appsettngs.json file in the same directory as the mecon binary. The example below shows two directory definitions; one for storing movies and one for storing television shows: |                                appsettings.json                                | Environment Variables | |----------------------------------------------------------------------------|----------------------------------------| -| "FileSystem": {
    "DirectoryDefinitions": {}
        "Films": {
            "DirectoryPath": "\\Video\\Films",
            "SupportedFileExtensions": [ "mkv", "ts", "mp4" ],
            "MediaTypes": [ "Movie" ]
        },
        "TV": {
            "DirectoryPath": "\\Video\\TV",
            "SupportedFileExtensions": [ "mkv", "ts", "mp4" ],
            "MediaTypes": [ "TvShow" ]
        }
    }
} | Mecon__FileSystem__DirectoryDefinitions__Films__DirectoryPath=\Video\Films
Mecon__FileSystem__DirectoryDefinitions__Films__SupportedFileExtensions__1=mkv
Mecon__FileSystem__DirectoryDefinitions__Films__SupportedFileExtensions__2=ts
Mecon__FileSystem__DirectoryDefinitions__Films__SupportedFileExtensions__3=mp4
Mecon__FileSystem__DirectoryDefinitions__Films__MediaTypes__1=Movie
Mecon__FileSystem__DirectoryDefinitions__TV__DirectoryPath=\Video\TV
Mecon__FileSystem__DirectoryDefinitions__TV__SupportedFileExtensions__1=mkv
Mecon__FileSystem__DirectoryDefinitions__TV__SupportedFileExtensions__2=ts
Mecon__FileSystem__DirectoryDefinitions__TV__SupportedFileExtensions__3=mp4
Mecon__FileSystem__DirectoryDefinitions__TV__MediaTypes__1=TvShow
| +| "FileSystem": {
    "DirectoryDefinitions": {}
        "Films": {
            "DirectoryPath": "\\Video\\Films",
            "SupportedFileExtensions": [ "mkv", "ts", "mp4" ],
            "MediaTypes": [ "Movie" ],
            "DirectoryFilterRegexPattern": "(?i)^(?!.*sample).*$"
        },
        "TV": {
            "DirectoryPath": "\\Video\\TV",
            "SupportedFileExtensions": [ "mkv", "ts", "mp4" ],
            "MediaTypes": [ "TvShow" ]
        }
    }
} | Mecon__FileSystem__DirectoryDefinitions__Films__DirectoryPath=\Video\Films
Mecon__FileSystem__DirectoryDefinitions__Films__SupportedFileExtensions__1=mkv
Mecon__FileSystem__DirectoryDefinitions__Films__SupportedFileExtensions__2=ts
Mecon__FileSystem__DirectoryDefinitions__Films__SupportedFileExtensions__3=mp4
Mecon__FileSystem__DirectoryDefinitions__Films__MediaTypes__1=Movie
Mecon__FileSystem__DirectoryDefinitions__Films__DirectoryFilterRegexPattern=(?!^(?!.*sample).*\$)
Mecon__FileSystem__DirectoryDefinitions__TV__DirectoryPath=\Video\TV
Mecon__FileSystem__DirectoryDefinitions__TV__SupportedFileExtensions__1=mkv
Mecon__FileSystem__DirectoryDefinitions__TV__SupportedFileExtensions__2=ts
Mecon__FileSystem__DirectoryDefinitions__TV__SupportedFileExtensions__3=mp4
Mecon__FileSystem__DirectoryDefinitions__TV__MediaTypes__1=TvShow
| + +If a directory definition is specified on the command line using the `-n|--directory-definition-name` option, the following commandline options will have no effect since they can already be specified in config as part of the directory definition: `-d -e -r -m -f`. ### Logging -Logging by default is implemented using a single-line simple console logger with a log level of `Warning`. This can be reconfigured in many ways. However, this configuration is not in the scope of this documentation; instead refer to [Microsoft's documentation for Console logging and its various options](https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.console?view=dotnet-plat-ext-6.0). +Logging by default is implemented using a single-line simple console logger with a log level of `Warning`. This can be reconfigured in many ways. However, this configuration is not in the scope of this documentation; instead, refer to [Microsoft's documentation for Console logging and its various options](https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.console?view=dotnet-plat-ext-6.0). ### Listing Configuration Since configuration can be performed by leaving defaults as they are, adding environment variables, editing an appsettings.json file or any combination of these layered together, it can be useful to view a list of all of these combinations resolved using the [order of precedence](#configuration) as described at the beginning of this section. This can be performed using mecon's `config` verb and its `-l|--list` option: -``` +```console mecon config -l ``` This will display all configuration in a JSON format regardless of whether it came from default settings, environment variables or the appsettings.json file. @@ -96,14 +100,13 @@ This will display all configuration in a JSON format regardless of whether it ca ## Limitations mecon uses each file's name and size in bytes to reconcile files in the file system with items in Plex libraries. In the unlikely event that you have files that are considered different but have identical names and sizes, the reconciliation process will provide unreliable results. -## Versioning & Features slated for v1.0.0 +## Versioning & Features Slated for v1.0.0 This application should be considered to be in beta until it reaches a v1.0.0+ version number. The version number can be confirmed using: -``` +```console mecon --version ``` Features slated for v1.0.0: -- Progress feedback/spinner -- Regex output filter (e.g. for ignoring all filenames containing 'sample') -- File size output filter (e.g. for ignoring all files under 0.5MB) -- User watched output filter (e.g. show only files watched by a list of users) -- Packages, installers or manual install instructions +- Progress feedback/spinner +- File size output filter (e.g. for ignoring all files under 0.5MB) +- User watched output filter (e.g. show only files watched by a list of users) +- Packages, installers or manual install instructions diff --git a/src/Elzik.Mecon.Console/CommandLine/CommandLineParserConfigurationExtensions.cs b/src/Elzik.Mecon.Console/CommandLine/CommandLineParserConfigurationExtensions.cs index b9cc137..d4481b7 100644 --- a/src/Elzik.Mecon.Console/CommandLine/CommandLineParserConfigurationExtensions.cs +++ b/src/Elzik.Mecon.Console/CommandLine/CommandLineParserConfigurationExtensions.cs @@ -10,7 +10,10 @@ public static IConfigurationBuilder AddCommandLineParser( this IConfigurationBuilder configurationBuilder, string[] args) { - if (!args.Any()) return configurationBuilder; + if (!args.Any()) + { + return configurationBuilder; + } var commandParser = new Parser(setting => { diff --git a/src/Elzik.Mecon.Console/CommandLine/Config/ConfigHandler.cs b/src/Elzik.Mecon.Console/CommandLine/Config/ConfigHandler.cs index 0413539..3227335 100644 --- a/src/Elzik.Mecon.Console/CommandLine/Config/ConfigHandler.cs +++ b/src/Elzik.Mecon.Console/CommandLine/Config/ConfigHandler.cs @@ -1,5 +1,4 @@ -using Elzik.Mecon.Console.Configuration; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration; namespace Elzik.Mecon.Console.CommandLine.Config { diff --git a/src/Elzik.Mecon.Console/CommandLine/OptionConfigurationParserExtensions.cs b/src/Elzik.Mecon.Console/CommandLine/OptionConfigurationParserExtensions.cs index d47abd0..d0897ba 100644 --- a/src/Elzik.Mecon.Console/CommandLine/OptionConfigurationParserExtensions.cs +++ b/src/Elzik.Mecon.Console/CommandLine/OptionConfigurationParserExtensions.cs @@ -118,13 +118,17 @@ private static IEnumerable GetSwitchNames(PropertyInfo propertyInfo) // https://github.com/commandlineparser/commandline/blob/master/src/CommandLine/OptionAttribute.cs var longName = optionAttribute.LongName; if (string.IsNullOrEmpty(longName)) + { longName = propertyInfo.Name; + } switchNames.Add($"--{longName}"); var shortName = optionAttribute.ShortName; if (!string.IsNullOrEmpty(shortName)) + { switchNames.Add($"-{shortName}"); + } return switchNames; } @@ -205,7 +209,9 @@ private static bool TryGetMappedOption( private static bool GetBoolOptionDefault(PropertyInfo propertyInfo) { if (propertyInfo.PropertyType != typeof(bool)) + { throw new ArgumentException("Property is not of type bool", nameof(propertyInfo)); + } var optionAttribute = GetOptionAttribute(propertyInfo); @@ -217,8 +223,10 @@ private static OptionAttribute GetOptionAttribute(PropertyInfo propertyInfo) { var optionAttribute = propertyInfo.GetCustomAttribute(); if (optionAttribute == null) + { throw new ArgumentException( $"Property does not have attribute {nameof(OptionAttribute)}", nameof(propertyInfo)); + } return optionAttribute; } @@ -227,8 +235,10 @@ private static OptionConfigurationAttribute GetOptionConfigurationAttribute(Prop { var optionConfigAttribute = propertyInfo.GetCustomAttribute(); if (optionConfigAttribute == null) + { throw new ArgumentException( $"Property does not have attribute {nameof(OptionConfigurationAttribute)}", nameof(propertyInfo)); + } return optionConfigAttribute; } diff --git a/src/Elzik.Mecon.Console/CommandLine/Reconciliation/IReconciliationHandler.cs b/src/Elzik.Mecon.Console/CommandLine/Reconciliation/IReconciliationHandler.cs new file mode 100644 index 0000000..d869ef3 --- /dev/null +++ b/src/Elzik.Mecon.Console/CommandLine/Reconciliation/IReconciliationHandler.cs @@ -0,0 +1,8 @@ +using Microsoft.Extensions.Configuration; + +namespace Elzik.Mecon.Console.CommandLine.Reconciliation; + +public interface IReconciliationHandler +{ + void Handle(IConfigurationBuilder configurationBuilder, ReconciliationOptions reconciliationOptions); +} \ No newline at end of file diff --git a/src/Elzik.Mecon.Console/Entries.cs b/src/Elzik.Mecon.Console/CommandLine/Reconciliation/MediaEntriesExtensions.cs similarity index 51% rename from src/Elzik.Mecon.Console/Entries.cs rename to src/Elzik.Mecon.Console/CommandLine/Reconciliation/MediaEntriesExtensions.cs index 5c1dbbd..142de1c 100644 --- a/src/Elzik.Mecon.Console/Entries.cs +++ b/src/Elzik.Mecon.Console/CommandLine/Reconciliation/MediaEntriesExtensions.cs @@ -1,18 +1,20 @@ -using Elzik.Mecon.Console.CommandLine; -using Elzik.Mecon.Console.CommandLine.Reconciliation; -using Elzik.Mecon.Framework.Domain; +using Elzik.Mecon.Framework.Domain; -namespace Elzik.Mecon.Console +namespace Elzik.Mecon.Console.CommandLine.Reconciliation { - internal static class Entries + internal static class MediaEntriesExtensions { internal static IEnumerable PerformOutputFilters(this IEnumerable entries, ReconciliationOptions options) { - if (options.MissingFromLibrary) + if (options.MissingFromLibrary) + { entries = entries.WhereNotInPlex(); + } - if (options.PresentInLibrary) + if (options.PresentInLibrary) + { entries = entries.WhereInPlex(); + } return entries; } diff --git a/src/Elzik.Mecon.Console/CommandLine/Reconciliation/ReconciliationHandler.cs b/src/Elzik.Mecon.Console/CommandLine/Reconciliation/ReconciliationHandler.cs index a1d7fc4..bb3601a 100644 --- a/src/Elzik.Mecon.Console/CommandLine/Reconciliation/ReconciliationHandler.cs +++ b/src/Elzik.Mecon.Console/CommandLine/Reconciliation/ReconciliationHandler.cs @@ -1,27 +1,39 @@ -using Elzik.Mecon.Console.Configuration; -using Elzik.Mecon.Framework.Application; +using Elzik.Mecon.Framework.Application; +using Elzik.Mecon.Framework.Infrastructure.FileSystem; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Nito.AsyncEx; namespace Elzik.Mecon.Console.CommandLine.Reconciliation { - public static class ReconciliationHandler + + public class ReconciliationHandler : IReconciliationHandler { - public static void Handle(ConfigurationManager configurationManager, ReconciliationOptions reconciliationOptions) + private readonly IReconciledMedia _reconciledMedia; + private readonly IFileSystem _fileSystem; + + public ReconciliationHandler(IReconciledMedia reconciledMedia, IFileSystem fileSystem) + { + _reconciledMedia = reconciledMedia; + _fileSystem = fileSystem; + } + + public void Handle(IConfigurationBuilder configurationBuilder, ReconciliationOptions reconciliationOptions) { try { - configurationManager.AddCommandLineParser(Environment.GetCommandLineArgs()); - - var services = Services.Get(configurationManager); + var directoryDefinition = reconciliationOptions.DirectoryKey == null + ? new DirectoryDefinition() + { + SupportedFileExtensions = + (reconciliationOptions.FileExtensions ?? Array.Empty()).ToArray(), + MediaTypes = reconciliationOptions.MediaTypes, + Recurse = reconciliationOptions.Recurse ?? false, + DirectoryFilterRegexPattern = reconciliationOptions.MatchRegex, + DirectoryPath = reconciliationOptions.DirectoryPath + } + : _fileSystem.GetDirectoryDefinition(reconciliationOptions.DirectoryKey); - var reconciledMedia = services.GetRequiredService(); - var entries = reconciliationOptions.DirectoryKey != null - ? AsyncContext.Run(() => reconciledMedia.GetMediaEntries(reconciliationOptions.DirectoryKey)) - : AsyncContext.Run(() => reconciledMedia.GetMediaEntries( - reconciliationOptions.DirectoryPath, reconciliationOptions.FileExtensions, - reconciliationOptions.Recurse!.Value, reconciliationOptions.MediaTypes)); + var entries = AsyncContext.Run(() => _reconciledMedia.GetMediaEntries(directoryDefinition)); entries = entries.PerformOutputFilters(reconciliationOptions); diff --git a/src/Elzik.Mecon.Console/CommandLine/Reconciliation/ReconciliationOptions.cs b/src/Elzik.Mecon.Console/CommandLine/Reconciliation/ReconciliationOptions.cs index 3e27a13..82d50db 100644 --- a/src/Elzik.Mecon.Console/CommandLine/Reconciliation/ReconciliationOptions.cs +++ b/src/Elzik.Mecon.Console/CommandLine/Reconciliation/ReconciliationOptions.cs @@ -46,12 +46,20 @@ public class ReconciliationOptions "If this is omitted, all libraries of all media types will be searched.")] public IEnumerable? MediaTypes { get; set; } - [Option('L', "missing-from-library", Group = "output filter", + [Option('L', "missing-from-library", Group = "required output filter", HelpText = "Filter output to only show files missing from media library.")] public bool MissingFromLibrary { get; set; } - [Option('l', "present-in-library", Group = "output filter", + [Option('l', "present-in-library", Group = "required output filter", HelpText = "Filter output to only show files present in media library.")] public bool PresentInLibrary { get; set; } + + [Option('f', "regex-match-filter", + HelpText = "Filter output to only show files where the filepath matches a regular expression.")] + public string? MatchRegex { get; set; } + + [Option('F', "regex-no-match-filter", + HelpText = "Filter output to only show files where the filepath does not match a regular expression.")] + public string? NoMatchRegex { get; set; } } } diff --git a/src/Elzik.Mecon.Console/Configuration/Configuration.cs b/src/Elzik.Mecon.Console/Configuration.cs similarity index 90% rename from src/Elzik.Mecon.Console/Configuration/Configuration.cs rename to src/Elzik.Mecon.Console/Configuration.cs index bafbe66..330b14b 100644 --- a/src/Elzik.Mecon.Console/Configuration/Configuration.cs +++ b/src/Elzik.Mecon.Console/Configuration.cs @@ -1,22 +1,24 @@ using System.Reflection; +using Elzik.Mecon.Console.CommandLine; using Microsoft.Extensions.Configuration; using Newtonsoft.Json.Linq; -namespace Elzik.Mecon.Console.Configuration +namespace Elzik.Mecon.Console { public static class Configuration { - public static ConfigurationManager Get() + public static ConfigurationManager Get(string[] args) { var config = new ConfigurationManager(); - config.AddJsonStream(Assembly.GetCallingAssembly() + config.AddJsonStream(Assembly.GetExecutingAssembly() .GetManifestResourceStream("Elzik.Mecon.Console.appsettings.Default.json")); config.AddJsonFile("appsettings.json", true); #if DEBUG config.AddJsonFile("appsettings.Development.json", true); #endif config.AddEnvironmentVariables("Mecon:"); + config.AddCommandLineParser(args); return config; } diff --git a/src/Elzik.Mecon.Console/Elzik.Mecon.Console.csproj b/src/Elzik.Mecon.Console/Elzik.Mecon.Console.csproj index 2ba6d69..a355299 100644 --- a/src/Elzik.Mecon.Console/Elzik.Mecon.Console.csproj +++ b/src/Elzik.Mecon.Console/Elzik.Mecon.Console.csproj @@ -45,6 +45,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Elzik.Mecon.Console/Program.cs b/src/Elzik.Mecon.Console/Program.cs index 1cd3852..d101172 100644 --- a/src/Elzik.Mecon.Console/Program.cs +++ b/src/Elzik.Mecon.Console/Program.cs @@ -1,20 +1,23 @@ using CommandLine; -using Elzik.Mecon.Console.CommandLine; +using Elzik.Mecon.Console; using Elzik.Mecon.Console.CommandLine.Config; using Elzik.Mecon.Console.CommandLine.Error; using Elzik.Mecon.Console.CommandLine.Reconciliation; -using Elzik.Mecon.Console.Configuration; - -var config = Configuration.Get(); +using Microsoft.Extensions.DependencyInjection; var commandParser = new Parser(setting => { setting.CaseInsensitiveEnumValues = true; }); - var parserResult = commandParser.ParseArguments(args); parserResult - .WithParsed(options => ReconciliationHandler.Handle(config, options)) - .WithParsed(_ => ConfigHandler.Display(config)) + .WithParsed(options => + { + var config = Configuration.Get(args); + var services = Services.Get(config); + var reconciliationHandler = services.GetRequiredService(); + reconciliationHandler.Handle(config, options); + }) + .WithParsed(_ => ConfigHandler.Display(Configuration.Get(args))) .WithNotParsed(errors => ErrorHandler.Display(parserResult, errors)); diff --git a/src/Elzik.Mecon.Console/Properties/launchSettings.json b/src/Elzik.Mecon.Console/Properties/launchSettings.json index 0d35711..a4a57ab 100644 --- a/src/Elzik.Mecon.Console/Properties/launchSettings.json +++ b/src/Elzik.Mecon.Console/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "Elzik.Mecon.Console": { "commandName": "Project", - "commandLineArgs": "" + "commandLineArgs": "-n Films -L -f (?i)^.*sample.*$" } } } \ No newline at end of file diff --git a/src/Elzik.Mecon.Console/Configuration/Services.cs b/src/Elzik.Mecon.Console/Services.cs similarity index 89% rename from src/Elzik.Mecon.Console/Configuration/Services.cs rename to src/Elzik.Mecon.Console/Services.cs index 8db07e8..5ea9861 100644 --- a/src/Elzik.Mecon.Console/Configuration/Services.cs +++ b/src/Elzik.Mecon.Console/Services.cs @@ -1,5 +1,6 @@ using System.IO.Abstractions; using System.Reflection; +using Elzik.Mecon.Console.CommandLine.Reconciliation; using Elzik.Mecon.Framework.Application; using Elzik.Mecon.Framework.Infrastructure.FileSystem.Options; using Elzik.Mecon.Framework.Infrastructure.Plex; @@ -16,7 +17,7 @@ using FileSystem = Elzik.Mecon.Framework.Infrastructure.FileSystem.FileSystem; using IFileSystem = Elzik.Mecon.Framework.Infrastructure.FileSystem.IFileSystem; -namespace Elzik.Mecon.Console.Configuration +namespace Elzik.Mecon.Console { internal static class Services { @@ -25,7 +26,10 @@ public static ServiceProvider Get(ConfigurationManager configurationManager) var fullAssemblyName = Assembly.GetExecutingAssembly().GetName(); var version = fullAssemblyName.Version; - if (version == null) throw new InvalidOperationException("It was not possible to get the assembly version."); + if (version == null) + { + throw new InvalidOperationException("It was not possible to get the assembly version."); + } var apiOptions = new ClientOptions { @@ -42,6 +46,7 @@ public static ServiceProvider Get(ConfigurationManager configurationManager) loggingBuilder.AddConfiguration(configurationManager.GetSection("Logging")); loggingBuilder.AddConsole(); }) + .AddSingleton() .AddSingleton() .AddTransient() .AddTransient() diff --git a/src/Elzik.Mecon.Console/appsettings.Development.json b/src/Elzik.Mecon.Console/appsettings.Development.json index c5dc2d3..1211781 100644 --- a/src/Elzik.Mecon.Console/appsettings.Development.json +++ b/src/Elzik.Mecon.Console/appsettings.Development.json @@ -2,11 +2,13 @@ "FileSystem": { "DirectoryDefinitions": { "Films": { - "DirectoryPath": "\\\\LOCALHOST\\Films", - "SupportedFileExtensions": ["mkv", "ts", "mp4"] + "DirectoryPath": "\\\\LOCALHOST\\Films", + "SupportedFileExtensions": [ "mkv", "ts", "mp4" ], + "DirectoryFilterRegexPattern": "(?i)^(?!.*sample).*$", + "MediaTypes": [ "Movie" ] + } } - } -}, + }, "Plex": { "BaseUrl": "http://localhost:32400", "AuthToken": "" diff --git a/src/Elzik.Mecon.Framework/Application/IReconciledMedia.cs b/src/Elzik.Mecon.Framework/Application/IReconciledMedia.cs index 891ef49..d97c3d4 100644 --- a/src/Elzik.Mecon.Framework/Application/IReconciledMedia.cs +++ b/src/Elzik.Mecon.Framework/Application/IReconciledMedia.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Elzik.Mecon.Framework.Domain; +using Elzik.Mecon.Framework.Infrastructure.FileSystem; namespace Elzik.Mecon.Framework.Application { @@ -8,10 +9,6 @@ public interface IReconciledMedia { Task> GetMediaEntries(string directoryDefinitionName); - Task> GetMediaEntries( - string directoryPath, - IEnumerable supportedFileExtensions, - bool recurse, - IEnumerable mediaTypes); + Task> GetMediaEntries(DirectoryDefinition directoryDefinition); } } \ No newline at end of file diff --git a/src/Elzik.Mecon.Framework/Application/MediaReconciler.cs b/src/Elzik.Mecon.Framework/Application/MediaReconciler.cs index 28e3408..f22e19e 100644 --- a/src/Elzik.Mecon.Framework/Application/MediaReconciler.cs +++ b/src/Elzik.Mecon.Framework/Application/MediaReconciler.cs @@ -36,27 +36,18 @@ public async Task> GetMediaEntries(string directoryDefin var directoryDefinition = _fileSystem.GetDirectoryDefinition(directoryDefinitionName); var mediaEntries = - await GetMediaEntries( - directoryDefinition.DirectoryPath, - directoryDefinition.SupportedFileExtensions, - directoryDefinition.Recurse, - directoryDefinition.MediaTypes); + await GetMediaEntries(directoryDefinition); return mediaEntries; } - public async Task> GetMediaEntries( - string directoryPath, - IEnumerable supportedFileExtensions, - bool recurse, - IEnumerable mediaTypes) + public async Task> GetMediaEntries(DirectoryDefinition directoryDefinition) { - var mediaFileInfos = _fileSystem - .GetMediaFileInfos(directoryPath, supportedFileExtensions, recurse); + var mediaFileInfos = _fileSystem.GetMediaFileInfos(directoryDefinition); - var plexItems = await _plexEntries.GetPlexEntries(mediaTypes); + var plexItems = await _plexEntries.GetPlexEntries(directoryDefinition.MediaTypes); - var mediaEntries = mediaFileInfos.Select(fileInfo => + var mediaEntries = mediaFileInfos.Select(fileInfo => { var mediaEntry = new MediaEntry(fileInfo.FullName) { @@ -68,8 +59,8 @@ public async Task> GetMediaEntries( }; var plexEntries = plexItems - .Where(m => m.Key == mediaEntry.FilesystemEntry.Key) - .ToList(); + .Where(m => m.Key == mediaEntry.FilesystemEntry.Key) + .ToList(); foreach (var plexEntry in plexEntries) { @@ -90,9 +81,14 @@ public async Task> GetMediaEntries( private void ValidatePlexConfiguration(IOptions plexOptions) { if (string.IsNullOrWhiteSpace(plexOptions.Value.BaseUrl)) + { throw new InvalidOperationException("No base URL has been supplied for Plex."); + } + if (string.IsNullOrWhiteSpace(plexOptions.Value.AuthToken)) + { throw new InvalidOperationException("No auth token has been supplied for Plex."); + } _logger.LogInformation("Plex reconciliation is enabled against {BaseUrl} with {CacheScheme}.", plexOptions.Value.BaseUrl, diff --git a/src/Elzik.Mecon.Framework/Domain/EntryKey.cs b/src/Elzik.Mecon.Framework/Domain/EntryKey.cs index cf4009c..67eed32 100644 --- a/src/Elzik.Mecon.Framework/Domain/EntryKey.cs +++ b/src/Elzik.Mecon.Framework/Domain/EntryKey.cs @@ -15,16 +15,36 @@ public EntryKey(string filename, long byteCount) public bool Equals(EntryKey other) { - if (other is null) return false; - if (ReferenceEquals(this, other)) return true; + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + return Filename == other.Filename && ByteCount == other.ByteCount; } public override bool Equals(object obj) { - if (obj is null) return false; - if (ReferenceEquals(this, obj)) return true; - if (obj.GetType() != this.GetType()) return false; + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != this.GetType()) + { + return false; + } + return Equals((EntryKey) obj); } diff --git a/src/Elzik.Mecon.Framework/Domain/MediaEntriesExtensions.cs b/src/Elzik.Mecon.Framework/Domain/MediaEntriesExtensions.cs index 2971546..3b3a1d7 100644 --- a/src/Elzik.Mecon.Framework/Domain/MediaEntriesExtensions.cs +++ b/src/Elzik.Mecon.Framework/Domain/MediaEntriesExtensions.cs @@ -1,8 +1,6 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Text.RegularExpressions; namespace Elzik.Mecon.Framework.Domain { diff --git a/src/Elzik.Mecon.Framework/Elzik.Mecon.Framework.csproj b/src/Elzik.Mecon.Framework/Elzik.Mecon.Framework.csproj index bcd5c46..d9c781e 100644 --- a/src/Elzik.Mecon.Framework/Elzik.Mecon.Framework.csproj +++ b/src/Elzik.Mecon.Framework/Elzik.Mecon.Framework.csproj @@ -12,14 +12,14 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Elzik.Mecon.Framework/Infrastructure/FileSystem/DirectoryDefinition.cs b/src/Elzik.Mecon.Framework/Infrastructure/FileSystem/DirectoryDefinition.cs index 10364a2..4e0ccae 100644 --- a/src/Elzik.Mecon.Framework/Infrastructure/FileSystem/DirectoryDefinition.cs +++ b/src/Elzik.Mecon.Framework/Infrastructure/FileSystem/DirectoryDefinition.cs @@ -7,6 +7,8 @@ public class DirectoryDefinition { public string DirectoryPath { get; set; } + public string DirectoryFilterRegexPattern { get; set; } + public string[] SupportedFileExtensions { get; set; } public bool Recurse { get; set; } = true; diff --git a/src/Elzik.Mecon.Framework/Infrastructure/FileSystem/FileSystem.cs b/src/Elzik.Mecon.Framework/Infrastructure/FileSystem/FileSystem.cs index 4773983..3b33e4b 100644 --- a/src/Elzik.Mecon.Framework/Infrastructure/FileSystem/FileSystem.cs +++ b/src/Elzik.Mecon.Framework/Infrastructure/FileSystem/FileSystem.cs @@ -3,6 +3,7 @@ using System.IO; using System.IO.Abstractions; using System.Linq; +using System.Text.RegularExpressions; using Elzik.Mecon.Framework.Infrastructure.FileSystem.Options; using Microsoft.Extensions.Options; @@ -45,21 +46,37 @@ public DirectoryDefinition GetDirectoryDefinition(string directoryDefinitionName return _fileSystemOptions.DirectoryDefinitions[directoryDefinitionName]; } - public IEnumerable GetMediaFileInfos(string directoryPath, IEnumerable supportedFileExtensions, bool recurse) + public IEnumerable GetMediaFileInfos(DirectoryDefinition directoryDefinition) { var files = _directory - .EnumerateFiles(directoryPath, "*.*", new EnumerationOptions() + .EnumerateFiles(directoryDefinition.DirectoryPath, "*.*", new EnumerationOptions() { - RecurseSubdirectories = recurse + RecurseSubdirectories = directoryDefinition.Recurse }); - files = FilterFileExtensions(files, supportedFileExtensions); + files = FilterFileExtensions(files, directoryDefinition.SupportedFileExtensions); + + files = FilterRegexPattern(files, directoryDefinition.DirectoryFilterRegexPattern); var fileInfos = files.Select(filePath => _fileSystem.FileInfo.FromFileName(filePath)); + return fileInfos; } + private static IEnumerable FilterRegexPattern(IEnumerable files, string directoryFilterRegexPattern) + { + if (string.IsNullOrWhiteSpace(directoryFilterRegexPattern)) + { + return files; + } + + var regex = new Regex(directoryFilterRegexPattern, RegexOptions.Compiled); + files = files.Where(s => regex.IsMatch(s)); + + return files; + } + private static IEnumerable FilterFileExtensions(IEnumerable files, IEnumerable fileExtensions) { var extensions = fileExtensions == null diff --git a/src/Elzik.Mecon.Framework/Infrastructure/FileSystem/IFileSystem.cs b/src/Elzik.Mecon.Framework/Infrastructure/FileSystem/IFileSystem.cs index 4eb0eaa..91339c3 100644 --- a/src/Elzik.Mecon.Framework/Infrastructure/FileSystem/IFileSystem.cs +++ b/src/Elzik.Mecon.Framework/Infrastructure/FileSystem/IFileSystem.cs @@ -7,6 +7,6 @@ public interface IFileSystem { DirectoryDefinition GetDirectoryDefinition(string directoryDefinitionName); - IEnumerable GetMediaFileInfos(string directoryPath, IEnumerable supportedFileExtensions, bool recurse); + IEnumerable GetMediaFileInfos(DirectoryDefinition directoryDefinition); } } diff --git a/src/Elzik.Mecon.Framework/Infrastructure/Plex/PlexEntries.cs b/src/Elzik.Mecon.Framework/Infrastructure/Plex/PlexEntries.cs index ff62f0c..2103091 100644 --- a/src/Elzik.Mecon.Framework/Infrastructure/Plex/PlexEntries.cs +++ b/src/Elzik.Mecon.Framework/Infrastructure/Plex/PlexEntries.cs @@ -73,7 +73,7 @@ from part in medium.Part private async Task> GetLibraryItems(Library library) { - SearchType searchType = library.Type switch + var searchType = library.Type switch { "movie" => SearchType.Movie, "show" => SearchType.Episode, diff --git a/tests/Elzik.Mecon.Console.Tests.Unit/CommandLine/Reconciliation/ReconciliationHandlerTests.cs b/tests/Elzik.Mecon.Console.Tests.Unit/CommandLine/Reconciliation/ReconciliationHandlerTests.cs new file mode 100644 index 0000000..3496e6e --- /dev/null +++ b/tests/Elzik.Mecon.Console.Tests.Unit/CommandLine/Reconciliation/ReconciliationHandlerTests.cs @@ -0,0 +1,130 @@ +using AutoFixture; +using AutoFixture.AutoNSubstitute; +using Elzik.Mecon.Console.CommandLine.Reconciliation; +using Elzik.Mecon.Framework.Application; +using Elzik.Mecon.Framework.Domain; +using Elzik.Mecon.Framework.Infrastructure.FileSystem; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Elzik.Mecon.Console.Tests.Unit.CommandLine.Reconciliation +{ + public sealed class ReconciliationHandlerTests : IDisposable + { + private readonly IFixture _fixture; + private readonly StringWriter _consoleWriter; + private readonly StringWriter _errorWriter; + + public ReconciliationHandlerTests() + { + _fixture = new Fixture().Customize(new AutoNSubstituteCustomization()); + + _consoleWriter = new StringWriter(); + System.Console.SetOut(_consoleWriter); + _errorWriter = new StringWriter(); + System.Console.SetError(_errorWriter); + } + + [Fact] + public void Handle_WithDirectoryKeyWhereInPlex_WritesExpectedFilePaths() + { + // Arrange + var testReconciliationOptionsInPlex = _fixture + .Build() + .With(options => options.MissingFromLibrary, false) + .With(options => options.PresentInLibrary, true) + .Create(); + var testDirectoryDefinition = _fixture.Create(); + var testMediaEntriesWithoutPlex = _fixture.CreateMany(); + var testMediaEntriesWithPlex = _fixture.CreateMany().ToList(); + foreach (var mediaEntry in testMediaEntriesWithPlex) + { + mediaEntry.ReconciledEntries.Add(_fixture.Create()); + } + var testAllMediaEntries = testMediaEntriesWithoutPlex.Concat(testMediaEntriesWithPlex); + + var mockReconciledMedia = Substitute.For(); + mockReconciledMedia.GetMediaEntries(Arg.Is(testDirectoryDefinition)).Returns(testAllMediaEntries); + var mockFileSystem = Substitute.For(); + mockFileSystem.GetDirectoryDefinition(Arg.Is(testReconciliationOptionsInPlex.DirectoryKey)) + .Returns(testDirectoryDefinition); + var mockConfigurationBuilder = new ConfigurationBuilder(); + + // Act + var reconciliationHandler = new ReconciliationHandler(mockReconciledMedia, mockFileSystem); + reconciliationHandler.Handle(mockConfigurationBuilder, testReconciliationOptionsInPlex); + + // Assert + var expectedOutput = string.Join(Environment.NewLine, + testMediaEntriesWithPlex.Select(entry => entry.FilesystemEntry.FileSystemPath)) + Environment.NewLine; + _consoleWriter.ToString().Should() + .Be(expectedOutput); + } + + [Fact] + public void Handle_WithDirectoryKeyWhereNotInPlex_WritesExpectedFilePaths() + { + // Arrange + var testReconciliationOptionsNotInPlex = _fixture + .Build() + .With(options => options.MissingFromLibrary, true) + .With(options => options.PresentInLibrary, false) + .Create(); + var testDirectoryDefinition = _fixture.Create(); + var testMediaEntriesWithoutPlex = _fixture.CreateMany().ToList(); + var testMediaEntriesWithPlex = _fixture.CreateMany().ToList(); + foreach (var mediaEntry in testMediaEntriesWithPlex) + { + mediaEntry.ReconciledEntries.Add(_fixture.Create()); + } + var testAllMediaEntries = testMediaEntriesWithoutPlex.Concat(testMediaEntriesWithPlex); + + var mockReconciledMedia = Substitute.For(); + mockReconciledMedia.GetMediaEntries(Arg.Is(testDirectoryDefinition)).Returns(testAllMediaEntries); + var mockFileSystem = Substitute.For(); + mockFileSystem.GetDirectoryDefinition(Arg.Is(testReconciliationOptionsNotInPlex.DirectoryKey)) + .Returns(testDirectoryDefinition); + var mockConfigurationBuilder = new ConfigurationBuilder(); + + // Act + var reconciliationHandler = new ReconciliationHandler(mockReconciledMedia, mockFileSystem); + reconciliationHandler.Handle(mockConfigurationBuilder, testReconciliationOptionsNotInPlex); + + // Assert + var expectedOutput = string.Join(Environment.NewLine, + testMediaEntriesWithoutPlex.Select(entry => entry.FilesystemEntry.FileSystemPath)) + Environment.NewLine; + _consoleWriter.ToString().Should() + .Be(expectedOutput); + } + + [Fact] + public void Handle_Throws_ExitsAndWritesToErrorStream() + { + // Arrange + var testReconciliationOptionsNotInPlex = _fixture.Create(); + var testException = _fixture.Create(); + + var mockReconciledMedia = Substitute.For(); + var mockFileSystem = Substitute.For(); + mockFileSystem.GetDirectoryDefinition(Arg.Is(testReconciliationOptionsNotInPlex.DirectoryKey)) + .Throws(testException); + var mockConfigurationBuilder = new ConfigurationBuilder(); + + // Act + var reconciliationHandler = new ReconciliationHandler(mockReconciledMedia, mockFileSystem); + reconciliationHandler.Handle(mockConfigurationBuilder, testReconciliationOptionsNotInPlex); + + // Assert + Environment.ExitCode.Should().Be(1); + _errorWriter.ToString().Should().Be($"Error: {testException.Message}{Environment.NewLine}"); + } + + public void Dispose() + { + _consoleWriter.Dispose(); + } + } +} diff --git a/tests/Elzik.Mecon.Console.Tests.Unit/Configuration/ConfigurationTests.cs b/tests/Elzik.Mecon.Console.Tests.Unit/Configuration/ConfigurationTests.cs deleted file mode 100644 index bb62a92..0000000 --- a/tests/Elzik.Mecon.Console.Tests.Unit/Configuration/ConfigurationTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Elzik.Mecon.Console.Configuration; -using FluentAssertions; -using Xunit; - -namespace Elzik.Mecon.Console.Tests.Unit.Configuration -{ - public class ConfigurationTests - { - [Fact] - public void ToJsonString_WithValidConfig_ReturnsExpectedJason() - { - // Arrange - var config = new ConfigurationManager(); - config.AddJsonFile("Configuration/appsettings.Test.json"); - - // Act - var jsonText = config.ToJsonString(); - - // Assert - var expected = File.ReadAllText("Configuration/appsettings.Test.json") - .Replace("TestPlexAuthToken", ""); - jsonText.Should().Be(expected); - } - } -} \ No newline at end of file diff --git a/tests/Elzik.Mecon.Console.Tests.Unit/ConfigurationTests.cs b/tests/Elzik.Mecon.Console.Tests.Unit/ConfigurationTests.cs new file mode 100644 index 0000000..fc6b8fc --- /dev/null +++ b/tests/Elzik.Mecon.Console.Tests.Unit/ConfigurationTests.cs @@ -0,0 +1,49 @@ +using AutoFixture; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Elzik.Mecon.Console.Tests.Unit +{ + public class ConfigurationTests + { + private readonly IFixture _fixture; + + public ConfigurationTests() + { + _fixture = new Fixture(); + } + [Fact] + public void ToJsonString_WithValidConfig_ReturnsExpectedJson() + { + // Arrange + var config = new ConfigurationManager(); + config.AddJsonFile("appsettings.Test.json"); + + // Act + var jsonText = config.ToJsonString(); + + // Assert + var expected = File.ReadAllText("appsettings.Test.json") + .Replace("TestPlexAuthToken", ""); + jsonText.Should().Be(expected); + } + + [Fact] + public void Handle_CalledWithPlexOptions_SetsPlexConfiguration() + { + // Arrange + var testPlexHost = _fixture.Create(); + var testPlexToken = _fixture.Create(); + var testCommandLineArgs = new[] { "-p", testPlexHost, "-t", testPlexToken, "-l"}; + + // Act + var configManager = Configuration.Get(testCommandLineArgs); + + // Assert + var config = ((IConfigurationBuilder)configManager).Build(); + config.GetValue("Plex:BaseUrl").Should().Be(testPlexHost); + config.GetValue("Plex:AuthToken").Should().Be(testPlexToken); + } + } +} \ No newline at end of file diff --git a/tests/Elzik.Mecon.Console.Tests.Unit/Elzik.Mecon.Console.Tests.Unit.csproj b/tests/Elzik.Mecon.Console.Tests.Unit/Elzik.Mecon.Console.Tests.Unit.csproj index 588962d..e121b27 100644 --- a/tests/Elzik.Mecon.Console.Tests.Unit/Elzik.Mecon.Console.Tests.Unit.csproj +++ b/tests/Elzik.Mecon.Console.Tests.Unit/Elzik.Mecon.Console.Tests.Unit.csproj @@ -8,11 +8,7 @@ - - - - - + PreserveNewest @@ -29,6 +25,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/Elzik.Mecon.Console.Tests.Unit/Configuration/appsettings.Test.json b/tests/Elzik.Mecon.Console.Tests.Unit/appsettings.Test.json similarity index 100% rename from tests/Elzik.Mecon.Console.Tests.Unit/Configuration/appsettings.Test.json rename to tests/Elzik.Mecon.Console.Tests.Unit/appsettings.Test.json diff --git a/tests/Elzik.Mecon.Framework.Tests.Integration/Elzik.Mecon.Framework.Tests.Integration.csproj b/tests/Elzik.Mecon.Framework.Tests.Integration/Elzik.Mecon.Framework.Tests.Integration.csproj index 0b6da16..8d7cc8a 100644 --- a/tests/Elzik.Mecon.Framework.Tests.Integration/Elzik.Mecon.Framework.Tests.Integration.csproj +++ b/tests/Elzik.Mecon.Framework.Tests.Integration/Elzik.Mecon.Framework.Tests.Integration.csproj @@ -29,7 +29,12 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/Elzik.Mecon.Framework.Tests.Unit/Application/Aligner.cs b/tests/Elzik.Mecon.Framework.Tests.Unit/Application/Aligner.cs index f34f8a3..970f7ae 100644 --- a/tests/Elzik.Mecon.Framework.Tests.Unit/Application/Aligner.cs +++ b/tests/Elzik.Mecon.Framework.Tests.Unit/Application/Aligner.cs @@ -1,12 +1,13 @@ using System; using System.Collections.Generic; using Elzik.Mecon.Framework.Domain; +using Elzik.Mecon.Framework.Tests.Unit.Infrastructure.FileSystemTests; namespace Elzik.Mecon.Framework.Tests.Unit.Application { public static class Aligner { - public static void AlignFileSystemWithPlexMediaContainer(IList files, IList plexEntries) + public static void AlignFileSystemWithPlexMediaContainer(IList files, IList plexEntries) { if (files.Count != plexEntries.Count) { diff --git a/tests/Elzik.Mecon.Framework.Tests.Unit/Application/MediaReconcilerTests.cs b/tests/Elzik.Mecon.Framework.Tests.Unit/Application/MediaReconcilerTests.cs index 58ddcc6..401f0a5 100644 --- a/tests/Elzik.Mecon.Framework.Tests.Unit/Application/MediaReconcilerTests.cs +++ b/tests/Elzik.Mecon.Framework.Tests.Unit/Application/MediaReconcilerTests.cs @@ -29,7 +29,7 @@ public class MediaReconcilerTests private readonly IPlexEntries _mockPlexEntries; private readonly OptionsWrapper _testPlexOptionsWrapper; private readonly string _testDirectoryDefinitionName; - private readonly List _testFiles; + private readonly List _testFiles; private readonly List _testPlexEntries; public MediaReconcilerTests() @@ -46,12 +46,9 @@ public MediaReconcilerTests() _testDirectoryDefinitionName = _fixture.Create(); var testDirectoryDefinition = _fixture.Create(); - _testFiles = _fixture.CreateMany().ToList(); + _testFiles = _fixture.CreateMany().ToList(); _mockFileSystem.GetDirectoryDefinition(_testDirectoryDefinitionName).Returns(testDirectoryDefinition); - _mockFileSystem.GetMediaFileInfos( - Arg.Is(testDirectoryDefinition.DirectoryPath), - Arg.Is(testDirectoryDefinition.SupportedFileExtensions), - Arg.Is(testDirectoryDefinition.Recurse)) + _mockFileSystem.GetMediaFileInfos(testDirectoryDefinition) .Returns(_testFiles); _testPlexEntries = _fixture.CreateMany().ToList(); diff --git a/tests/Elzik.Mecon.Framework.Tests.Unit/Domain/MediaEntriesExtensionsTests.cs b/tests/Elzik.Mecon.Framework.Tests.Unit/Domain/MediaEntriesExtensionsTests.cs index e558d2e..ad4f5d8 100644 --- a/tests/Elzik.Mecon.Framework.Tests.Unit/Domain/MediaEntriesExtensionsTests.cs +++ b/tests/Elzik.Mecon.Framework.Tests.Unit/Domain/MediaEntriesExtensionsTests.cs @@ -1,13 +1,10 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoNSubstitute; using Elzik.Mecon.Framework.Domain; using FluentAssertions; -using NSubstitute; using Xunit; namespace Elzik.Mecon.Framework.Tests.Unit.Domain diff --git a/tests/Elzik.Mecon.Framework.Tests.Unit/Elzik.Mecon.Framework.Tests.Unit.csproj b/tests/Elzik.Mecon.Framework.Tests.Unit/Elzik.Mecon.Framework.Tests.Unit.csproj index fad4515..7634882 100644 --- a/tests/Elzik.Mecon.Framework.Tests.Unit/Elzik.Mecon.Framework.Tests.Unit.csproj +++ b/tests/Elzik.Mecon.Framework.Tests.Unit/Elzik.Mecon.Framework.Tests.Unit.csproj @@ -18,6 +18,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/Elzik.Mecon.Framework.Tests.Unit/Infrastructure/FileSystemTests/FileSystemTests.cs b/tests/Elzik.Mecon.Framework.Tests.Unit/Infrastructure/FileSystemTests/FileSystemTests.cs index 1c01f31..9ec5e61 100644 --- a/tests/Elzik.Mecon.Framework.Tests.Unit/Infrastructure/FileSystemTests/FileSystemTests.cs +++ b/tests/Elzik.Mecon.Framework.Tests.Unit/Infrastructure/FileSystemTests/FileSystemTests.cs @@ -17,7 +17,7 @@ namespace Elzik.Mecon.Framework.Tests.Unit.Infrastructure.FileSystemTests { - public partial class FileSystemTests + public class FileSystemTests { private readonly IFixture _fixture; @@ -73,18 +73,23 @@ public void Constructor_NullFileSystemOptionsValue_Throws() public void GetMediaFileInfos_NoFileExtensions_ReturnsExpectedPaths(bool testRecurse) { // Arrange - var testNoFileExtensions = Array.Empty(); - var testDirectoryPath = _fixture.Create(); + var testDirectoryDefinition = _fixture + .Build() + .Without(definition => definition.DirectoryFilterRegexPattern) + .With(definition => definition.Recurse, testRecurse) + .With(definition => definition.SupportedFileExtensions, + Array.Empty()) + .Create(); _mockDirectory.EnumerateFiles( - Arg.Is(testDirectoryPath), + Arg.Is(testDirectoryDefinition.DirectoryPath), Arg.Is("*.*"), Arg.Is(options => options.RecurseSubdirectories == testRecurse)) .Returns(_testFileInfos.Select(info => info.FullName)); // Act var fileSystem = new FileSystem(_mockDirectory, _mockFileSystem, _testOptionsWrapper); - var filePaths = fileSystem.GetMediaFileInfos(testDirectoryPath, testNoFileExtensions, testRecurse); + var filePaths = fileSystem.GetMediaFileInfos(testDirectoryDefinition); // Assert filePaths.Should().BeEquivalentTo(_testFileInfos); @@ -96,17 +101,22 @@ public void GetMediaFileInfos_NoFileExtensions_ReturnsExpectedPaths(bool testRec public void GetMediaFileInfos_NullFileExtensions_ReturnsExpectedPaths(bool testRecurse) { // Arrange - var testDirectoryPath = _fixture.Create(); + var testDirectoryDefinition = _fixture + .Build() + .Without(definition => definition.DirectoryFilterRegexPattern) + .With(definition => definition.Recurse, testRecurse) + .Without(definition => definition.SupportedFileExtensions) + .Create(); _mockDirectory.EnumerateFiles( - Arg.Is(testDirectoryPath), + Arg.Is(testDirectoryDefinition.DirectoryPath), Arg.Is("*.*"), Arg.Is(options => options.RecurseSubdirectories == testRecurse)) .Returns(_testFileInfos.Select(info => info.FullName)); // Act var fileSystem = new FileSystem(_mockDirectory, _mockFileSystem, _testOptionsWrapper); - var filePaths = fileSystem.GetMediaFileInfos(testDirectoryPath, null, testRecurse); + var filePaths = fileSystem.GetMediaFileInfos(testDirectoryDefinition); // Assert filePaths.Should().BeEquivalentTo(_testFileInfos); @@ -116,18 +126,21 @@ public void GetMediaFileInfos_NullFileExtensions_ReturnsExpectedPaths(bool testR public void GetMediaFileInfos_NoExistingFileExtensions_ReturnsNoPaths() { // Arrange - var testNonExistingFileExtensions = new[] {"no1", "no2", "no3"}; - var testDirectoryPath = _fixture.Create(); + var testDirectoryDefinition = _fixture + .Build() + .With(definition => definition.SupportedFileExtensions, new[] { "no1", "no2", "no3" }) + .Without(definition => definition.DirectoryFilterRegexPattern) + .Create(); _mockDirectory.EnumerateFiles( - Arg.Is(testDirectoryPath), + Arg.Is(testDirectoryDefinition.DirectoryPath), Arg.Is("*.*"), Arg.Is(options => options.RecurseSubdirectories)) .Returns(_testFileInfos.Select(info => info.FullName)); // Act var fileSystem = new FileSystem(_mockDirectory, _mockFileSystem, _testOptionsWrapper); - var filePaths = fileSystem.GetMediaFileInfos(testDirectoryPath, testNonExistingFileExtensions, true); + var filePaths = fileSystem.GetMediaFileInfos(testDirectoryDefinition); // Assert filePaths.Should().BeEmpty(); @@ -140,17 +153,21 @@ public void GetMediaFileInfos_WithExistingFileExtensions_ReturnsExpectedPaths( params string[] testExistingFileExtensions) { // Arrange - var testDirectoryPath = _fixture.Create(); + var testDirectoryDefinition = _fixture + .Build() + .With(definition => definition.SupportedFileExtensions, testExistingFileExtensions) + .Without(definition => definition.DirectoryFilterRegexPattern) + .Create(); _mockDirectory.EnumerateFiles( - Arg.Is(testDirectoryPath), + Arg.Is(testDirectoryDefinition.DirectoryPath), Arg.Is("*.*"), Arg.Is(options => options.RecurseSubdirectories)) .Returns(_testFileInfos.Select(info => info.FullName)); // Act var fileSystem = new FileSystem(_mockDirectory, _mockFileSystem, _testOptionsWrapper); - var filePaths = fileSystem.GetMediaFileInfos(testDirectoryPath, testExistingFileExtensions, true); + var filePaths = fileSystem.GetMediaFileInfos(testDirectoryDefinition); // Assert _testFileInfos.RemoveAll(info => info.FullName == "/FileThree.ext3"); @@ -224,5 +241,32 @@ public void GetDirectoryDefinition_DirectoryDefinitionExists_ReturnsDirectoryDef // Assert directoryDefinition.Should().Be(testValidDirectoryDefinition); } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GetMediaFileInfos_WithRegExFilter_ReturnsExpectedPaths(bool testRecurse) + { + // Arrange + var testDirectoryDefinition = _fixture + .Build() + .With(definition => definition.Recurse, testRecurse) + .Without(definition => definition.SupportedFileExtensions) + .With(definition => definition.DirectoryFilterRegexPattern, "^(?!.*FileTwo).*$") + .Create(); + + _mockDirectory.EnumerateFiles( + Arg.Is(testDirectoryDefinition.DirectoryPath), + Arg.Is("*.*"), + Arg.Is(options => options.RecurseSubdirectories == testRecurse)) + .Returns(_testFileInfos.Select(info => info.FullName)); + + // Act + var fileSystem = new FileSystem(_mockDirectory, _mockFileSystem, _testOptionsWrapper); + var filePaths = fileSystem.GetMediaFileInfos(testDirectoryDefinition); + + // Assert + filePaths.Should().BeEquivalentTo(_testFileInfos.Where(info => !info.Name.Contains("FileTwo"))); + } } } diff --git a/tests/Elzik.Mecon.Framework.Tests.Unit/Infrastructure/FileSystemTests/TestFileInfoImplementation.cs b/tests/Elzik.Mecon.Framework.Tests.Unit/Infrastructure/FileSystemTests/TestFileInfoImplementation.cs index d9ad074..a1c66c2 100644 --- a/tests/Elzik.Mecon.Framework.Tests.Unit/Infrastructure/FileSystemTests/TestFileInfoImplementation.cs +++ b/tests/Elzik.Mecon.Framework.Tests.Unit/Infrastructure/FileSystemTests/TestFileInfoImplementation.cs @@ -3,147 +3,149 @@ using System.IO.Abstractions; using System.Security.AccessControl; -namespace Elzik.Mecon.Framework.Tests.Unit.Infrastructure.FileSystemTests +namespace Elzik.Mecon.Framework.Tests.Unit.Infrastructure.FileSystemTests; + +public class TestFileInfoImplementation : IFileInfo { - public partial class FileSystemTests - { - public class TestFileInfoImplementation : IFileInfo - { - - public TestFileInfoImplementation(string fullName, string name, long length) - { - FullName = fullName; - Name = name; - Length = length; - } - - public void Delete() - { - throw new NotImplementedException(); - } - - public void Refresh() - { - throw new NotImplementedException(); - } - - public IFileSystem FileSystem { get; } - public FileAttributes Attributes { get; set; } - public DateTime CreationTime { get; set; } - public DateTime CreationTimeUtc { get; set; } - public bool Exists { get; } - public string Extension { get; } - public string FullName { get; } - public DateTime LastAccessTime { get; set; } - public DateTime LastAccessTimeUtc { get; set; } - public DateTime LastWriteTime { get; set; } - public DateTime LastWriteTimeUtc { get; set; } - public string LinkTarget { get; } - public string Name { get; } - public StreamWriter AppendText() - { - throw new NotImplementedException(); - } - - public IFileInfo CopyTo(string destFileName) - { - throw new NotImplementedException(); - } - - public IFileInfo CopyTo(string destFileName, bool overwrite) - { - throw new NotImplementedException(); - } - - public Stream Create() - { - throw new NotImplementedException(); - } - - public StreamWriter CreateText() - { - throw new NotImplementedException(); - } - - public void Decrypt() - { - throw new NotImplementedException(); - } - - public void Encrypt() - { - throw new NotImplementedException(); - } - - public FileSecurity GetAccessControl() - { - throw new NotImplementedException(); - } - - public FileSecurity GetAccessControl(AccessControlSections includeSections) - { - throw new NotImplementedException(); - } - - public void MoveTo(string destFileName) - { - throw new NotImplementedException(); - } - - public void MoveTo(string destFileName, bool overwrite) - { - throw new NotImplementedException(); - } - - public Stream Open(FileMode mode) - { - throw new NotImplementedException(); - } - - public Stream Open(FileMode mode, FileAccess access) - { - throw new NotImplementedException(); - } - - public Stream Open(FileMode mode, FileAccess access, FileShare share) - { - throw new NotImplementedException(); - } - - public Stream OpenRead() - { - throw new NotImplementedException(); - } - - public StreamReader OpenText() - { - throw new NotImplementedException(); - } - - public Stream OpenWrite() - { - throw new NotImplementedException(); - } - - public IFileInfo Replace(string destinationFileName, string destinationBackupFileName) - { - throw new NotImplementedException(); - } - - public IFileInfo Replace(string destinationFileName, string destinationBackupFileName, bool ignoreMetadataErrors) - { - throw new NotImplementedException(); - } - - public void SetAccessControl(FileSecurity fileSecurity) - { - throw new NotImplementedException(); - } - - public IDirectoryInfo Directory { get; } - public string DirectoryName { get; } - public bool IsReadOnly { get; set; } - public long Length { get; } - } + public TestFileInfoImplementation(string fullName, string name, long length) + { + FullName = fullName; + Name = name; + Length = length; + Exists = false; + Extension = null; + LinkTarget = null; + Directory = null; + DirectoryName = null; + FileSystem = null; + } + + public IFileSystem FileSystem { get; } + public FileAttributes Attributes { get; set; } + public DateTime CreationTime { get; set; } + public DateTime CreationTimeUtc { get; set; } + public bool Exists { get; } + public string Extension { get; } + public string FullName { get; } + public DateTime LastAccessTime { get; set; } + public DateTime LastAccessTimeUtc { get; set; } + public DateTime LastWriteTime { get; set; } + public DateTime LastWriteTimeUtc { get; set; } + public string LinkTarget { get; } + public string Name { get; } + + public void Delete() + { + throw new NotImplementedException(); + } + + public void Refresh() + { + throw new NotImplementedException(); + } + + public StreamWriter AppendText() + { + throw new NotImplementedException(); + } + + public IFileInfo CopyTo(string destFileName) + { + throw new NotImplementedException(); + } + + public IFileInfo CopyTo(string destFileName, bool overwrite) + { + throw new NotImplementedException(); + } + + public Stream Create() + { + throw new NotImplementedException(); + } + + public StreamWriter CreateText() + { + throw new NotImplementedException(); + } + + public void Decrypt() + { + throw new NotImplementedException(); + } + + public void Encrypt() + { + throw new NotImplementedException(); + } + + public FileSecurity GetAccessControl() + { + throw new NotImplementedException(); + } + + public FileSecurity GetAccessControl(AccessControlSections includeSections) + { + throw new NotImplementedException(); } + + public void MoveTo(string destFileName) + { + throw new NotImplementedException(); + } + + public void MoveTo(string destFileName, bool overwrite) + { + throw new NotImplementedException(); + } + + public Stream Open(FileMode mode) + { + throw new NotImplementedException(); + } + + public Stream Open(FileMode mode, FileAccess access) + { + throw new NotImplementedException(); + } + + public Stream Open(FileMode mode, FileAccess access, FileShare share) + { + throw new NotImplementedException(); + } + + public Stream OpenRead() + { + throw new NotImplementedException(); + } + + public StreamReader OpenText() + { + throw new NotImplementedException(); + } + + public Stream OpenWrite() + { + throw new NotImplementedException(); + } + + public IFileInfo Replace(string destinationFileName, string destinationBackupFileName) + { + throw new NotImplementedException(); + } + + public IFileInfo Replace(string destinationFileName, string destinationBackupFileName, bool ignoreMetadataErrors) + { + throw new NotImplementedException(); + } + + public void SetAccessControl(FileSecurity fileSecurity) + { + throw new NotImplementedException(); + } + + public IDirectoryInfo Directory { get; } + public string DirectoryName { get; } + public bool IsReadOnly { get; set; } + public long Length { get; } } \ No newline at end of file