diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..0167ca0 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,64 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: .NET + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: windows-latest + + steps: + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --configuration Release --no-restore + - name: Publish ReadyToRun + run: dotnet publish ImageCompressor\ImageCompressor.csproj -c Release -r win-x64 /p:AssemblyVersion=1.0.${{ github.run_number }} /p:FileVersion=1.0.${{ github.run_number }} /p:SelfContained=true /p:PublishReadyToRun=true /p:PublishSingleFile=true -o out + - name: Compress ReadyToRun + run: Compress-Archive out\ImageCompressor.exe ImageCompressor_ReadyToRun_v1.0.${{ github.run_number }}.zip + - name: Publish Service ReadyToRun + run: dotnet publish ImageCompressor.Service\ImageCompressor.Service.csproj -c Release -r win-x64 /p:AssemblyVersion=1.0.${{ github.run_number }} /p:FileVersion=1.0.${{ github.run_number }} /p:SelfContained=true /p:PublishReadyToRun=true /p:PublishSingleFile=true -o out.Service + - name: Compress Service ReadyToRun + run: Compress-Archive out.Service\ImageCompressor.Service.exe ImageCompressor.Service_ReadyToRun_v1.0.${{ github.run_number }}.zip + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: v1.0.${{ github.run_number }} + release_name: v1.0.${{ github.run_number }} + draft: false + prerelease: true + - name: Upload ReadyToRun + id: upload-release-asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps + asset_path: ImageCompressor_ReadyToRun_v1.0.${{ github.run_number }}.zip + asset_name: ImageCompressor_ReadyToRun_v1.0.${{ github.run_number }}.zip + asset_content_type: application/zip + - name: Upload ReadyToRun Service + id: upload-release-asset2 + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps + asset_path: ImageCompressor.Service_ReadyToRun_v1.0.${{ github.run_number }}.zip + asset_name: ImageCompressor.Service_ReadyToRun_v1.0.${{ github.run_number }}.zip + asset_content_type: application/zip diff --git a/.gitignore b/.gitignore index 9491a2f..2412283 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,7 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +#Rider +.idea/ \ No newline at end of file diff --git a/ImageCompressor.Service/CompressionTasks.cs b/ImageCompressor.Service/CompressionTasks.cs new file mode 100644 index 0000000..23621a3 --- /dev/null +++ b/ImageCompressor.Service/CompressionTasks.cs @@ -0,0 +1,9 @@ +namespace ImageCompressor.Service; + +public class CompressionTasks +{ + public string Name { get; set; } + public TimeSpan InitialDelay { get; set; } = TimeSpan.FromSeconds(1); + public TimeSpan Period { get; set; } = TimeSpan.FromMinutes(5); + public CompressImagesSettings CompressionSettings { get; set; } +} \ No newline at end of file diff --git a/ImageCompressor.Service/ImageCompressor.Service.csproj b/ImageCompressor.Service/ImageCompressor.Service.csproj new file mode 100644 index 0000000..66b76c8 --- /dev/null +++ b/ImageCompressor.Service/ImageCompressor.Service.csproj @@ -0,0 +1,23 @@ + + + + net7.0 + enable + enable + dotnet-ImageCompressor.Service-303BD63E-8769-4D93-8BD9-EB337757F14E + + + + + + + + + + + + + + + + diff --git a/ImageCompressor.Service/Program.cs b/ImageCompressor.Service/Program.cs new file mode 100644 index 0000000..4569cec --- /dev/null +++ b/ImageCompressor.Service/Program.cs @@ -0,0 +1,26 @@ +using ImageCompressor; +using ImageCompressor.Service; +using Microsoft.Extensions.Options; +using Serilog; + +var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json") + .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", true) + .Build(); + +IHost host = Host.CreateDefaultBuilder(args) + .ConfigureServices(services => + { + services.AddOptions() + .BindConfiguration("Settings") // 👈 Bind the section + .ValidateDataAnnotations() // 👈 Enable validation + .ValidateOnStart(); // 👈 Validate on app start + services.AddSingleton(resolver => resolver.GetRequiredService>().Value); + }) + .ConfigureServices(services => { services.AddHostedService(); }) + .UseSerilog((context, loggerConfiguration) => loggerConfiguration.ReadFrom.Configuration(configuration)) + .UseWindowsService() + .Build(); + +host.Run(); \ No newline at end of file diff --git a/ImageCompressor.Service/Properties/launchSettings.json b/ImageCompressor.Service/Properties/launchSettings.json new file mode 100644 index 0000000..1a37a39 --- /dev/null +++ b/ImageCompressor.Service/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "ImageCompressor.Service": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ImageCompressor.Service/SettingsRoot.cs b/ImageCompressor.Service/SettingsRoot.cs new file mode 100644 index 0000000..f156e09 --- /dev/null +++ b/ImageCompressor.Service/SettingsRoot.cs @@ -0,0 +1,6 @@ +namespace ImageCompressor.Service; + +public class SettingsRoot +{ + public List CompressionTasks { get; set; } = new (); +} \ No newline at end of file diff --git a/ImageCompressor.Service/Worker.cs b/ImageCompressor.Service/Worker.cs new file mode 100644 index 0000000..325d0ae --- /dev/null +++ b/ImageCompressor.Service/Worker.cs @@ -0,0 +1,67 @@ +using System.Reactive.Disposables; +using System.Reactive.Linq; + +namespace ImageCompressor.Service; + +public class Worker : BackgroundService +{ + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + disposables.Dispose(); + } + } + + public sealed override void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private readonly ILogger logger; + private readonly SettingsRoot settings; + + private readonly CompositeDisposable disposables = new(); + + public Worker(ILogger logger, SettingsRoot settings) + { + this.logger = logger; + this.settings = settings; + } + + public override Task StartAsync(CancellationToken cancellationToken) + { + foreach (var task in settings.CompressionTasks) + { + logger.LogInformation("Setting up task {TaskName}...", task.Name); + var validationResult = task.CompressionSettings.Validate(); + logger.LogInformation("Validation result: {ValidationResult}: {ValidationMessage}", validationResult.Successful, validationResult.Message); + if (validationResult.Successful) + { + var subscrition = Observable.Timer(task.InitialDelay, task.Period) + .Do(_ => logger.LogInformation("Executing task {TaskName}", task.Name)) + .Do(_ => (new CompressImagesCommand()).ExecuteEmbedded(logger, task.CompressionSettings)) + .Retry() + .Subscribe(_ => {}, exception => logger.LogError(exception, "Error while executing task {TaskName}", task.Name)); + disposables.Add(subscrition); + + } + } + return base.StartAsync(cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(-1, stoppingToken); + } + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + disposables?.Dispose(); + return base.StopAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/ImageCompressor.Service/appsettings.Development.json b/ImageCompressor.Service/appsettings.Development.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/ImageCompressor.Service/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/ImageCompressor.Service/appsettings.json b/ImageCompressor.Service/appsettings.json new file mode 100644 index 0000000..507511b --- /dev/null +++ b/ImageCompressor.Service/appsettings.json @@ -0,0 +1,32 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Serilog": { + "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ], + "MinimumLevel": "Debug", + "WriteTo": [ + { "Name": "Console" }, + { "Name": "File", "Args": { "path": "Logs/log.txt" } } + ] + }, + "Settings": { + "CompressionTasks": [ + { + "Name": "Compress", + "CompressionSettings": { + "SourcePath": "C:\\temp\\pictures", + "TargetPath": "C:\\temp\\pictures\\out", + "DeleteOriginal": true, + "OverwriteExisting": true, + "IncludeSubDirectories": true, + "OutMode": "Jpeg", + "MinAgeInDays": 30 + } + } + ] + } +} diff --git a/ImageCompressor.sln b/ImageCompressor.sln index 8479926..5f3578a 100644 --- a/ImageCompressor.sln +++ b/ImageCompressor.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.0.31808.319 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImageCompressor", "ImageCompressor\ImageCompressor.csproj", "{E765A8FD-5F91-4AC8-A05E-08E4EAD5FA49}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImageCompressor.Service", "ImageCompressor.Service\ImageCompressor.Service.csproj", "{C5679F38-15C9-4FDE-83AC-0B86B7458979}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {E765A8FD-5F91-4AC8-A05E-08E4EAD5FA49}.Debug|Any CPU.Build.0 = Debug|Any CPU {E765A8FD-5F91-4AC8-A05E-08E4EAD5FA49}.Release|Any CPU.ActiveCfg = Release|Any CPU {E765A8FD-5F91-4AC8-A05E-08E4EAD5FA49}.Release|Any CPU.Build.0 = Release|Any CPU + {C5679F38-15C9-4FDE-83AC-0B86B7458979}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5679F38-15C9-4FDE-83AC-0B86B7458979}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5679F38-15C9-4FDE-83AC-0B86B7458979}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5679F38-15C9-4FDE-83AC-0B86B7458979}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ImageCompressor/CompressImagesCommand.cs b/ImageCompressor/CompressImagesCommand.cs index 77f6fdc..edacc59 100644 --- a/ImageCompressor/CompressImagesCommand.cs +++ b/ImageCompressor/CompressImagesCommand.cs @@ -5,13 +5,44 @@ using System.IO; using System.Linq; using ImageCompressor.Compressors; +using Microsoft.Extensions.Logging; using Spectre.Console.Cli; using Spectre.Console; namespace ImageCompressor; -internal sealed class CompressImagesCommand : Command +public sealed class CompressImagesCommand : Command { + public void ExecuteEmbedded(ILogger logger, [NotNull] CompressImagesSettings settings) + { + logger?.LogInformation($"Compressing {settings.SearchPattern} files to {settings.OutMode}"); + if (settings.SampleRatio is < 1) + logger?.LogInformation($"sampeling {settings.SampleRatio * 100} %"); + logger?.LogInformation($"\tfrom {settings.GetSourcePath()}"); + logger?.LogInformation($"\tto {settings.GetTargetPath()}"); + + var stopwatch = Stopwatch.StartNew(); + + var results = ConvertFiles(settings); + + var originalSize = results.Where(r => r.Result == Result.Success).Sum(r => r.OriginalSize); + var compressedSize = results.Where(r => r.Result == Result.Success).Sum(r => r.CompressedSize); + + logger?.LogInformation($"Converted {results.Count(r => r.Result == Result.Success):N0} {settings.SearchPattern} files in {stopwatch.Elapsed}."); + logger?.LogInformation($"Reduced size from {originalSize >> 20} to {(compressedSize >> 20)} MiB: {(compressedSize * 100.0 / (originalSize + 0.1)):N1} %."); + if (results.Any(r => r.Result == Result.Skipped)) + { + logger?.LogInformation($"{results.Count(r => r.Result == Result.Skipped)} files already existed and were skipped."); + } + if (results.Any(r => r.Result == Result.Failed)) + { + logger?.LogError($"Error: {results.Count(r => r.Result == Result.Failed)} files could not be compressed:"); + foreach (var res in results.Where(r => r.Result == Result.Failed).Take(50)) + { + logger?.LogInformation($"{res.Path}: {res.ErrorMessage}"); + } + } + } public override int Execute([NotNull] CommandContext context, [NotNull] CompressImagesSettings settings) { AnsiConsole.WriteLine(); @@ -93,6 +124,16 @@ private List ConvertFiles(CompressImagesSettings settings) var files = new DirectoryInfo(settings.GetSourcePath()) .EnumerateFiles(settings.SearchPattern, settings.IncludeSubDirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); + if (settings.MinAgeInDays != null) + { + files = files.Where(f => f.CreationTimeUtc <= DateTime.UtcNow.AddDays(-settings.MinAgeInDays.Value)); + } + + if (settings.MaxAgeInDays != null) + { + files = files.Where(f => f.CreationTimeUtc >= DateTime.UtcNow.AddDays(-settings.MaxAgeInDays.Value)); + } + if (settings.SampleRatio is < 1) { var rand = new Random(); diff --git a/ImageCompressor/CompressImagesSettings.cs b/ImageCompressor/CompressImagesSettings.cs index a3d6272..2a1f561 100644 --- a/ImageCompressor/CompressImagesSettings.cs +++ b/ImageCompressor/CompressImagesSettings.cs @@ -43,7 +43,7 @@ public enum OutputMode [Description("Number of concurrent compressions (default: [white]4[/])")] [CommandOption("--parallel")] [DefaultValue(4)] - public int Parallel { get; init; } + public int Parallel { get; init; } = 4; [Description("Quality used for output\r\ndefault: [white]98[/] for Jpeg, [white]4[/] for Brotli)")] [CommandOption("-q|--quality")] @@ -65,6 +65,13 @@ public enum OutputMode [Description("Path to store images. Defaults to [[sourcePath]].")] [CommandArgument(1, "[targetPath]")] public string? TargetPath { get; init; } + + [Description("Minimal age in days of files to process - exclude files younger than n days")] + [CommandOption("--minage")] + public int? MinAgeInDays { get; init; } + [Description("Maximal age in days of files to process - exclude files older than n days")] + [CommandOption("--maxage")] + public int? MaxAgeInDays { get; init; } public string GetSourcePath() => Path.GetFullPath(SourcePath ?? Directory.GetCurrentDirectory()); public string GetTargetPath() => Path.GetFullPath(TargetPath ?? SourcePath ?? Directory.GetCurrentDirectory()); diff --git a/ImageCompressor/ImageCompressor.csproj b/ImageCompressor/ImageCompressor.csproj index 0adb28c..c864ea0 100644 --- a/ImageCompressor/ImageCompressor.csproj +++ b/ImageCompressor/ImageCompressor.csproj @@ -7,6 +7,7 @@ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..d26bf9e --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# ImageCompressor + +[![.NET](https://github.com/Peter-B-/ImageCompressor/actions/workflows/dotnet.yml/badge.svg)](https://github.com/Peter-B-/ImageCompressor/actions/workflows/dotnet.yml) + +# How to get it? + +Download the utility from the [releases](https://github.com/Peter-B-/ImageCompressor/releases/) and put it wherever you like to execute it. + +# Use it from the CLI + +```powershell +ImageCompressor.exe [sourcePath] [targetPath] [OPTIONS] +``` +(powershell or bash) + +## Arguments +- `[sourcePath]` Path to search. Defaults to current directory +- `[targetPath]` Path to store images. Defaults to `[sourcePath]` + +## Options +``` + -h, --help Prints help information + -d, --delete Delete original file after conversion + -f, --force Overwrite existing files. If false, existing files are skipped + -r, --recursive Include subdirectories + -m, --mode The compression file format to be used: + image: Jpeg, Webp + lossless: Png, WebpLl + compression: Brotli, BrotliUncompress + --parallel Number of concurrent compressions (default: 4) + -q, --quality Quality used for output + default: 98 for Jpeg, 4 for Brotli + --sample The ratio of files to process. + Use 0.025 to convert 2.5% of all images + --pattern Search pattern to discover files. Defaults to *.bmp + --minage Minimal age in days of files to process - exclude files younger than n days + --maxage Maximal age in days of files to process - exclude files older than n days + +``` + +# Use it as a service + +ImageCompressor can also run as service and regular trigger different task so you don't have to trigger them manually. +Since it also able to copy (recreating the relative path) and delete the original files, it basically works like a `robocopy` with compression power for images! +Follow all the following steps to get ImageCompressor running as a Service. + +## Download the service +Download the `ImageCompressor.Service` from the [releases](https://github.com/Peter-B-/ImageCompressor/releases/) and put it into your favorite location (e.g.: in `C:\tools\imagecompressor`) + +## Create a new service + +```powershell +> New-Service -Name ImageCompressor -BinaryPathName "C:\tools\imagecompressor\ImageCompressor.Service.exe --contentRoot C:\tools\imagecompressor\I" -Description "ImageCompressor" -DisplayName "ImageCompressor" -StartupType Automatic +``` + +## Create your settings + +Put the json content into a file `appsettings.json` located near to the `ImageCompressor.Service.exe`. + +You can even add new compression tasks to be run in certain periods + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Serilog": { + "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ], + "MinimumLevel": "Debug", + "WriteTo": [ + { "Name": "Console" }, + { "Name": "File", "Args": { "path": "Logs/log.txt" } } + ] + }, + "Settings": { + "CompressionTasks": [ + { + "Name": "Compress", + "Period": "00:00:30", + "InitialDelay": "00:00:01", + "CompressionSettings": { + "SourcePath": "C:\\temp\\pictures", + "TargetPath": "C:\\temp\\pictures\\out", + "DeleteOriginal": true, + "OverwriteExisting": true, + "IncludeSubDirectories": true, + "OutMode": "Jpeg", + "MinAgeInDays": 30 + } + } + ] + } +} +``` +For logging configuration refer to [Serilog](https://github.com/serilog/serilog-settings-configuration) . + +## Start the service + +```powershell +> Start-Service -Name "ImageCompressor" +``` + +# Got troubles or wishes? + +- Give a star +- Raise an [issue](https://github.com/Peter-B-/ImageCompressor/issues/new) +- Start contribute +