diff --git a/ImmichFrame.Core.Tests/Logic/QueueMutator/DuplicateAvoidingQueueMutatorTests.cs b/ImmichFrame.Core.Tests/Logic/QueueMutator/DuplicateAvoidingQueueMutatorTests.cs new file mode 100644 index 00000000..8c57e309 --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/QueueMutator/DuplicateAvoidingQueueMutatorTests.cs @@ -0,0 +1,115 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Logic.QueueMutator; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; + +namespace ImmichFrame.Core.Tests.Logic.QueueMutator; + +[TestFixture] +public class DuplicateAvoidingQueueMutatorTests +{ + private IQueueMutator _queueMutator; + + [SetUp] + public void Setup() + { + _queueMutator = new DuplicateAvoidingQueueMutator(2, new NullLogger()); + } + + [Test] + public void Mutate_NoItems_Succeeds() + { + IEnumerable result = [new()]; + + Assert.DoesNotThrow(() => result = _queueMutator.Mutate([])); + Assert.That(result, Is.Empty); + } + + [Test] + public void Mutate_NoDuplicates_NoChange() + { + List original = [ + new(){Id = "aap"}, + new(){Id = "noot"}, + new(){Id = "mies"}, + new(){Id = "aardappel"}, + ]; + IEnumerable result = []; + IEnumerable input = new List(original); + + Assert.DoesNotThrow(() => result = _queueMutator.Mutate(input)); + Assert.That(result, Is.EqualTo(original)); + } + + [Test] + public void Mutate_SeparatedDuplicates_NoChange() + { + List original = [ + new(){Id = "aap"}, + new(){Id = "mies"}, + new(){Id = "noot"}, + new(){Id = "mies"}, + new(){Id = "aardappel"}, + new(){Id = "mies"}, + ]; + IEnumerable result = []; + IEnumerable input = new List(original); + + Assert.DoesNotThrow(() => result = _queueMutator.Mutate(input)); + Assert.That(result, Is.EqualTo(original)); + } + + [Test] + public void Mutate_AdjacentDuplicates_ShouldSeparate() + { + List original = [ + new(){Id = "aap"}, + new(){Id = "aap"}, + new(){Id = "noot"}, + new(){Id = "mies"}, + new(){Id = "aardappel"}, + ]; + IEnumerable result = []; + IEnumerable input = new List(original); + + Assert.DoesNotThrow(() => result = _queueMutator.Mutate(input)); + var assetResponseDtos = result as AssetResponseDto[] ?? result.ToArray(); + for(int i = 0; i < assetResponseDtos.Length - 1; i++) + { + Assert.That(assetResponseDtos.ElementAt(i).Id, Is.Not.EqualTo(assetResponseDtos.ElementAt(i + 1).Id)); + } + } + [Test] + public void Mutate_AdjacentDuplicates_differentBatch_NoChange() + { + List original = [ + new(){Id = "aap"}, + new(){Id = "noot"}, + new(){Id = "noot"}, + new(){Id = "mies"}, + new(){Id = "aardappel"}, + ]; + IEnumerable result = []; + IEnumerable input = new List(original); + + Assert.DoesNotThrow(() => result = _queueMutator.Mutate(input)); + Assert.That(result, Is.EqualTo(original)); + } + [Test] + public void Mutate_LandscapeDuplicates_adjacent_NoChange() + { + List original = [ + new(){Id = "aap"}, + new(){Id = "peter"}, + new(){Id = "noot", ExifInfo =new(){Orientation = "1", ExifImageWidth = 200, ExifImageHeight = 100}}, + new(){Id = "noot", ExifInfo =new(){Orientation = "1", ExifImageWidth = 200, ExifImageHeight = 100}}, + new(){Id = "mies"}, + new(){Id = "aardappel"}, + ]; + IEnumerable result = []; + IEnumerable input = new List(original); + + Assert.DoesNotThrow(() => result = _queueMutator.Mutate(input)); + Assert.That(result, Is.EqualTo(original)); + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Helpers/ImageHelper.cs b/ImmichFrame.Core/Helpers/ImageHelper.cs index 8ddb5521..08c3668c 100644 --- a/ImmichFrame.Core/Helpers/ImageHelper.cs +++ b/ImmichFrame.Core/Helpers/ImageHelper.cs @@ -14,5 +14,40 @@ public static Stream SaveDataUrlToStream(string dataUrl) return new MemoryStream(binData); } + + public static double GetAspectRatioFloat(double width, double height) + { + if (width <= 0 || height <= 0) + throw new ArgumentException("Width and height must be positive."); + + return width / height; + } + + public static bool IsLandscape(double exifWidth, double exifHeight, string exifOrientation) + { + double width; + double height; + if (exifOrientation == "1" + || exifOrientation == "2" + || exifOrientation == "3" + || exifOrientation == "4") + { + width = exifWidth; + height = exifHeight; + }else if (exifOrientation == "5" + || exifOrientation == "6" + || exifOrientation == "7" + || exifOrientation == "8") + { + height = exifWidth; + width = exifHeight; + } + else + { + throw new ArgumentException(nameof(exifOrientation)); + } + + return GetAspectRatioFloat(width, height) > 1; + } } } diff --git a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs index fad0eb32..e2bbabe5 100644 --- a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs +++ b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs @@ -2,6 +2,7 @@ using ImmichFrame.Core.Api; using ImmichFrame.Core.Helpers; using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Logic.QueueMutator; using ImmichFrame.Core.Models; using Microsoft.Extensions.Logging; @@ -13,13 +14,15 @@ public class MultiImmichFrameLogicDelegate : IImmichFrameLogic private readonly IServerSettings _serverSettings; private readonly IAccountSelectionStrategy _accountSelectionStrategy; private readonly ILogger _logger; + private readonly IQueueMutator _queueMutator; public MultiImmichFrameLogicDelegate(IServerSettings serverSettings, Func logicFactory, ILogger logger, - IAccountSelectionStrategy accountSelectionStrategy) + IAccountSelectionStrategy accountSelectionStrategy, IQueueMutator queueMutator) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _accountSelectionStrategy = accountSelectionStrategy; + _queueMutator = queueMutator; _serverSettings = serverSettings; _accountToDelegate = serverSettings.Accounts.ToFrozenDictionary( keySelector: a => a, @@ -32,7 +35,7 @@ public MultiImmichFrameLogicDelegate(IServerSettings serverSettings, public async Task> GetAssets() - => (await _accountSelectionStrategy.GetAssets()).Shuffle().Select(it => it.ToAsset()); + => _queueMutator.Mutate((await _accountSelectionStrategy.GetAssets()).Select(it => it.ToAsset())); public Task GetAssetInfoById(Guid assetId) diff --git a/ImmichFrame.Core/Logic/QueueMutator/DuplicateAvoidingQueueMutator.cs b/ImmichFrame.Core/Logic/QueueMutator/DuplicateAvoidingQueueMutator.cs new file mode 100644 index 00000000..c2eae6ee --- /dev/null +++ b/ImmichFrame.Core/Logic/QueueMutator/DuplicateAvoidingQueueMutator.cs @@ -0,0 +1,69 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Helpers; +using Microsoft.Extensions.Logging; + +namespace ImmichFrame.Core.Logic.QueueMutator; + +public class DuplicateAvoidingQueueMutator: QueueMutatorBase +{ + private readonly int _batchSize; + private readonly ILogger _logger; + + public DuplicateAvoidingQueueMutator(int batchSize, ILogger logger) + { + if(batchSize <= 0) throw new ArgumentException("Batch size must be greater than 0", nameof(batchSize)); + _batchSize = batchSize; + _logger = logger; + } + public override IEnumerable Mutate(IEnumerable source) + { + var remaining = new Queue(source); + List result = new(remaining.Count); + var batch = new List(_batchSize); + + int consecutiveDuplicates = 0; + while (remaining.Count > 0) + { + var currentItem = remaining.Dequeue(); + + bool landscape = false; + try + { + + landscape = ImageHelper.IsLandscape(currentItem.ExifInfo.ExifImageWidth!.Value, + currentItem.ExifInfo.ExifImageHeight!.Value, currentItem.ExifInfo.Orientation); + } + catch + { + _logger.LogWarning("Failed to determine orientation for asset {AssetId}, defaulting to portrait", currentItem.Id); + } + + + if (!landscape && batch.Any(x => x.Id == currentItem.Id)) + { + remaining.Enqueue(currentItem); + consecutiveDuplicates++; + // no other items to process, break to avoid infinite loop + if (consecutiveDuplicates >= remaining.Count) break; + continue; + } + + batch.Add(currentItem); + + if (batch.Count >= _batchSize) + { + result.AddRange(batch); + batch.Clear(); + } + + consecutiveDuplicates = 0; + } + + if (batch.Count > 0) + { + result.AddRange(batch); + } + + return Next?.Mutate(result) ?? result; + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/QueueMutator/IQueueMutator.cs b/ImmichFrame.Core/Logic/QueueMutator/IQueueMutator.cs new file mode 100644 index 00000000..b983f5fc --- /dev/null +++ b/ImmichFrame.Core/Logic/QueueMutator/IQueueMutator.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.Contracts; + +namespace ImmichFrame.Core.Logic.QueueMutator; + +public interface IQueueMutator +{ + [Pure] + IEnumerable Mutate(IEnumerable source); + void SetNext(IQueueMutator next); +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/QueueMutator/QueueMutatorBase.cs b/ImmichFrame.Core/Logic/QueueMutator/QueueMutatorBase.cs new file mode 100644 index 00000000..51fb4c3b --- /dev/null +++ b/ImmichFrame.Core/Logic/QueueMutator/QueueMutatorBase.cs @@ -0,0 +1,13 @@ +namespace ImmichFrame.Core.Logic.QueueMutator; + +public abstract class QueueMutatorBase: IQueueMutator +{ + protected IQueueMutator? Next; + + public abstract IEnumerable Mutate(IEnumerable source); + + public void SetNext(IQueueMutator next) + { + Next = next; + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/QueueMutator/RandomQueueMutator.cs b/ImmichFrame.Core/Logic/QueueMutator/RandomQueueMutator.cs new file mode 100644 index 00000000..694a3c37 --- /dev/null +++ b/ImmichFrame.Core/Logic/QueueMutator/RandomQueueMutator.cs @@ -0,0 +1,11 @@ +namespace ImmichFrame.Core.Logic.QueueMutator; + +public class RandomQueueMutator: QueueMutatorBase +{ + private readonly Random _random = new(); + public override IEnumerable Mutate(IEnumerable source) + { + var result = source.OrderBy(_ => _random.Next()); + return Next?.Mutate(result) ?? result; + } +} \ No newline at end of file diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index f2d68244..c4932cd2 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -3,9 +3,12 @@ using ImmichFrame.WebApi.Models; using Microsoft.AspNetCore.Authentication; using System.Reflection; +using ImmichFrame.Core.Api; using ImmichFrame.Core.Logic; using ImmichFrame.Core.Logic.AccountSelection; +using ImmichFrame.Core.Logic.QueueMutator; using ImmichFrame.WebApi.Helpers.Config; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; var builder = WebApplication.CreateBuilder(args); //log the version number @@ -73,6 +76,13 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddTransient>(x => +{ + var mutator = new RandomQueueMutator(); + mutator.SetNext(new DuplicateAvoidingQueueMutator(2, x.GetService>()!)); + return mutator; +}); + builder.Services.AddAuthorization(options => { options.AddPolicy("AllowAnonymous", policy => policy.RequireAssertion(context => true)); }); builder.Services.AddAuthentication("ImmichFrameScheme")