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
+