diff --git a/.DS_Store b/.DS_Store index cd3f2d24..b5ec83df 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Accord/Directory.Build.targets b/Accord/Directory.Build.targets index 67be0f84..892691ec 100644 --- a/Accord/Directory.Build.targets +++ b/Accord/Directory.Build.targets @@ -1,6 +1,6 @@ 0 - 1701;1702;3245;0003;0436; + CA1416,MSB3245,SYSLIB0011,IL2026,IL3000,CS0436,MSB3245,SYSLIB0003,SYSLIB0006,SYSLIB0014,CS0108 \ No newline at end of file diff --git a/Damselfly.Core.DbModels/DBAbstractions/BaseModel.cs b/Damselfly.Core.DbModels/DBAbstractions/BaseModel.cs index 9b42c9e8..5d2b943a 100644 --- a/Damselfly.Core.DbModels/DBAbstractions/BaseModel.cs +++ b/Damselfly.Core.DbModels/DBAbstractions/BaseModel.cs @@ -241,7 +241,10 @@ public override int SaveChanges() /// /// /// The number of entities written to the DB - public async Task SaveChangesAsync(string contextDesc) + public async Task SaveChangesAsync(string contextDesc, + [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", + [System.Runtime.CompilerServices.CallerMemberNameAttribute] string sourceMethod = "", + [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0 ) { if (ReadOnly) { @@ -250,30 +253,47 @@ public async Task SaveChangesAsync(string contextDesc) return 1; } - try + int retriesRemaining = 3; + int recordsWritten = 0; + + while ( retriesRemaining > 0 ) { - // Write to the DB - var watch = new Stopwatch("SaveChanges" + contextDesc); + try + { + // Write to the DB + var watch = new Stopwatch("SaveChanges" + contextDesc); - LogChangeSummary(); + LogChangeSummary(); - int written = await base.SaveChangesAsync(); + recordsWritten = await base.SaveChangesAsync(); - Logging.LogTrace("{0} changes written to the DB", written); + Logging.LogTrace("{0} changes written to the DB", recordsWritten); - watch.Stop(); + watch.Stop(); - return written; - } - catch (Exception ex) - { - if (ex.InnerException != null) - Logging.Log("Exception - DB WRITE FAILED. InnerException: {0}", ex.InnerException.Message); - else - Logging.Log("Exception - DB WRITE FAILED: {0}", ex.Message); + break; + } + catch (Exception ex) + { + if (ex.Message.Contains("database is locked") && retriesRemaining > 0 ) + { + Logging.LogWarning($"Database locked for {contextDesc} - sleeping for 5s and retying {retriesRemaining}..."); + retriesRemaining--; + await Task.Delay(5 * 1000); + } + else + { + Logging.LogError($"Exception - DB WRITE FAILED for {contextDesc}: {ex.Message}" ); + Logging.LogError($" Called from {sourceMethod} line {lineNumber} in {sourceFilePath}."); + if (ex.InnerException != null) + Logging.LogError(" Exception - DB WRITE FAILED. InnerException: {0}", ex.InnerException.Message); + + } + } - return 0; } + + return recordsWritten; } /// diff --git a/Damselfly.Core.Utils/Constants/ConfigSettings.cs b/Damselfly.Core.Utils/Constants/ConfigSettings.cs index 0246f358..9b831d23 100644 --- a/Damselfly.Core.Utils/Constants/ConfigSettings.cs +++ b/Damselfly.Core.Utils/Constants/ConfigSettings.cs @@ -16,6 +16,7 @@ public class ConfigSettings public const string ImportSidecarKeywords = "ImportSidecarKeywords"; + public const string AIProcessingTimeRange = "AIProcessingTimeRange"; public const string AzureEndpoint = "AzureEndpoint"; public const string AzureApiKey = "AzureApiKey"; public const string AzureDetectionType = "AzureDetectionType"; diff --git a/Damselfly.Core.Utils/Utils/DateTimeExtensions.cs b/Damselfly.Core.Utils/Utils/DateTimeExtensions.cs new file mode 100644 index 00000000..c5f6803c --- /dev/null +++ b/Damselfly.Core.Utils/Utils/DateTimeExtensions.cs @@ -0,0 +1,33 @@ +using System; + +namespace Damselfly.Core.Utils +{ + public static class DateTimeExtensions + { + public static TimeSpan? LocalTimeSpanToUTC(this TimeSpan? ts) + { + if (ts.HasValue) + { + DateTime dt = DateTime.Now.Date.Add(ts.Value); + DateTime dtUtc = dt.ToUniversalTime(); + TimeSpan tsUtc = dtUtc.TimeOfDay; + return tsUtc; + } + + return null; + } + + public static TimeSpan? UTCTimeSpanToLocal(this TimeSpan? tsUtc) + { + if (tsUtc.HasValue) + { + DateTime dtUtc = DateTime.UtcNow.Date.Add(tsUtc.Value); + DateTime dt = dtUtc.ToLocalTime(); + TimeSpan ts = dt.TimeOfDay; + return ts; + } + + return null; + } + } +} diff --git a/Damselfly.Core.Utils/Utils/Logging.cs b/Damselfly.Core.Utils/Utils/Logging.cs index 5c8f5081..dad3d6ad 100644 --- a/Damselfly.Core.Utils/Utils/Logging.cs +++ b/Damselfly.Core.Utils/Utils/Logging.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using System.Threading; using Serilog; using Serilog.Core; @@ -45,34 +46,44 @@ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) /// public static Logger InitLogs() { - if (!Directory.Exists(LogFolder)) - Directory.CreateDirectory(LogFolder); - - logLevel.MinimumLevel = LogEventLevel.Information; - - if (Verbose) - logLevel.MinimumLevel = LogEventLevel.Verbose; - - if (Trace) - logLevel.MinimumLevel = LogEventLevel.Debug; - - string logFilePattern = Path.Combine(LogFolder, "Damselfly-.log"); - - logger = new LoggerConfiguration() - .MinimumLevel.ControlledBy(logLevel) - .Enrich.With( new ThreadIDEnricher() ) - .WriteTo.Console(outputTemplate: template) - .WriteTo.File( logFilePattern, - outputTemplate: template, - rollingInterval: RollingInterval.Day, - fileSizeLimitBytes:104857600, - retainedFileCountLimit:10) - .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) - .CreateLogger(); - - logger.Information("=== Damselfly Log Started ==="); - logger.Information("Log folder: {0}", LogFolder); - logger.Information("LogLevel: {0}", logLevel.MinimumLevel); + try + { + if (!Directory.Exists(LogFolder)) + { + Console.WriteLine($"Creating log folder {LogFolder}"); + Directory.CreateDirectory(LogFolder); + } + + logLevel.MinimumLevel = LogEventLevel.Information; + + if (Verbose) + logLevel.MinimumLevel = LogEventLevel.Verbose; + + if (Trace) + logLevel.MinimumLevel = LogEventLevel.Debug; + + string logFilePattern = Path.Combine(LogFolder, "Damselfly-.log"); + + logger = new LoggerConfiguration() + .MinimumLevel.ControlledBy(logLevel) + .Enrich.With(new ThreadIDEnricher()) + .WriteTo.Console(outputTemplate: template) + .WriteTo.File(logFilePattern, + outputTemplate: template, + rollingInterval: RollingInterval.Day, + fileSizeLimitBytes: 104857600, + retainedFileCountLimit: 10) + .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) + .CreateLogger(); + + logger.Information("=== Damselfly Log Started ==="); + logger.Information("Log folder: {0}", LogFolder); + logger.Information("LogLevel: {0}", logLevel.MinimumLevel); + } + catch( Exception ex ) + { + Console.WriteLine($"Unable to initialise logs: {ex}"); + } return logger; } diff --git a/Damselfly.Core/ImageProcessing/ImageMagickProcessor.cs b/Damselfly.Core/ImageProcessing/ImageMagickProcessor.cs index 51c8b5ba..751197a5 100644 --- a/Damselfly.Core/ImageProcessing/ImageMagickProcessor.cs +++ b/Damselfly.Core/ImageProcessing/ImageMagickProcessor.cs @@ -49,7 +49,7 @@ private void CheckToolStatus() /// /// Source. /// Sizes. - public Task CreateThumbs(FileInfo source, IDictionary destFiles ) + public async Task CreateThumbs(FileInfo source, IDictionary destFiles ) { // This processor doesn't support hash creation ImageProcessResult result = new ImageProcessResult { ThumbsGenerated = false, ImageHash = string.Empty }; @@ -119,7 +119,7 @@ public Task CreateThumbs(FileInfo source, IDictionary CreateThumbs(FileInfo source, IDictionary image) return result; } + /// + /// Draw rectangles onto a file + /// + /// + /// + /// public static void DrawRects( string path, List rects, string output ) { using var image = Image.Load(path); @@ -115,14 +121,14 @@ public static void DrawRects( string path, List rects, string output /// /// /// - public Task CreateThumbs(FileInfo source, IDictionary destFiles) + public async Task CreateThumbs(FileInfo source, IDictionary destFiles) { var result = new ImageProcessResult(); Stopwatch load = new Stopwatch("ImageSharpLoad"); // Image.Load(string path) is a shortcut for our default type. // Other pixel formats use Image.Load(string path)) - using var image = Image.Load(source.FullName); + using var image = await Image.LoadAsync(source.FullName); // We've got the image in memory. Create the hash. result.ImageHash = GetHash(image); @@ -160,14 +166,26 @@ public Task CreateThumbs(FileInfo source, IDictionary x.Resize(opts)); - image.Save(dest.FullName); + await image.SaveAsync(dest.FullName); result.ThumbsGenerated = true; } thumbs.Stop(); - return Task.FromResult(result); + return result; + } + + public async Task GetCroppedFile( FileInfo source, int x, int y, int width, int height, FileInfo destFile ) + { + // Image.Load(string path) is a shortcut for our default type. + // Other pixel formats use Image.Load(string path)) + using var image = Image.Load(source.FullName); + + var rect = new Rectangle(x, y, width, height); + image.Mutate(x => x.AutoOrient()); + image.Mutate(x => x.Crop( rect )); + await image.SaveAsync(destFile.FullName); } /// diff --git a/Damselfly.Core/ImageProcessing/SkiaSharpProcessor.cs b/Damselfly.Core/ImageProcessing/SkiaSharpProcessor.cs index bb4af57e..676179aa 100644 --- a/Damselfly.Core/ImageProcessing/SkiaSharpProcessor.cs +++ b/Damselfly.Core/ImageProcessing/SkiaSharpProcessor.cs @@ -139,6 +139,39 @@ public Task CreateThumbs(FileInfo source, IDictionary + /// Crop a file + /// + /// + /// + /// + /// + /// + /// + /// + public Task GetCroppedFile(FileInfo source, int x, int y, int width, int height, FileInfo dest) + { + try + { + using var sourceBitmap = LoadOrientedBitmap(source, width); + + // setup crop rect + var cropRect = new SKRectI(x, y, x + width, y + height); + var cropped = Crop(sourceBitmap, cropRect); + using SKData data = cropped.Encode(SKEncodedImageFormat.Jpeg, 90); + + using (var stream = new FileStream(dest.FullName, FileMode.Create, FileAccess.Write)) + data.SaveTo(stream); + } + catch (Exception ex) + { + Logging.Log($"Exception during Crop processing: {ex.Message}"); + throw; + } + + return Task.CompletedTask; + } + /// /// Crop the image to fit within the dimensions specified. /// @@ -167,15 +200,26 @@ private SKBitmap Crop(SKBitmap original, SKSize maxSize) Bottom = original.Height - cropTopBottom + cropTopBottom / 2 }; - // crop - SKBitmap bitmap = new SKBitmap(cropRect.Width, cropRect.Height); - original.ExtractSubset(bitmap, cropRect); - return bitmap; + return Crop(original, cropRect); } else return original.Copy(); } + /// + /// Crop to a rectangle + /// + /// + /// + /// + private SKBitmap Crop( SKBitmap original, SKRectI cropRect) + { + // crop + SKBitmap bitmap = new SKBitmap(cropRect.Width, cropRect.Height); + original.ExtractSubset(bitmap, cropRect); + return bitmap; + } + /// /// Loads an image from a disk file, decoding for the optimal required /// size so that we don't load the entire image for a smaller target, diff --git a/Damselfly.Core/Interfaces/IImageProcessor.cs b/Damselfly.Core/Interfaces/IImageProcessor.cs index 83fff90c..a2d1f310 100644 --- a/Damselfly.Core/Interfaces/IImageProcessor.cs +++ b/Damselfly.Core/Interfaces/IImageProcessor.cs @@ -20,6 +20,7 @@ public interface IImageProcessor { Task CreateThumbs(FileInfo source, IDictionary destFiles ); void TransformDownloadImage(string input, Stream output, ExportConfig config); + Task GetCroppedFile(FileInfo source, int x, int y, int width, int height, FileInfo destFile); static ICollection SupportedFileExtensions { get; } } } diff --git a/Damselfly.Core/Models/ImageContext.cs b/Damselfly.Core/Models/ImageContext.cs index c959b64b..fbad3f44 100644 --- a/Damselfly.Core/Models/ImageContext.cs +++ b/Damselfly.Core/Models/ImageContext.cs @@ -609,15 +609,15 @@ public enum GroupingType }; public string SearchText { get; set; } - public DateTime MaxDate { get; set; } = DateTime.MaxValue; - public DateTime MinDate { get; set; } = DateTime.MinValue; + public DateTime? MaxDate { get; set; } = null; + public DateTime? MinDate { get; set; } = null; public ulong MaxSizeKB { get; set; } = ulong.MaxValue; public ulong MinSizeKB { get; set; } = ulong.MinValue; public Folder Folder { get; set; } = null; public bool TagsOnly { get; set; } = false; public bool IncludeAITags { get; set; } = true; public int CameraId { get; set; } = -1; - public int TagId { get; set; } = -1; + public Tag Tag { get; set; } = null; public int LensId { get; set; } = -1; public GroupingType Grouping { get; set; } = GroupingType.None; public SortOrderType SortOrder { get; set; } = SortOrderType.Descending; diff --git a/Damselfly.Core/Models/SideCars/On1Sidecar.cs b/Damselfly.Core/Models/SideCars/On1Sidecar.cs index 4e2d1457..71fdffad 100644 --- a/Damselfly.Core/Models/SideCars/On1Sidecar.cs +++ b/Damselfly.Core/Models/SideCars/On1Sidecar.cs @@ -12,7 +12,7 @@ public class MetaData public List Keywords { get; set; } } - public class Guid + public class Photo { public bool guid_locked { get; set; } public MetaData metadata { get; set; } @@ -24,6 +24,8 @@ public class Guid /// public class On1Sidecar { + public Dictionary photos { get; set; } = new Dictionary(); + /// /// Load the on1 sidecar metadata for the image - if it exists. /// @@ -38,19 +40,15 @@ public static MetaData LoadMetadata(FileInfo sidecarPath) string json = File.ReadAllText( sidecarPath.FullName ); // Deserialize. - var list = JsonSerializer.Deserialize>(json); - - if (list.TryGetValue("photos", out var photos)) - { - // Unfortunately, On1 uses the slightly crazy method of a GUID as the field identifier, - // which means we have to deserialise as a dictionary, and then just pick the first kvp. - var guid = JsonSerializer.Deserialize>(photos.ToString()).First(); - - // Now we can deserialise the actual object, and get the metadata. - var data = JsonSerializer.Deserialize(guid.Value.ToString()); + var sideCar = JsonSerializer.Deserialize(json); + if( sideCar != null ) + { Logging.LogVerbose($"Successfully loaded on1 sidecar for {sidecarPath.FullName}"); - result = data.metadata; + var photo = sideCar.photos.Values.FirstOrDefault(); + + if( photo != null ) + result = photo.metadata; } } catch( Exception ex ) diff --git a/Damselfly.Core/ScopedServices/BasketService.cs b/Damselfly.Core/ScopedServices/BasketService.cs index 396ba836..a09b0770 100644 --- a/Damselfly.Core/ScopedServices/BasketService.cs +++ b/Damselfly.Core/ScopedServices/BasketService.cs @@ -22,6 +22,7 @@ public class BasketService private readonly UserStatusService _statusService; private const string s_MyBasket = "My Basket"; + private const string s_DefaultBasket = "default"; public event Action OnBasketChanged; public Basket CurrentBasket { get; set; } @@ -123,7 +124,13 @@ public async Task> GetUserBaskets( AppIdentityUser user ) if( ! myBaskets.Any( x => x.UserId == userId )) { - var newBasketName = (user != null) ? s_MyBasket : "default"; + var newBasketName = (user != null) ? s_MyBasket : s_DefaultBasket; + + if( user == null && myBaskets.Any( x => x.Name.Equals(s_DefaultBasket))) + { + // Don't create another default basket if one already exists. + return myBaskets; + } // Create a default (user) basket if none exists. var userBasket = new Basket { Name = newBasketName, UserId = user?.Id }; diff --git a/Damselfly.Core/ScopedServices/SearchService.cs b/Damselfly.Core/ScopedServices/SearchService.cs index 414fdefe..50fbf932 100644 --- a/Damselfly.Core/ScopedServices/SearchService.cs +++ b/Damselfly.Core/ScopedServices/SearchService.cs @@ -46,20 +46,20 @@ public void NotifyStateChanged() public event Action OnChange; public string SearchText { get { return query.SearchText; } set { if (query.SearchText != value.Trim() ) { query.SearchText = value.Trim(); QueryChanged(); } } } - public DateTime MaxDate { get { return query.MaxDate; } set { if (query.MaxDate != value) { query.MaxDate = value; QueryChanged(); } } } - public DateTime MinDate { get { return query.MinDate; } set { if (query.MinDate != value) { query.MinDate = value; QueryChanged(); } } } + public DateTime? MaxDate { get { return query.MaxDate; } set { if (query.MaxDate != value) { query.MaxDate = value; QueryChanged(); } } } + public DateTime? MinDate { get { return query.MinDate; } set { if (query.MinDate != value) { query.MinDate = value; QueryChanged(); } } } public ulong MaxSizeKB { get { return query.MaxSizeKB; } set { if (query.MaxSizeKB != value) { query.MaxSizeKB = value; QueryChanged(); } } } public ulong MinSizeKB { get { return query.MinSizeKB; } set { if (query.MinSizeKB != value) { query.MinSizeKB = value; QueryChanged(); } } } public Folder Folder { get { return query.Folder; } set { if (query.Folder != value) { query.Folder = value; QueryChanged(); } } } public bool TagsOnly { get { return query.TagsOnly; } set { if (query.TagsOnly != value) { query.TagsOnly = value; QueryChanged(); } } } public bool IncludeAITags { get { return query.IncludeAITags; } set { if (query.IncludeAITags != value) { query.IncludeAITags = value; QueryChanged(); } } } public int CameraId { get { return query.CameraId; } set { if (query.CameraId != value) { query.CameraId = value; QueryChanged(); } } } - public int TagId { get { return query.TagId; } set { if (query.TagId != value) { query.TagId = value; QueryChanged(); } } } + public Tag Tag { get { return query.Tag; } set { if (query.Tag != value) { query.Tag = value; QueryChanged(); } } } public int LensId { get { return query.LensId; } set { if (query.LensId != value) { query.LensId = value; QueryChanged(); } } } public GroupingType Grouping { get { return query.Grouping; } set { if (query.Grouping != value) { query.Grouping = value; QueryChanged(); } } } public SortOrderType SortOrder { get { return query.SortOrder; } set { if (query.SortOrder != value) { query.SortOrder = value; QueryChanged(); } } } - public void SetDateRange( DateTime min, DateTime max ) + public void SetDateRange( DateTime? min, DateTime? max ) { if (query.MinDate != min || query.MaxDate != max) { @@ -134,10 +134,10 @@ private async Task LoadMoreData(int first, int count) images = images.Include(x => x.Folder); - if ( query.TagId != -1 ) + if ( query.Tag != null ) { - var tagImages = images.Where(x => x.ImageTags.Any(y => y.TagId == query.TagId)); - var objImages = images.Where(x => x.ImageObjects.Any(y => y.TagId == query.TagId)); + var tagImages = images.Where(x => x.ImageTags.Any(y => y.TagId == query.Tag.TagId)); + var objImages = images.Where(x => x.ImageObjects.Any(y => y.TagId == query.Tag.TagId)); images = tagImages.Union(objImages); } @@ -179,12 +179,14 @@ private async Task LoadMoreData(int first, int count) throw new ArgumentException("Unexpected grouping type."); } - if (query.MinDate > DateTime.MinValue || query.MaxDate < DateTime.MaxValue) + if (query.MinDate.HasValue || query.MaxDate.HasValue) { + var minDate = query.MinDate.HasValue ? query.MinDate : DateTime.MinValue; + var maxDate = query.MaxDate.HasValue ? query.MaxDate : DateTime.MaxValue; // Always filter by date - because if there's no filter // set then they'll be set to min/max date. - images = images.Where(x => x.SortDate >= query.MinDate && - x.SortDate <= query.MaxDate); + images = images.Where(x => x.SortDate >= minDate && + x.SortDate <= maxDate); } if( query.MinSizeKB > ulong.MinValue ) @@ -255,5 +257,41 @@ public async Task GetQueryImagesAsync(int first, int count) return SearchResults.Skip(first).Take(count).ToArray(); } + + public string SearchBreadcrumbs + { + get + { + var hints = new List(); + + if (!string.IsNullOrEmpty(SearchText)) + hints.Add($"Text: {SearchText}"); + + if (Folder != null) + hints.Add($"Folder: {Folder.Name}"); + + if (Tag != null) + hints.Add($"Tag: {Tag.Keyword}"); + + string dateRange = string.Empty; + if (MinDate.HasValue) + dateRange = $"{MinDate:dd-MMM-yyyy}"; + + if (MaxDate.HasValue && + (! MinDate.HasValue || MaxDate.Value.Date != MinDate.Value.Date)) + { + if (!string.IsNullOrEmpty(dateRange)) + dateRange += " - "; + dateRange += $"{MaxDate:dd-MMM-yyyy}"; + } + + if (!string.IsNullOrEmpty(dateRange)) + hints.Add($"Date: {dateRange}"); + + // TODO: Need camera here. + + return string.Join(", ", hints); + } + } } } diff --git a/Damselfly.Core/Services/ImageProcessService.cs b/Damselfly.Core/Services/ImageProcessService.cs index 6253e5ae..389f3edf 100644 --- a/Damselfly.Core/Services/ImageProcessService.cs +++ b/Damselfly.Core/Services/ImageProcessService.cs @@ -57,6 +57,7 @@ public async Task CreateThumbs(FileInfo source, IDictionary< /// /// /// + /// TODO: Async public void TransformDownloadImage(string input, Stream output, ExportConfig config) { var ext = Path.GetExtension(input); @@ -83,5 +84,15 @@ public bool IsImageFileType(FileInfo filename) // If we have a valid processor, we're good. return processor != null; } + + public async Task GetCroppedFile(FileInfo source, int x, int y, int width, int height, FileInfo destFile) + { + var ext = Path.GetExtension(source.Name); + + var processor = _factory.GetProcessor(ext); + + if (processor != null) + await processor.GetCroppedFile(source, x, y, width, height, destFile); + } } } diff --git a/Damselfly.Core/Services/ImageRecognitionService.cs b/Damselfly.Core/Services/ImageRecognitionService.cs index fcc168cd..7d814dee 100644 --- a/Damselfly.Core/Services/ImageRecognitionService.cs +++ b/Damselfly.Core/Services/ImageRecognitionService.cs @@ -8,6 +8,7 @@ using Damselfly.Core.ImageProcessing; using Damselfly.Core.Models; using Damselfly.Core.Utils; +using Damselfly.Core.Utils.Constants; using Damselfly.Core.Utils.ML; using Damselfly.ML.Face.Accord; using Damselfly.ML.Face.Azure; @@ -26,13 +27,14 @@ public class ImageRecognitionService private readonly StatusService _statusService; private readonly IndexingService _indexingService; private readonly ThumbnailService _thumbService; + private readonly ConfigService _configService; private IDictionary _peopleCache; public static bool EnableImageRecognition { get; set; } = true; public ImageRecognitionService(StatusService statusService, ObjectDetector objectDetector, IndexingService indexingService, AzureFaceService azureFace, AccordFaceService accordFace, EmguFaceService emguService, - ThumbnailService thumbs) + ThumbnailService thumbs, ConfigService configService) { _thumbService = thumbs; _accordFaceService = accordFace; @@ -41,6 +43,7 @@ public ImageRecognitionService(StatusService statusService, ObjectDetector objec _objectDetector = objectDetector; _indexingService = indexingService; _emguFaceService = emguService; + _configService = configService; } public ImageRecognitionService() @@ -52,6 +55,26 @@ public List GetCachedPeople() return _peopleCache.Values.OrderBy(x => x.Name).ToList(); } + public (TimeSpan? start,TimeSpan? end) GetProcessingTimeRange() + { + TimeSpan? aiStartTime = null, aiEndTime = null; + + string aiTimeRange = _configService.Get(ConfigSettings.AIProcessingTimeRange); + + if (!string.IsNullOrEmpty(aiTimeRange)) + { + var settings = aiTimeRange.Split("-"); + + if (settings.Length == 2) + { + aiStartTime = TimeSpan.Parse(settings[0]); + aiEndTime = TimeSpan.Parse(settings[1]); + } + } + + return( aiStartTime, aiEndTime ); + } + /// /// Initialise the in-memory cache of people. /// @@ -524,7 +547,24 @@ private async Task ProcessImage(ImageMetaData metadata) { metadata.AILastUpdated = DateTime.UtcNow; await DetectObjects(metadata.Image); - await Task.Delay(500); + } + + private bool WithinProcessingTimeRange() + { + var timeRange = GetProcessingTimeRange(); + + if (timeRange.start != null && timeRange.end != null) + { + var now = DateTime.UtcNow.TimeOfDay; + + if (now < timeRange.start && now > timeRange.end) + { + // AI scans are disabled at this time. + return false; + } + } + + return true; } /// @@ -541,6 +581,12 @@ private async Task ProcessAIScan() while (!complete) { + if( ! WithinProcessingTimeRange() ) + { + Logging.LogVerbose("AI Processing disabled at this time."); + return; + } + Logging.LogVerbose("Querying DB for pending AI scans..."); var watch = new Stopwatch("GetAIQueue"); diff --git a/Damselfly.Core/Services/IndexingService.cs b/Damselfly.Core/Services/IndexingService.cs index 303b40c7..ce23ff93 100644 --- a/Damselfly.Core/Services/IndexingService.cs +++ b/Damselfly.Core/Services/IndexingService.cs @@ -386,6 +386,29 @@ private void LoadTagCache(bool force = false) Logging.LogError($"Unexpected exception loading tag cache: {ex.Message}"); } } + + /// + /// Return a tag by its ID. + /// TODO: Is this faster, or slower than a DB query, given it means iterating + /// a collection of, say, 10,000 tags. Probably faster, but perhaps we should + /// maintain a dict of ID => tag? + /// + /// + /// + public Models.Tag GetTag( int tagId ) + { + var tag = _tagCache.Values.Where(x => x.TagId == tagId).FirstOrDefault(); + + return tag; + } + + public Models.Tag GetTag(string keyword) + { + // TODO: Should we make the tag-cache key case-insensitive? What would happen?! + var tag = _tagCache.Values.Where(x => x.Keyword.Equals( keyword, StringComparison.OrdinalIgnoreCase) ).FirstOrDefault(); + + return tag; + } #endregion public async Task> CreateTagsFromStrings(IEnumerable tags) diff --git a/Damselfly.Migrations.Sqlite/SqlLiteModel.cs b/Damselfly.Migrations.Sqlite/SqlLiteModel.cs index 4459b580..486e1a0b 100644 --- a/Damselfly.Migrations.Sqlite/SqlLiteModel.cs +++ b/Damselfly.Migrations.Sqlite/SqlLiteModel.cs @@ -68,6 +68,9 @@ private void ExecutePragma(BaseDBModel db, string pragmaCommand) /// private void IncreasePerformance(BaseDBModel db) { + // Increase the timeout from the default (which I think is 30s) + // To help concurrency. + db.Database.SetCommandTimeout(60); // Enable journal mode - this will also improve // concurrent acces ExecutePragma(db, "PRAGMA journal_mode=WAL;"); diff --git a/Damselfly.Web/Controllers/ImageController.cs b/Damselfly.Web/Controllers/ImageController.cs index 4964ece0..4fe94026 100644 --- a/Damselfly.Web/Controllers/ImageController.cs +++ b/Damselfly.Web/Controllers/ImageController.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Damselfly.Core.ImageProcessing; @@ -10,6 +11,7 @@ using Damselfly.Core.Models; using Damselfly.Core.Utils; using Damselfly.Core.ScopedServices; +using Microsoft.EntityFrameworkCore; namespace Damselfly.Web.Controllers { @@ -177,5 +179,48 @@ public async Task Thumb(string thumbSize, string imageId, Cancell return result; } + + [HttpGet("/face/{faceId}")] + public async Task Face(string faceId, CancellationToken cancel, + [FromServices] ImageProcessService imageProcessor, [FromServices] ThumbnailService thumbService) + { + using var db = new ImageContext(); + + IActionResult result = Redirect("/no-image.png"); + + // TODO: Use cache + + var query = db.ImageObjects + .Include(x => x.Image) + .ThenInclude(o => o.MetaData) + .Include(x => x.Person) + .OrderByDescending(x => x.Image.MetaData.Width) + .ThenByDescending(x => x.Image.MetaData.Height); + + ImageObject face = null; + + if ( int.TryParse( faceId, out var personId )) + { + face = await query.Where( x => x.Person.PersonId == personId ) + .FirstOrDefaultAsync(); + } + else + { + face = await query.Where(x => x.Person.AzurePersonId == faceId) + .FirstOrDefaultAsync(); + } + + var file = new FileInfo(face.Image.FullPath); + var imagePath = new FileInfo( thumbService.GetThumbPath(file, ThumbSize.Large) ); + var destFile = new FileInfo( "path" ); + + if (!destFile.Exists) + { + await imageProcessor.GetCroppedFile(imagePath, face.RectX, face.RectY, face.RectWidth, face.RectHeight, destFile ); + } + + return result; + } + } } \ No newline at end of file diff --git a/Damselfly.Web/Damselfly.Web.csproj b/Damselfly.Web/Damselfly.Web.csproj index 7f029d53..05a1c963 100644 --- a/Damselfly.Web/Damselfly.Web.csproj +++ b/Damselfly.Web/Damselfly.Web.csproj @@ -45,12 +45,15 @@ + + + @@ -69,7 +72,7 @@ - + @@ -82,5 +85,6 @@ + \ No newline at end of file diff --git a/Damselfly.Web/Extensions/QueryStringExtensions.cs b/Damselfly.Web/Extensions/QueryStringExtensions.cs new file mode 100644 index 00000000..352d5e75 --- /dev/null +++ b/Damselfly.Web/Extensions/QueryStringExtensions.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Primitives; + +namespace Damselfly.Web.Extensions +{ + /// + /// from https://www.meziantou.net/bind-parameters-from-the-query-string-in-blazor.htm + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public sealed class QueryStringParameterAttribute : Attribute + { + public QueryStringParameterAttribute() + { + } + + public QueryStringParameterAttribute(string name) + { + Name = name; + } + + /// Name of the query string parameter. It uses the property name by default. + public string Name { get; } + } + + // Requires Microsoft.AspNetCore.WebUtilities to edit the query string + // + public static class QueryStringParameterExtensions + { + // Apply the values from the query string to the current component + public static void SetParametersFromQueryString(this T component, NavigationManager navigationManager) + where T : ComponentBase + { + if (!Uri.TryCreate(navigationManager.Uri, UriKind.RelativeOrAbsolute, out var uri)) + throw new InvalidOperationException("The current url is not a valid URI. Url: " + navigationManager.Uri); + + // Parse the query string + Dictionary queryString = QueryHelpers.ParseQuery(uri.Query); + + // Enumerate all properties of the component + foreach (var property in GetProperties()) + { + // Get the name of the parameter to read from the query string + var parameterName = GetQueryStringParameterName(property); + if (parameterName == null) + continue; // The property is not decorated by [QueryStringParameterAttribute] + + if (queryString.TryGetValue(parameterName, out var value)) + { + // Convert the value from string to the actual property type + var convertedValue = ConvertValue(value, property.PropertyType); + property.SetValue(component, convertedValue); + } + } + } + + // Apply the values from the component to the query string + public static void UpdateQueryString(this T component, NavigationManager navigationManager) + where T : ComponentBase + { + if (!Uri.TryCreate(navigationManager.Uri, UriKind.RelativeOrAbsolute, out var uri)) + throw new InvalidOperationException("The current url is not a valid URI. Url: " + navigationManager.Uri); + + // Fill the dictionary with the parameters of the component + Dictionary parameters = QueryHelpers.ParseQuery(uri.Query); + foreach (var property in GetProperties()) + { + var parameterName = GetQueryStringParameterName(property); + if (parameterName == null) + continue; + + var value = property.GetValue(component); + if (value is null) + { + parameters.Remove(parameterName); + } + else + { + var convertedValue = ConvertToString(value); + parameters[parameterName] = convertedValue; + } + } + + // Compute the new URL + var newUri = uri.GetComponents(UriComponents.Scheme | UriComponents.Host | UriComponents.Port | UriComponents.Path, UriFormat.UriEscaped); + foreach (var parameter in parameters) + { + foreach (var value in parameter.Value) + { + newUri = QueryHelpers.AddQueryString(newUri, parameter.Key, value); + } + } + + navigationManager.NavigateTo(newUri); + } + + private static object ConvertValue(StringValues value, Type type) + { + return Convert.ChangeType(value[0], type, CultureInfo.InvariantCulture); + } + + private static string ConvertToString(object value) + { + return Convert.ToString(value, CultureInfo.InvariantCulture); + } + + private static PropertyInfo[] GetProperties() + { + return typeof(T).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + } + + private static string GetQueryStringParameterName(PropertyInfo property) + { + var attribute = property.GetCustomAttribute(); + if (attribute == null) + return null; + + return attribute.Name ?? property.Name; + } + } +} diff --git a/Damselfly.Web/Pages/HomePage.razor b/Damselfly.Web/Pages/HomePage.razor index e3bcfb9b..c611de71 100644 --- a/Damselfly.Web/Pages/HomePage.razor +++ b/Damselfly.Web/Pages/HomePage.razor @@ -1,41 +1,96 @@ @page "/" -@page "/folder/{FolderId}" @using Damselfly.Web.Data @using Damselfly.Core.Services; @using Damselfly.Web.Shared.Images; +@implements IDisposable @inject ImageService imageService @inject ThumbnailService thumbService @inject ViewDataService ViewDataService +@inject IndexingService indexingService @inject NavigationService navContext @inject SearchService searchService @inject UserStatusService statusService +@inject NavigationManager navigationManager
- - - - - - + +
-@code { [Parameter] - public string FolderId { get; set; } +@code +{ + [QueryStringParameter] + public string S { get; set; } + + [QueryStringParameter] + public int FolderId { get; set; } + + [QueryStringParameter] + public int TagId { get; set; } + + [QueryStringParameter] + public string Tag { get; set; } + + [QueryStringParameter] + public int PersonId { get; set; } + + [QueryStringParameter] + public DateTime Date { get; set; } protected override async Task OnParametersSetAsync() - { - if (int.TryParse(FolderId, out var fID)) - { - var folder = await ImageService.GetFolderAsync(fID); - - if (folder != null) - { - statusService.StatusText = $"Selected folder {folder.Name}"; - searchService.Folder = folder; - } + { + await ApplyQueryParams(); + } + + private async Task ApplyQueryParams() + { + if( ! string.IsNullOrEmpty( S ) ) + { + searchService.SearchText = S; + } + + if (FolderId != 0) + { + var folder = await ImageService.GetFolderAsync(FolderId); + + if (folder != null) + { + statusService.StatusText = $"Selected folder {folder.Name}"; + searchService.Folder = folder; + } + } + + if (TagId != 0) + { + var tag = indexingService.GetTag(TagId); + + if (tag != null) + searchService.Tag = tag; + } + else if( ! string.IsNullOrEmpty( Tag )) + { + var tag = indexingService.GetTag(Tag); + + if (tag != null) + searchService.Tag = tag; + } + + if (Date != DateTime.MinValue) + { + // searchService.MinDate } + + // Don't need this yet + //this.UpdateQueryString(navigationManager); + } + + public override Task SetParametersAsync(ParameterView parameters) + { + this.SetParametersFromQueryString(navigationManager); + + return base.SetParametersAsync(parameters); } protected override void OnInitialized() @@ -44,6 +99,22 @@ navContext.CurrentImage = null; ViewDataService.SetSideBarState(new ViewDataService.SideBarState { ShowFolderList = true, ShowBasket = true, ShowTags = true }); + + navigationManager.LocationChanged += HandleLocationChanged; + + } + + void HandleLocationChanged(object sender, LocationChangedEventArgs e) + { + this.SetParametersFromQueryString(navigationManager); + StateHasChanged(); + + _ = ApplyQueryParams(); + } + + public void Dispose() + { + navigationManager.LocationChanged -= HandleLocationChanged; } // TODO: Don't think we need this @@ -53,4 +124,5 @@ { Logging.Log($"Checking for update: {clientVersion}"); - } } \ No newline at end of file + } +} \ No newline at end of file diff --git a/Damselfly.Web/Pages/LogsPage.razor b/Damselfly.Web/Pages/LogsPage.razor index b5a5066e..7cb53b73 100644 --- a/Damselfly.Web/Pages/LogsPage.razor +++ b/Damselfly.Web/Pages/LogsPage.razor @@ -1,36 +1,44 @@ @page "/logs" @inject ViewDataService ViewDataService +@inject UserStatusService statusService
-
-

@LogFileName

-
- - - - - - - - - - - - @foreach (var line in logLines) - { - - - - - - - } - - -
TimestampLog LevelEntryThread
@line.date@line.level@line.entry@line.thread
+ @if (logLines == null || !logLines.Any()) + { +

Loading log entries....

+ } + else + { +
+

@LogFileName

+
+ + + + + + + + + + + + @foreach (var line in logLines) + { + + + + + + + } + + +
TimestampLog LevelEntryThread
@line.date@line.level@line.entry@line.thread
+
-
+ }
@code { @@ -46,20 +54,23 @@ List logLines = new List(); private string LogFileName { get; set; } - - protected override async Task OnAfterRenderAsync(bool firstRender) + protected override void OnAfterRender(bool firstRender) { if (firstRender) { ViewDataService.SetSideBarState(new ViewDataService.SideBarState { ShowLogs = true }); - var lines = await GetLogLines(); - logLines.AddRange(lines); - StateHasChanged(); + _ = GetLogLines(); } } - private Task GetLogLines() + private async Task DownloadLogFile() + { + // TODO: Download log file here + await Task.Delay(500); + } + + private async Task GetLogLines() { LogEntry[] result = new LogEntry[0]; @@ -72,13 +83,31 @@ { LogFileName = file.Name; - result = File.ReadAllLines(file.FullName) - .Reverse() - .Select(x => CreateLogEntry(x)) - .ToArray(); - } + int page = 0, pageSize = 100; + var lines = File.ReadLines(file.FullName); - return Task.FromResult(result); + while (true) + { + var pageLines = lines.Skip(page * pageSize).Take(pageSize).Reverse(); + + if (pageLines.Any()) + { + var logPage = pageLines + .Select(x => CreateLogEntry(x)) + .ToArray(); + + logLines.AddRange(logPage); + + page++; + + await InvokeAsync(StateHasChanged); + await Task.Delay(100); + statusService.StatusText = $"Loaded {logLines.Count()} log lines..."; + } + else + break; + } + } } // TODO: Use a regex here diff --git a/Damselfly.Web/Pages/TagPage.razor b/Damselfly.Web/Pages/TagPage.razor index cd373a94..a0ce794b 100644 --- a/Damselfly.Web/Pages/TagPage.razor +++ b/Damselfly.Web/Pages/TagPage.razor @@ -1,9 +1,5 @@ @page "/tags" -@page "/tag/{TagName}" -@using Damselfly.Web.Data -@using System.IO; -@using Damselfly.Web.Shared.Images; @using Damselfly.Core.Services; @using Damselfly.Core.Models; @@ -12,65 +8,47 @@ @inject IndexingService indexingService
- @if (images == null) + @if (Tags == null) { - if (Tags == null) - { -

Loading tags...

- } - else - { - - - Keyword Tags - - - - - ID - Keyword - Favourite - Date Created - - - @context.TagId - - @context.Keyword - - @context.Favourite - @context.TimeStamp - - - - - - } +

Loading tags...

} else { -
-

Tag: @TagName

-
-
- @foreach (var image in images) - { -
- -
- } -
+ + + Keyword Tags + + + + + ID + Keyword + Favourite + Date Created + + + @context.TagId + + @context.Keyword + + @context.Favourite + @context.TimeStamp + + + + + }
@code { [Parameter] public string TagName { get; set; } - Image[] images; Tag selectedTag; string searchText; - private string TagLink( Tag tag ) => $"/tag/{tag.Keyword}"; + private string TagLink( Tag tag ) => $"/?tagid={tag.TagId}"; private IEnumerable Tags { @@ -96,24 +74,4 @@ ViewDataService.SetSideBarState(new ViewDataService.SideBarState { ShowBasket = true, ShowTags = true }); } } - - protected override async Task OnParametersSetAsync() - { - if (!string.IsNullOrEmpty(TagName)) - { - images = await ImageService.GetTagImagesAsync(TagName); - StateHasChanged(); - } - - StateHasChanged(); - } - - protected override void OnInitialized() - { - if (string.IsNullOrEmpty(TagName)) - { - ViewDataService.SetSideBarState(new ViewDataService.SideBarState { ShowBasket = true }); - images = null; - } - } } diff --git a/Damselfly.Web/Program.cs b/Damselfly.Web/Program.cs index d867b6c8..029e710a 100644 --- a/Damselfly.Web/Program.cs +++ b/Damselfly.Web/Program.cs @@ -67,85 +67,104 @@ public class DamselflyOptions public static void Main(string[] args) { - Parser.Default.ParseArguments(args).WithParsed( o => - { - Logging.Verbose = o.Verbose; - Logging.Trace = o.Trace; - - if (Directory.Exists(o.SourceDirectory)) - { - if (!Directory.Exists(o.ConfigPath)) - Directory.CreateDirectory(o.ConfigPath); - - Logging.LogFolder = Path.Combine(o.ConfigPath, "logs"); - - Log.Logger = Logging.InitLogs(); - - if (o.ReadOnly) - { - o.NoEnableIndexing = true; - o.NoGenerateThumbnails = true; - } - - // TODO: Do away with static members here. We should pass this - // through to the config service and pick them up via DI - IndexingService.EnableIndexing = ! o.NoEnableIndexing; - IndexingService.RootFolder = o.SourceDirectory; - ThumbnailService.PicturesRoot = o.SourceDirectory; - ThumbnailService.Synology = o.Synology; - ThumbnailService.SetThumbnailRoot(o.ThumbPath); - ThumbnailService.EnableThumbnailGeneration = !o.NoGenerateThumbnails; - - Logging.Log("Startup State:"); - Logging.Log($" Damselfly Ver: {Assembly.GetExecutingAssembly().GetName().Version}"); - Logging.Log($" CLR Ver: {Environment.Version}"); - Logging.Log($" OS: {Environment.OSVersion}"); - Logging.Log($" CPU Arch: {RuntimeInformation.ProcessArchitecture}"); - Logging.Log($" Processor Count: {Environment.ProcessorCount}"); - Logging.Log($" Read-only mode: {o.ReadOnly}"); - Logging.Log($" Synology = {o.Synology}"); - Logging.Log($" Indexing = {!o.NoEnableIndexing}"); - Logging.Log($" ThumbGen = {!o.NoGenerateThumbnails}"); - Logging.Log($" Images Root set as {o.SourceDirectory}"); - - IDataBase dbType = null; - - if (! o.UsePostgresDB ) - { - string dbFolder = Path.Combine(o.ConfigPath, "db"); - - if (!Directory.Exists(dbFolder)) - { - Logging.Log(" Created DB folder: {0}", dbFolder); - Directory.CreateDirectory(dbFolder); - } - - string dbPath = Path.Combine(dbFolder, "damselfly.db"); - dbType = new SqlLiteModel(dbPath); - Logging.Log(" Sqlite Database location: {0}", dbPath); - } - else // Postgres - { - // READ Postgres config json - dbType = PostgresModel.ReadSettings("settings.json"); - Logging.Log(" Postgres Database location: {0}"); - } - - // TODO: https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/providers?tabs=dotnet-core-cli - BaseDBModel.InitDB(dbType, o.ReadOnly); - - // Make ourselves low-priority. - System.Diagnostics.Process.GetCurrentProcess().PriorityClass = System.Diagnostics.ProcessPriorityClass.Idle; - - StartWebServer(o.Port, args); - - Logging.Log("Shutting down."); - } - else - Logging.Log("Folder {0} did not exist. Exiting.", o.SourceDirectory); - }); + try + { + Parser.Default.ParseArguments(args).WithParsed(o => + { + Startup(o, args); + }); + } + catch( Exception ex ) + { + Console.WriteLine($"Startup exception: {ex}"); + } } + /// + /// Process the startup args and initialise the logging. + /// + /// + /// + private static void Startup(DamselflyOptions o, string[] args) + { + Logging.Verbose = o.Verbose; + Logging.Trace = o.Trace; + + if (Directory.Exists(o.SourceDirectory)) + { + if (!Directory.Exists(o.ConfigPath)) + Directory.CreateDirectory(o.ConfigPath); + + Logging.LogFolder = Path.Combine(o.ConfigPath, "logs"); + + Log.Logger = Logging.InitLogs(); + + if (o.ReadOnly) + { + o.NoEnableIndexing = true; + o.NoGenerateThumbnails = true; + } + + // TODO: Do away with static members here. We should pass this + // through to the config service and pick them up via DI + IndexingService.EnableIndexing = !o.NoEnableIndexing; + IndexingService.RootFolder = o.SourceDirectory; + ThumbnailService.PicturesRoot = o.SourceDirectory; + ThumbnailService.Synology = o.Synology; + ThumbnailService.SetThumbnailRoot(o.ThumbPath); + ThumbnailService.EnableThumbnailGeneration = !o.NoGenerateThumbnails; + + Logging.Log("Startup State:"); + Logging.Log($" Damselfly Ver: {Assembly.GetExecutingAssembly().GetName().Version}"); + Logging.Log($" CLR Ver: {Environment.Version}"); + Logging.Log($" OS: {Environment.OSVersion}"); + Logging.Log($" CPU Arch: {RuntimeInformation.ProcessArchitecture}"); + Logging.Log($" Processor Count: {Environment.ProcessorCount}"); + Logging.Log($" Read-only mode: {o.ReadOnly}"); + Logging.Log($" Synology = {o.Synology}"); + Logging.Log($" Indexing = {!o.NoEnableIndexing}"); + Logging.Log($" ThumbGen = {!o.NoGenerateThumbnails}"); + Logging.Log($" Images Root set as {o.SourceDirectory}"); + + IDataBase dbType = null; + + if (!o.UsePostgresDB) + { + string dbFolder = Path.Combine(o.ConfigPath, "db"); + + if (!Directory.Exists(dbFolder)) + { + Logging.Log(" Created DB folder: {0}", dbFolder); + Directory.CreateDirectory(dbFolder); + } + + string dbPath = Path.Combine(dbFolder, "damselfly.db"); + dbType = new SqlLiteModel(dbPath); + Logging.Log(" Sqlite Database location: {0}", dbPath); + } + else // Postgres + { + // READ Postgres config json + dbType = PostgresModel.ReadSettings("settings.json"); + Logging.Log(" Postgres Database location: {0}"); + } + + // TODO: https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/providers?tabs=dotnet-core-cli + BaseDBModel.InitDB(dbType, o.ReadOnly); + + // Make ourselves low-priority. + System.Diagnostics.Process.GetCurrentProcess().PriorityClass = System.Diagnostics.ProcessPriorityClass.Idle; + + StartWebServer(o.Port, args); + + Logging.Log("Shutting down."); + } + else + { + Console.WriteLine("Folder {0} did not exist. Exiting.", o.SourceDirectory); + } + + } /// /// Main entry point. Creates a bunch of services, and then kicks off /// the webserver, which is a blocking call (since it's the dispatcher diff --git a/Damselfly.Web/Shared/Config.razor b/Damselfly.Web/Shared/Config.razor index ae021503..b107b6be 100644 --- a/Damselfly.Web/Shared/Config.razor +++ b/Damselfly.Web/Shared/Config.razor @@ -11,7 +11,7 @@ @inject ThemeService themeService @inject UserStatusService statusService @inject WordpressService wpService -@inject TaskService taskScheduler +@inject ImageRecognitionService imageService @inject AzureFaceService azureService
@@ -88,11 +88,18 @@ - + +

AI Processing

+ +
+ + +
+

Azure Cognitive Services Face Recognition

@@ -219,6 +226,9 @@ private bool useSmtp = true; private bool forceLogin = false; private bool anableAuthAndRoles = false; + TimeSpan? aiStartTime = new TimeSpan(23, 00, 00); + TimeSpan? aiEndTime = new TimeSpan(04, 30, 00); + private bool enableAITimeLimit = false; private void HandleValidSubmit() { @@ -249,6 +259,14 @@ themeService.CurrentTheme = selectedTheme; } + string aiSettings = string.Empty; + + if( enableAITimeLimit ) + { + string aiTimeRange = $"{aiStartTime.LocalTimeSpanToUTC()}-{aiEndTime.LocalTimeSpanToUTC()}"; + configService.Set(ConfigSettings.AIProcessingTimeRange, aiTimeRange); + } + statusService.StatusText = "Settings saved."; UpdateServicesWithNewSettings(); @@ -297,11 +315,15 @@ importSidecarKeywords = configService.GetBool(ConfigSettings.ImportSidecarKeywords); selectedTheme = themeService.CurrentTheme; - base.OnInitialized(); - } + var range = imageService.GetProcessingTimeRange(); - private void RunTask(ScheduledTask task) - { - taskScheduler.EnqueueTaskAsync(task); + if( range.start != null && range.end != null ) + { + aiStartTime = range.start.UTCTimeSpanToLocal(); + aiEndTime = range.end.UTCTimeSpanToLocal(); + enableAITimeLimit = true; + } + + base.OnInitialized(); } } diff --git a/Damselfly.Web/Shared/DatePickerEx.razor b/Damselfly.Web/Shared/DatePickerEx.razor index b09f0d65..bcccf293 100644 --- a/Damselfly.Web/Shared/DatePickerEx.razor +++ b/Damselfly.Web/Shared/DatePickerEx.razor @@ -58,5 +58,6 @@ // Close the picker picker.Close(); // Fire OnRangeSelectEvent - picker.OnRangeSelect.InvokeAsync(new BlazorDateRangePicker.DateRange { Start = DateTime.MinValue, End = DateTime.MaxValue } ); + picker.Reset(); + picker.OnRangeSelect.InvokeAsync(null); }} diff --git a/Damselfly.Web/Shared/Images/GridImage.razor b/Damselfly.Web/Shared/Images/GridImage.razor index fe9cef60..f1357956 100644 --- a/Damselfly.Web/Shared/Images/GridImage.razor +++ b/Damselfly.Web/Shared/Images/GridImage.razor @@ -51,7 +51,7 @@ else set { _ = SetBasketState(value); } } - private string ImgToolTip => $"{CurrentImage.FileName}\nTaken: {CurrentImage.SortDate.Display()}"; + private string ImgToolTip => $"{CurrentImage.FileName}\nTaken: {CurrentImage.SortDate.Display()}\nFolder: {CurrentImage.Folder.Path}"; private LocalFileExporter FileExporter; string ImageUrl => $"/thumb/{ThumbnailSize}/{CurrentImage.ImageId}"; string SelectStyle { get { return selectionService.IsSelected(CurrentImage) ? "grid-image-selected" : "grid-image-unselected"; } } diff --git a/Damselfly.Web/Shared/Images/ImageGrid.razor b/Damselfly.Web/Shared/Images/ImageGrid.razor index be282e06..8475487a 100644 --- a/Damselfly.Web/Shared/Images/ImageGrid.razor +++ b/Damselfly.Web/Shared/Images/ImageGrid.razor @@ -12,26 +12,19 @@ @inherits ImageGridBase @using Damselfly.Web.Shared -@using Damselfly.Web.Shared.Images @using Damselfly.Core.ImageProcessing @using Damselfly.Core.Utils.Constants

-
- -
@foreach (var choice in new[] { ThumbSize.Small, ThumbSize.Medium, ThumbSize.Large }) { } @@ -43,8 +36,8 @@ { } @@ -57,13 +50,16 @@ { }
+
+ +
@@ -165,14 +161,6 @@ // Todo - save an image to local storage } - async Task AddSelectedToBasket() - { - var selected = selectionService.Selection.ToList(); - await basketService.SetBasketState(selected, true); - - statusService.StatusText = $"{selected.Count()} images added to the basket"; - } - async Task AddGroupToBasket(ImageGrouping grouping) { await basketService.SetBasketState(grouping.Images, true); @@ -302,12 +290,7 @@ { get { - if (searchService.Folder != null) - { - return $"No images were found in folder '{searchService.Folder.Name}' that match the current filter."; - } - - return "No images were found that match the current filter."; + return $"No images were found that match the filter:\n{searchService.SearchBreadcrumbs}"; } } @@ -322,6 +305,8 @@ gridImages.Clear(); endOfImages = false; + _ = InvokeAsync( StateHasChanged ); + _ = LoadData(imagesPerPage); } diff --git a/Damselfly.Web/Shared/Images/ImageProperties.razor b/Damselfly.Web/Shared/Images/ImageProperties.razor index b838edc2..43aeeb4b 100644 --- a/Damselfly.Web/Shared/Images/ImageProperties.razor +++ b/Damselfly.Web/Shared/Images/ImageProperties.razor @@ -23,10 +23,13 @@ else {
-
Add to @basketService.CurrentBasket.Name:
+ @if( basketService.CurrentBasket != null ) + { +
Add to @basketService.CurrentBasket.Name:
+ }
Date Taken: @CurrentImage.SortDate.Display()
Filename: @CurrentImage.FileName
-
Folder: @CurrentImage.Folder.Name
+
Folder: @CurrentImage.Folder.Name
@if (CurrentImage.MetaData != null) { @if (!string.IsNullOrEmpty(CurrentImage.MetaData.Description)) @@ -103,7 +106,7 @@ @code { private Image theImage; public Image CurrentImage { get { return theImage; } set { theImage = value; } } - public string CurrentFolderLink => $"/folder/{CurrentImage.Folder.FolderId}"; + public string CurrentFolderLink => $"/?folderId={CurrentImage.Folder.FolderId}"; public string Aperture => $"f{CurrentImage.MetaData.FNum}"; public string Exposure => $"{CurrentImage.MetaData.Exposure} {(CurrentImage.MetaData.FlashFired ? "(flash)" : string.Empty)}"; diff --git a/Damselfly.Web/Shared/Images/SelectedImages.razor b/Damselfly.Web/Shared/Images/SelectedImages.razor index b124f80b..30c9a601 100644 --- a/Damselfly.Web/Shared/Images/SelectedImages.razor +++ b/Damselfly.Web/Shared/Images/SelectedImages.razor @@ -45,6 +45,7 @@ else
+ @@ -62,6 +63,7 @@ else public bool ShowTags { get; set; } = true; public bool BasketHasImages => gridImages.Any(); + public bool ImagesSelected => selectionService.Selection.Any(); private void UploadToWordPress() { @@ -84,6 +86,14 @@ else } + async Task AddSelectedToBasket() + { + var selected = selectionService.Selection.ToList(); + await basketService.SetBasketState(selected, true); + + statusService.StatusText = $"{selected.Count()} images added to the basket"; + } + private void ShowDownloads() { try diff --git a/Damselfly.Web/Shared/Images/TagList.razor b/Damselfly.Web/Shared/Images/TagList.razor index 6857f3cc..b6de8eab 100644 --- a/Damselfly.Web/Shared/Images/TagList.razor +++ b/Damselfly.Web/Shared/Images/TagList.razor @@ -328,7 +328,7 @@ contextMenuService.Close(); switch (args.Value) { - case 0: NavigationManager.NavigateTo("/tag/" + tag.Keyword); break; + case 0: NavigationManager.NavigateTo($"/?tagid={tag.TagId}"); break; case 1: await iptcService.ToggleFavourite(tag); break; case 2: QuickAddTag(tag); break; case 3: DeleteTag(tag); break; diff --git a/Damselfly.Web/Shared/Keywords.razor b/Damselfly.Web/Shared/Keywords.razor index f4a6900a..cba04305 100644 --- a/Damselfly.Web/Shared/Keywords.razor +++ b/Damselfly.Web/Shared/Keywords.razor @@ -4,4 +4,5 @@
@code { + } diff --git a/Damselfly.Web/Shared/MainLayout.razor b/Damselfly.Web/Shared/MainLayout.razor index a2159821..85c68c1b 100644 --- a/Damselfly.Web/Shared/MainLayout.razor +++ b/Damselfly.Web/Shared/MainLayout.razor @@ -4,25 +4,24 @@ - - - -
-
- - - - - @Body - -
- - - -
- -
- + + + + + +
+
+ + @Body +
+ +
+
+
+ +

An error occurred. Please check the Damselfly logs and reload the page.

+
+
@code{ } diff --git a/Damselfly.Web/Shared/SearchBar.razor b/Damselfly.Web/Shared/SearchBar.razor index 03752625..0dfe0836 100644 --- a/Damselfly.Web/Shared/SearchBar.razor +++ b/Damselfly.Web/Shared/SearchBar.razor @@ -38,7 +38,10 @@ public void OnRangeSelect(BlazorDateRangePicker.DateRange range) { - searchService.SetDateRange(range.Start.Date, range.End.Date); + if( range != null ) + searchService.SetDateRange(range.Start.Date, range.End.Date); + else + searchService.SetDateRange(null, null); } private void KeyChangedMinSize(string newText) diff --git a/Damselfly.Web/_Imports.razor b/Damselfly.Web/_Imports.razor index d9f59a3f..fcbfa924 100644 --- a/Damselfly.Web/_Imports.razor +++ b/Damselfly.Web/_Imports.razor @@ -20,6 +20,7 @@ @using Damselfly.Core.DbModels; @using Damselfly.Web @using Damselfly.Web.Data +@using Damselfly.Web.Extensions @using Damselfly.Web.Shared @using Damselfly.Web.Components @using Damselfly.Web.Shared.Images diff --git a/Damselfly.Web/wwwroot/css/site.css b/Damselfly.Web/wwwroot/css/site.css index 613c9dd2..5c1fe4a6 100644 --- a/Damselfly.Web/wwwroot/css/site.css +++ b/Damselfly.Web/wwwroot/css/site.css @@ -674,6 +674,14 @@ f align-items: center; } +.damselfly-searchhint { + flex: 1 1 200px; + margin: 3px; + display: flex; + flex-flow: row; + align-items: center; +} + .damselfly-browsetoollabel { margin: 2px; } @@ -1210,11 +1218,13 @@ padding: 0.4em 0.65em; flex: 1 1 auto; display: flex; flex-direction: column; + padding: 3px; } .damselfly-configsetting { display: flex; flex-direction: row; + padding: 3px; } .damselfly-configfield { diff --git a/Directory.Build.props b/Directory.Build.props index eac64816..98cf4899 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,7 @@ - true - net6.0 + true + net6.0 + CA1416 \ No newline at end of file diff --git a/README.md b/README.md index d158e374..8e2a40ee 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ editing etc. * Baskets can be user-specific, or shared with other users * Server-based deployment, with a web-based front-end UI, so the image library can be accessed via multiple devices without having to copy catalogues or other DBs to local device storage. +* Exclude images from Damselfly scanning by adding a `.nomedia` file in any folder. * Themes * Completely automated background indexing of images, so that the collection is automatically and quickly updated when new images are added or updated @@ -76,7 +77,7 @@ be appreciated! * Synchronisation of local images back to the server * If you have ideas for other features - let me know by [raising an issue](https://github.com/Webreaper/Damselfly/issues)! -# How should we I Damselfly? What's the workflow? +# How should I use Damselfly? What's the workflow? The photos live in the library on the server, but whenever you want to work on a picture (e.g., in Photoshop, Digikam or your editing tool of choice) you use the Damselfly Deskop app to add the images to the basket, and choose Download => Save Locally, to sync the pictures across diff --git a/VERSION b/VERSION index ccbccc3d..c043eea7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.0 +2.2.1 diff --git a/docs/Technical.md b/docs/Technical.md index 6f019f3f..b84408a4 100644 --- a/docs/Technical.md +++ b/docs/Technical.md @@ -35,25 +35,30 @@ etc - these will be coming. At startup, Damselfly will run a full index on the folder of images passed in as the only mandatory parameter (or the volume mapped to /pictures if you're running in Docker). The process runs as follows: -1. Damselfly will scan the entire folder tree for any images (currently JPEG, PNG, etc; HEIC and others will be added when Google's image processing -library Skia supports them). This process is normally very quick - on my Synology NAS, around half a million images can be scanned in an hour or two). -During this process, a filesystem iNotify watcher will be set up for each folder in the tree to watch for changes. +1. Damselfly will scan the entire folder tree for any images (currently JPG, PNG, HEIC, TIFF, Webp, BMP and DNG/CR2 RAW). This process is normally +very quick - on my Synology NAS, around half a million images can be scanned in an hour or two). During this process, a filesystem iNotify watcher +will be set up for each folder in the tree to watch for changes. 2. Next, Damselfly will then go back through all of the images, in newest (by file-modified-date) order first, and run the metadata scan. This is more time-consuming, but will pull in all of the metadata such as the resolution, keyword tags, other EXIF data, when the photo was taken, etc. 3. Lastly, the thumbnail generation process will run - generating the small previews used when browsing. This process is CPU-intensive, and can take some time; for my 4TB+ collection of half a million photos, it'll take 5+ days to run on my Synology NAS - processing around 100 images per minute. It'll also hammer the CPU while it runs, so be aware of that. -4. Image/object/face detection will run on the thumbnails after they've been generated. Thumbnails are used (rather than original images) because -image recognition models usually work faster with lower-res images. +4. Image/object/face detection will run on the thumbnails after they've been generated. Thumbnails are used (rather than original +images) because image recognition models usually work faster with lower-res images. Note that AI processing can be extremely CPU-intensive; +for this reason you may want to configure the time-range for processing so that AI processing will only happen during a period when your +NAS isn't being used for much else. Once step #2 finishes, the first full-index is complete. From that point onwards, changes to the image library should be picked up almost instantly and processed quickly; adding a new folder of photos after a day out should only take a few seconds to be indexed, and a couple of minutes for the thumbnails to be generated. +Note that if you want to exclude any folders from Damselfly's indexing process, just create a file named `.nomedia` in the folder, and it will +be skipped. + ## How does Damselfly's Image/Object/Face Recognition Work? -The latest version of Damselfly includes machine-learning functionality to find objects and faces in your images, and tag -them. If you're interested in the technical design/implementation of this feature, there's an article about it +The latest version of Damselfly includes machine-learning functionality to find objects and faces in your images, and tag them. +If you're interested in the technical design/implementation of this feature, there's an article about it [here](https://damselfly.info/face-recognition-in-net-the-good-the-bad-and-the-ugly/). Faces and objects that are recognised will be displayed as special tags, alongside normal keyword-tags, when browsing the