From 3b007d577b4e2398d315290f3117fdb20b78118a Mon Sep 17 00:00:00 2001 From: REDCODE Date: Mon, 6 Jul 2026 19:56:04 +0200 Subject: [PATCH 01/13] feat(assets): add ThumbnailSize column to track generated thumbnail dimensions --- Lactose/Models/Asset.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Lactose/Models/Asset.cs b/Lactose/Models/Asset.cs index a67da99..2d1739c 100644 --- a/Lactose/Models/Asset.cs +++ b/Lactose/Models/Asset.cs @@ -37,6 +37,12 @@ public class Asset { [Column(TypeName = "VARCHAR(2048)")] public string ThumbnailPath { get; set; } = string.Empty; + /// + /// Gets or sets the longest-side size in pixels of the generated thumbnail. + /// Zero means no thumbnail has been generated. + /// + public int ThumbnailSize { get; set; } + /// /// Gets or sets the preview path of the asset. /// From 7a580a950c86a74eea35d21a00e6bc031fd4ef8a Mon Sep 17 00:00:00 2001 From: REDCODE Date: Mon, 6 Jul 2026 19:56:10 +0200 Subject: [PATCH 02/13] feat(repository): add GetAssetsMissingOrWrongThumbnail to interface --- Lactose/Repositories/IAssetRepository.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Lactose/Repositories/IAssetRepository.cs b/Lactose/Repositories/IAssetRepository.cs index 1167bb2..c146ba6 100644 --- a/Lactose/Repositories/IAssetRepository.cs +++ b/Lactose/Repositories/IAssetRepository.cs @@ -96,6 +96,14 @@ public interface IAssetRepository : IDisposable { /// Assets that do not have a thumbnail path set. IEnumerable GetAssetsMissingThumbnail(out int totalAssets); + /// + /// Finds all assets that need thumbnails — either missing entirely, or with wrong dimensions. + /// + /// The expected longest-side size in pixels. + /// The total number of assets needing thumbnails. + /// Assets that need thumbnails regenerated. + IEnumerable GetAssetsMissingOrWrongThumbnail(int thumbnailSize, out int totalAssets); + /// /// Finds all image assets that are missing resolution metadata (ResolutionWidth/ResolutionHeight = 0). /// From 7e6e0ed3ec11ea562ae458e44da60d5baad6bb4f Mon Sep 17 00:00:00 2001 From: REDCODE Date: Mon, 6 Jul 2026 19:56:20 +0200 Subject: [PATCH 03/13] feat(repository): implement GetAssetsMissingOrWrongThumbnail query --- Lactose/Repositories/AssetRepository.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Lactose/Repositories/AssetRepository.cs b/Lactose/Repositories/AssetRepository.cs index 9e68761..3d25713 100644 --- a/Lactose/Repositories/AssetRepository.cs +++ b/Lactose/Repositories/AssetRepository.cs @@ -89,6 +89,12 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository { count = context.Assets.Count(a => a.ThumbnailPath == null || a.ThumbnailPath == ""); return context.Assets.Where(a => a.ThumbnailPath == null || a.ThumbnailPath == ""); } + + /// + public IEnumerable GetAssetsMissingOrWrongThumbnail(int thumbnailSize, out int count) { + count = context.Assets.Count(a => a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize); + return context.Assets.Where(a => a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize); + } /// public IEnumerable GetAssetsMissingMetadata(out int count) { From 4372206d9c70e974be4cb3314b3d887ecbf4fc06 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Mon, 6 Jul 2026 19:56:56 +0200 Subject: [PATCH 04/13] feat(thumbs): read ThumbnailSize setting, invalidate wrong-sized thumbnails, set size after gen --- Lactose/Jobs/ThumbnailJob.cs | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/Lactose/Jobs/ThumbnailJob.cs b/Lactose/Jobs/ThumbnailJob.cs index 88b96dd..3810ed4 100644 --- a/Lactose/Jobs/ThumbnailJob.cs +++ b/Lactose/Jobs/ThumbnailJob.cs @@ -8,7 +8,8 @@ using SixLabors.ImageSharp.Processing; namespace Lactose.Jobs; /// -/// Generates JPEG thumbnails for assets missing them. Creates a master job that spawns sub-jobs per asset. +/// Generates JPEG thumbnails for assets missing them or with wrong dimensions. +/// Creates a master job that spawns sub-jobs per asset. /// public class ThumbnailJob : Job { ThumbnailJob? ParentJob; @@ -100,6 +101,13 @@ public class ThumbnailJob : Job { return; } + // Remove stale thumbnail if it exists (wrong size or orphaned) + if (!string.IsNullOrEmpty(Asset.ThumbnailPath) && File.Exists(Asset.ThumbnailPath)) { + Logger.LogInformation("Removing stale thumbnail for asset ID {Id} at {Path}.", Asset.Id, Asset.ThumbnailPath); + File.Delete(Asset.ThumbnailPath); + Asset.ThumbnailPath = ""; + } + using var image = Image.Load(Asset.OriginalPath); image.Mutate(x => x.Resize( @@ -132,6 +140,7 @@ public class ThumbnailJob : Job { ); Asset.ThumbnailPath = path; + Asset.ThumbnailSize = ThumbnailSize; AssetRepository.Update(Asset); AssetRepository.Save(); } catch (Exception ex) { @@ -140,7 +149,7 @@ public class ThumbnailJob : Job { return; } - Logger.LogInformation($"Thumbnail generated for asset ID {Asset.Id} at {Asset.ThumbnailPath}"); + Logger.LogInformation($"Thumbnail generated for asset ID {Asset.Id} at {Asset.ThumbnailPath} (size: {ThumbnailSize}px)"); JobStatus.Complete($"Thumbnail generated for asset ID {Asset.Id} at {Asset.ThumbnailPath}"); } @@ -153,21 +162,27 @@ public class ThumbnailJob : Job { JobStatus.Start(); Logger.LogInformation("Starting master Thumbnail job for all assets."); - var thumbPath = SettingsRepository.Get(Settings.ThumbnailPath.AsString()); - //TODO: Add thumbnail size setting to the system - if (thumbPath == null) { + var thumbPathSetting = SettingsRepository.Get(Settings.ThumbnailPath.AsString()); + var thumbSizeSetting = SettingsRepository.Get(Settings.ThumbnailSize.AsString()); + + if (thumbPathSetting == null) { Logger.LogError("Thumbnail path not found. Cannot proceed with thumbnail generation."); JobStatus.Fail("Thumbnail path not found. Cannot proceed with thumbnail generation."); return; } - var missingThumbnail = AssetRepository.GetAssetsMissingThumbnail(out totalAssets); - Logger.LogInformation($"Found {totalAssets} assets missing thumbnails."); + if (!int.TryParse(thumbSizeSetting?.Value, out var thumbnailSize)) { + thumbnailSize = 256; + Logger.LogWarning("Thumbnail Size setting not found or invalid. Defaulting to {Size}.", thumbnailSize); + } + + var assetsNeedingThumbnails = AssetRepository.GetAssetsMissingOrWrongThumbnail(thumbnailSize, out totalAssets); + Logger.LogInformation($"Found {totalAssets} assets needing thumbnails (missing or wrong size)."); subJobs = new List(); - foreach (var asset in missingThumbnail) { + foreach (var asset in assetsNeedingThumbnails) { if (token.IsCancellationRequested) { lock (subJobs) subJobs.ForEach(j => j.Cancel()); JobStatus.Cancel("Cancellation requested from user."); @@ -175,7 +190,7 @@ public class ThumbnailJob : Job { } // Create a sub-job for each asset - var job = JobManager.CreateJob(this, asset, 256, thumbPath.Value!); + var job = JobManager.CreateJob(this, asset, thumbnailSize, thumbPathSetting.Value!); job.Done += (o, _) => { IncrementProcessedAssets(); From 4cffb152b766ab3671d1a879bed6697ccc44eb5e Mon Sep 17 00:00:00 2001 From: REDCODE Date: Mon, 6 Jul 2026 19:57:44 +0200 Subject: [PATCH 05/13] feat(db): add migration for ThumbnailSize column on Assets --- ...75732_AddThumbnailSizeToAssets.Designer.cs | 513 ++++++++++++++++++ ...20260706175732_AddThumbnailSizeToAssets.cs | 29 + .../LactoseDbContextModelSnapshot.cs | 3 + 3 files changed, 545 insertions(+) create mode 100644 Lactose/Migrations/20260706175732_AddThumbnailSizeToAssets.Designer.cs create mode 100644 Lactose/Migrations/20260706175732_AddThumbnailSizeToAssets.cs diff --git a/Lactose/Migrations/20260706175732_AddThumbnailSizeToAssets.Designer.cs b/Lactose/Migrations/20260706175732_AddThumbnailSizeToAssets.Designer.cs new file mode 100644 index 0000000..aa0bfce --- /dev/null +++ b/Lactose/Migrations/20260706175732_AddThumbnailSizeToAssets.Designer.cs @@ -0,0 +1,513 @@ +// +using System; +using System.Collections; +using Lactose.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Lactose.Migrations +{ + [DbContext(typeof(LactoseDbContext))] + [Migration("20260706175732_AddThumbnailSizeToAssets")] + partial class AddThumbnailSizeToAssets + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.15") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlbumAsset", b => + { + b.Property("AlbumsId") + .HasColumnType("uuid"); + + b.Property("AssetsId") + .HasColumnType("uuid"); + + b.HasKey("AlbumsId", "AssetsId"); + + b.HasIndex("AssetsId"); + + b.ToTable("AlbumAsset"); + }); + + modelBuilder.Entity("AssetTag", b => + { + b.Property("AssetsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("AssetsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("AssetTag"); + }); + + modelBuilder.Entity("Lactose.Models.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("timestamp without time zone"); + + b.Property("PersonOwnerId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("UserOwnerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PersonOwnerId"); + + b.HasIndex("UserOwnerId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Lactose.Models.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("Duration") + .HasColumnType("real"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FolderId") + .HasColumnType("uuid"); + + b.Property("FrameRate") + .HasColumnType("real"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("bit(64)"); + + b.Property("IsPubliclyShared") + .HasColumnType("boolean"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("OriginalFilename") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("OriginalPath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("PreviewPath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("ResolutionHeight") + .HasColumnType("integer"); + + b.Property("ResolutionWidth") + .HasColumnType("integer"); + + b.Property("ThumbnailPath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("ThumbnailSize") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("OriginalPath") + .IsUnique(); + + b.HasIndex("OwnerId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Lactose.Models.Face", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("BoundingBoxX1") + .HasColumnType("integer"); + + b.Property("BoundingBoxX2") + .HasColumnType("integer"); + + b.Property("BoundingBoxY1") + .HasColumnType("integer"); + + b.Property("BoundingBoxY2") + .HasColumnType("integer"); + + b.Property("ImageHeight") + .HasColumnType("integer"); + + b.Property("ImageWidth") + .HasColumnType("integer"); + + b.Property("PersonId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("PersonId"); + + b.ToTable("Faces"); + }); + + modelBuilder.Entity("Lactose.Models.Folder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("BasePath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.HasKey("Id"); + + b.ToTable("Folders"); + }); + + modelBuilder.Entity("Lactose.Models.JobRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp without time zone"); + + b.Property("Finished") + .HasColumnType("timestamp without time zone"); + + b.Property("JobType") + .HasColumnType("integer"); + + b.Property("Message") + .HasColumnType("VARCHAR(2048)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("VARCHAR(256)"); + + b.Property("ParentJobId") + .HasColumnType("uuid"); + + b.Property("Progress") + .HasColumnType("real"); + + b.Property("Started") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("JobRecords"); + }); + + modelBuilder.Entity("Lactose.Models.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Lactose.Models.Setting", b => + { + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DisplayType") + .HasColumnType("integer"); + + b.Property("Options") + .HasColumnType("text[]"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Value") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Name"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Lactose.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Lactose.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessLevel") + .HasColumnType("integer"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("BannedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("VARCHAR(128)"); + + b.Property("LastLogin") + .HasColumnType("timestamp without time zone"); + + b.Property("Password") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("RefreshTokenExpires") + .HasColumnType("timestamp without time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("Username") + .IsRequired() + .HasColumnType("VARCHAR(64)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("AlbumAsset", b => + { + b.HasOne("Lactose.Models.Album", null) + .WithMany() + .HasForeignKey("AlbumsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lactose.Models.Asset", null) + .WithMany() + .HasForeignKey("AssetsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AssetTag", b => + { + b.HasOne("Lactose.Models.Asset", null) + .WithMany() + .HasForeignKey("AssetsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lactose.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Lactose.Models.Album", b => + { + b.HasOne("Lactose.Models.Person", "PersonOwner") + .WithMany("Albums") + .HasForeignKey("PersonOwnerId"); + + b.HasOne("Lactose.Models.User", "UserOwner") + .WithMany("OwnedAlbums") + .HasForeignKey("UserOwnerId"); + + b.Navigation("PersonOwner"); + + b.Navigation("UserOwner"); + }); + + modelBuilder.Entity("Lactose.Models.Asset", b => + { + b.HasOne("Lactose.Models.Folder", "Folder") + .WithMany("Assets") + .HasForeignKey("FolderId"); + + b.HasOne("Lactose.Models.User", "Owner") + .WithMany("OwnedAssets") + .HasForeignKey("OwnerId"); + + b.Navigation("Folder"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Lactose.Models.Face", b => + { + b.HasOne("Lactose.Models.Asset", "Asset") + .WithMany("Faces") + .HasForeignKey("AssetId"); + + b.HasOne("Lactose.Models.Person", "Person") + .WithMany("Faces") + .HasForeignKey("PersonId"); + + b.Navigation("Asset"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("Lactose.Models.Tag", b => + { + b.HasOne("Lactose.Models.Tag", "Parent") + .WithMany() + .HasForeignKey("ParentId"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Lactose.Models.User", b => + { + b.HasOne("Lactose.Models.Asset", null) + .WithMany("SharedWith") + .HasForeignKey("AssetId"); + }); + + modelBuilder.Entity("Lactose.Models.Asset", b => + { + b.Navigation("Faces"); + + b.Navigation("SharedWith"); + }); + + modelBuilder.Entity("Lactose.Models.Folder", b => + { + b.Navigation("Assets"); + }); + + modelBuilder.Entity("Lactose.Models.Person", b => + { + b.Navigation("Albums"); + + b.Navigation("Faces"); + }); + + modelBuilder.Entity("Lactose.Models.User", b => + { + b.Navigation("OwnedAlbums"); + + b.Navigation("OwnedAssets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Lactose/Migrations/20260706175732_AddThumbnailSizeToAssets.cs b/Lactose/Migrations/20260706175732_AddThumbnailSizeToAssets.cs new file mode 100644 index 0000000..5bae0f6 --- /dev/null +++ b/Lactose/Migrations/20260706175732_AddThumbnailSizeToAssets.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lactose.Migrations +{ + /// + public partial class AddThumbnailSizeToAssets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ThumbnailSize", + table: "Assets", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ThumbnailSize", + table: "Assets"); + } + } +} diff --git a/Lactose/Migrations/LactoseDbContextModelSnapshot.cs b/Lactose/Migrations/LactoseDbContextModelSnapshot.cs index abc5d1d..8e7b7a8 100644 --- a/Lactose/Migrations/LactoseDbContextModelSnapshot.cs +++ b/Lactose/Migrations/LactoseDbContextModelSnapshot.cs @@ -146,6 +146,9 @@ namespace Lactose.Migrations .IsRequired() .HasColumnType("VARCHAR(2048)"); + b.Property("ThumbnailSize") + .HasColumnType("integer"); + b.Property("Type") .HasColumnType("integer"); From 699ab856140ae68e2d0f5e619594bf68ff074d85 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Mon, 6 Jul 2026 19:59:03 +0200 Subject: [PATCH 06/13] fix: redirect unauthenticated users from jobs page to /login --- MilkStream.Client/Components/Pages/Jobs.razor | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/MilkStream.Client/Components/Pages/Jobs.razor b/MilkStream.Client/Components/Pages/Jobs.razor index 036342e..408da09 100644 --- a/MilkStream.Client/Components/Pages/Jobs.razor +++ b/MilkStream.Client/Components/Pages/Jobs.razor @@ -3,8 +3,9 @@ @using Butter.Dtos.Jobs @using Butter.Types @using MilkStream.Client.Services -@inject JobsService JobsService -@inject LoginService LoginService +@inject JobsService JobsService +@inject LoginService LoginService +@inject NavigationManager NavigationManager

Jobs

@@ -81,16 +82,18 @@ TimeSpan pollInterval = TimeSpan.FromSeconds(1); protected override async Task OnInitializedAsync() { + if (!LoginService.IsLoggedIn) { + NavigationManager.NavigateTo("/login"); + return; + } LoginService.LoggedUserChanged += async (_, _) => { await RefreshActiveRoots(); await LoadPastJobs(1); }; - if (LoginService.IsLoggedIn) { - await RefreshActiveRoots(); - await LoadPastJobs(1); - StartPolling(); - StartPastRefresh(); - } + await RefreshActiveRoots(); + await LoadPastJobs(1); + StartPolling(); + StartPastRefresh(); } // ── Active root polling ── From 82250739e362edb5ff375d01fbc505a38e3b83bd Mon Sep 17 00:00:00 2001 From: REDCODE Date: Mon, 6 Jul 2026 20:04:32 +0200 Subject: [PATCH 07/13] fix(ui): render child jobs in JobTree by skipping root filter at deeper levels --- MilkStream.Client/Components/JobTree.razor | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/MilkStream.Client/Components/JobTree.razor b/MilkStream.Client/Components/JobTree.razor index 12ce460..641cfee 100644 --- a/MilkStream.Client/Components/JobTree.razor +++ b/MilkStream.Client/Components/JobTree.razor @@ -29,10 +29,9 @@ [Parameter] public string EmptyText { get; set; } = "No jobs"; [Parameter] public Func>>? FetchChildren { get; set; } - List Roots => Jobs? - .Where(j => j.ParentJobId is null) - .OrderBy(j => j.Created) - .ToList() ?? []; + List Roots => Level == 0 + ? Jobs?.Where(j => j.ParentJobId is null).OrderBy(j => j.Created).ToList() ?? [] + : Jobs?.OrderBy(j => j.Created).ToList() ?? []; readonly HashSet _expandedParents = []; readonly Dictionary?> _childrenCache = []; From 9e8d31f4577a2e9171c6f588e4f4030f10c52f86 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Mon, 6 Jul 2026 20:09:23 +0200 Subject: [PATCH 08/13] fix(db): always override ThumbnailPath/PreviewPath values with local paths on init --- Lactose/Services/DbInitializer.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lactose/Services/DbInitializer.cs b/Lactose/Services/DbInitializer.cs index 68453a0..08cd505 100644 --- a/Lactose/Services/DbInitializer.cs +++ b/Lactose/Services/DbInitializer.cs @@ -85,6 +85,10 @@ internal class DbInitializer(LactoseDbContext dbContext, IPasswordHasher p dbSetting.Type = setting.Type; dbSetting.DisplayType = setting.DisplayType; dbSetting.Options = setting.Options; + if (setting.Name == Settings.ThumbnailPath.AsString()) + dbSetting.Value = Path.Combine(Environment.CurrentDirectory, "thumbnails"); + else if (setting.Name == Settings.PreviewPath.AsString()) + dbSetting.Value = Path.Combine(Environment.CurrentDirectory, "previews"); dbContext.Settings.Update(dbSetting); } } From 167cbb8ad10ebe4e0bf6237e8f86607e7e81d384 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Mon, 6 Jul 2026 20:10:47 +0200 Subject: [PATCH 09/13] Revert "fix(db): always override ThumbnailPath/PreviewPath values with local paths on init" This reverts commit 9e8d31f4577a2e9171c6f588e4f4030f10c52f86. --- Lactose/Services/DbInitializer.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Lactose/Services/DbInitializer.cs b/Lactose/Services/DbInitializer.cs index 08cd505..68453a0 100644 --- a/Lactose/Services/DbInitializer.cs +++ b/Lactose/Services/DbInitializer.cs @@ -85,10 +85,6 @@ internal class DbInitializer(LactoseDbContext dbContext, IPasswordHasher p dbSetting.Type = setting.Type; dbSetting.DisplayType = setting.DisplayType; dbSetting.Options = setting.Options; - if (setting.Name == Settings.ThumbnailPath.AsString()) - dbSetting.Value = Path.Combine(Environment.CurrentDirectory, "thumbnails"); - else if (setting.Name == Settings.PreviewPath.AsString()) - dbSetting.Value = Path.Combine(Environment.CurrentDirectory, "previews"); dbContext.Settings.Update(dbSetting); } } From ffffb7ff2b6dbf4e46fad65d725d891c30f49e89 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Mon, 6 Jul 2026 20:13:15 +0200 Subject: [PATCH 10/13] fix(docker): create thumbnails/previews dirs as app user so subdirs can be created at runtime --- Lactose/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Lactose/Dockerfile b/Lactose/Dockerfile index 9a7f8b5..fa43c40 100644 --- a/Lactose/Dockerfile +++ b/Lactose/Dockerfile @@ -21,4 +21,5 @@ RUN dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:U FROM base AS final WORKDIR /app COPY --from=publish /app/publish . +RUN mkdir -p thumbnails previews ENTRYPOINT ["dotnet", "Lactose.dll"] From cbe374a010558a7b73ba0b018ac623be6d9432f7 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Mon, 6 Jul 2026 20:22:15 +0200 Subject: [PATCH 11/13] fix(ui): restore auth in App.razor before rendering to prevent race redirect to /login --- MilkStream.Client/Components/App.razor | 43 ++++++++++++++++--- .../Components/Layout/NavMenu.razor | 17 +------- MilkStream.Client/Components/_Imports.razor | 1 + 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/MilkStream.Client/Components/App.razor b/MilkStream.Client/Components/App.razor index 2266eae..eb948e5 100644 --- a/MilkStream.Client/Components/App.razor +++ b/MilkStream.Client/Components/App.razor @@ -1,6 +1,37 @@ - - - - - - +@inject ILocalStorageService localStorage +@inject LoginService loginService +@inject UserService userService + +@if (_initialized) { + + + + + + +} else { +
+
+ Loading... +
+} + +@code { + bool _initialized; + + protected override async Task OnInitializedAsync() { + AuthInfo? auth = null; + try { + auth = await localStorage.GetItemAsync("auth"); + } catch { + try { await localStorage.RemoveItemAsync("auth"); } catch { } + } + + if (auth != null) { + loginService.AuthInfo = auth; + loginService.LoggedUser = await userService.GetUserAsync(); + } + + _initialized = true; + } +} diff --git a/MilkStream.Client/Components/Layout/NavMenu.razor b/MilkStream.Client/Components/Layout/NavMenu.razor index b8a8dde..664b53b 100644 --- a/MilkStream.Client/Components/Layout/NavMenu.razor +++ b/MilkStream.Client/Components/Layout/NavMenu.razor @@ -3,7 +3,6 @@ @inherits LayoutComponentBase @inject NavigationManager navigation -@inject UserService userService @inject LoginService loginService @inject ILocalStorageService localStorage @@ -70,21 +69,7 @@ bool LoggedIn => loginService.IsLoggedIn; bool Admin => loginService.LoggedUser?.AccessLevel == EAccessLevel.Admin; - protected override async Task OnInitializedAsync() { - AuthInfo? auth = null; - try { - auth = await localStorage.GetItemAsync("auth"); - } catch (Exception) { - // The stored value may be a stale DPAPI-encrypted blob from the old Blazor Server - // app. It is not valid JSON so deserialization will throw. Clear the bad entry and - // continue as a logged-out user; the user can log in again normally. - await localStorage.RemoveItemAsync("auth"); - } - - if (auth != null) { - loginService.AuthInfo = auth; - loginService.LoggedUser = await userService.GetUserAsync(); - } + protected override void OnInitialized() { loginService.LoggedUserChanged += (_, _) => StateHasChanged(); } diff --git a/MilkStream.Client/Components/_Imports.razor b/MilkStream.Client/Components/_Imports.razor index 94721dc..1729e3f 100644 --- a/MilkStream.Client/Components/_Imports.razor +++ b/MilkStream.Client/Components/_Imports.razor @@ -8,3 +8,4 @@ @using Microsoft.JSInterop @using MilkStream.Client @using MilkStream.Client.Components +@using MilkStream.Client.Services From a7b71153e90b9f27b951f2d863b6e18860be426b Mon Sep 17 00:00:00 2001 From: REDCODE Date: Mon, 6 Jul 2026 20:28:39 +0200 Subject: [PATCH 12/13] feat(jobs): add CompletedWithErrors status for parents with failed sub-jobs --- Butter/Types/EJobStatus.cs | 2 ++ Lactose/Jobs/Job.cs | 1 + Lactose/Jobs/JobManager.cs | 1 + Lactose/Jobs/JobStatus.cs | 11 +++++++++++ Lactose/Jobs/ThumbnailJob.cs | 15 +++++++++++---- Lactose/Repositories/JobRecordRepository.cs | 2 +- 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/Butter/Types/EJobStatus.cs b/Butter/Types/EJobStatus.cs index 1ec828b..c63109c 100644 --- a/Butter/Types/EJobStatus.cs +++ b/Butter/Types/EJobStatus.cs @@ -12,6 +12,8 @@ public enum EJobStatus { Waiting, /// Successfully finished. Completed, + /// Finished, but some sub-jobs or steps had errors. + CompletedWithErrors, /// Finished with an error. Failed, /// Canceled by user or system. diff --git a/Lactose/Jobs/Job.cs b/Lactose/Jobs/Job.cs index 84719eb..0e28df5 100644 --- a/Lactose/Jobs/Job.cs +++ b/Lactose/Jobs/Job.cs @@ -52,6 +52,7 @@ public abstract class Job { case EJobStatus.Running: // If still running or waiting, mark as completed case EJobStatus.Waiting: //somebody forgot to update the status and the method returned. case EJobStatus.Completed: + case EJobStatus.CompletedWithErrors: Completed?.Invoke(this, JobStatus); break; case EJobStatus.Queued: // This should never happen diff --git a/Lactose/Jobs/JobManager.cs b/Lactose/Jobs/JobManager.cs index 676ffbd..f4aa5ce 100644 --- a/Lactose/Jobs/JobManager.cs +++ b/Lactose/Jobs/JobManager.cs @@ -132,6 +132,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger lo break; case EJobStatus.Queued: case EJobStatus.Completed: + case EJobStatus.CompletedWithErrors: case EJobStatus.Canceled: case EJobStatus.Failed: logger.LogInformation($"Job with ID {id} has been removed from the job manager."); diff --git a/Lactose/Jobs/JobStatus.cs b/Lactose/Jobs/JobStatus.cs index 7a791f6..d9fed44 100644 --- a/Lactose/Jobs/JobStatus.cs +++ b/Lactose/Jobs/JobStatus.cs @@ -88,6 +88,17 @@ public class JobStatus(Job job) { if (message != null) Message = message; } + /// + /// Marks the job as completed, but some sub-jobs or steps had errors. + /// + /// Optional completion message. + public void CompleteWithErrors(string? message = null) { + Status = EJobStatus.CompletedWithErrors; + Finished = DateTime.UtcNow; + Progress = 1; + if (message != null) Message = message; + } + /// /// Marks the job as failed. /// diff --git a/Lactose/Jobs/ThumbnailJob.cs b/Lactose/Jobs/ThumbnailJob.cs index 3810ed4..a61b636 100644 --- a/Lactose/Jobs/ThumbnailJob.cs +++ b/Lactose/Jobs/ThumbnailJob.cs @@ -1,4 +1,5 @@ using Butter.Settings; +using Butter.Types; using Lactose.Models; using Lactose.Repositories; using SixLabors.ImageSharp; @@ -16,8 +17,9 @@ public class ThumbnailJob : Job { List? subJobs; Asset? Asset; string? ThumbnailPath; - int processedAssets = 0; - int totalAssets = 1; // Avoid division by zero + int processedAssets = 0; + int failedAssets = 0; + int totalAssets = 1; // Avoid division by zero int ThumbnailSize; ILogger Logger; ISettingsRepository SettingsRepository; @@ -193,8 +195,10 @@ public class ThumbnailJob : Job { var job = JobManager.CreateJob(this, asset, thumbnailSize, thumbPathSetting.Value!); job.Done += (o, _) => { + var sub = (ThumbnailJob)o!; + if (sub.JobStatus.Status == EJobStatus.Failed) failedAssets++; IncrementProcessedAssets(); - lock (subJobs) subJobs.Remove((o as ThumbnailJob)!); + lock (subJobs) subJobs.Remove(sub); }; lock (subJobs) subJobs.Add(job); @@ -212,7 +216,10 @@ public class ThumbnailJob : Job { } } - JobStatus.Complete("All thumbnails generated successfully."); + if (failedAssets > 0) + JobStatus.CompleteWithErrors($"{failedAssets} of {totalAssets} thumbnails failed."); + else + JobStatus.Complete("All thumbnails generated successfully."); } void IncrementProcessedAssets() { diff --git a/Lactose/Repositories/JobRecordRepository.cs b/Lactose/Repositories/JobRecordRepository.cs index 4064cf2..8170e18 100644 --- a/Lactose/Repositories/JobRecordRepository.cs +++ b/Lactose/Repositories/JobRecordRepository.cs @@ -22,7 +22,7 @@ public class JobRecordRepository(LactoseDbContext context) : IJobRecordRepositor .OrderByDescending(j => j.Finished ?? j.Created) .ToList(); - static readonly EJobStatus[] PastStatuses = [EJobStatus.Completed, EJobStatus.Failed, EJobStatus.Canceled]; + static readonly EJobStatus[] PastStatuses = [EJobStatus.Completed, EJobStatus.CompletedWithErrors, EJobStatus.Failed, EJobStatus.Canceled]; /// public List GetPastRootJobs(int page, int pageSize, out int total) { From 9dd93494c15b998fc19a6981fd6c02827cd9bf8a Mon Sep 17 00:00:00 2001 From: REDCODE Date: Mon, 6 Jul 2026 20:28:42 +0200 Subject: [PATCH 13/13] feat(ui): show CompletedWithErrors as orange and group sub-jobs by status --- MilkStream.Client/Components/JobRow.razor | 43 ++++++++++-------- MilkStream.Client/Components/JobTree.razor | 53 ++++++++++++++++++++-- 2 files changed, 72 insertions(+), 24 deletions(-) diff --git a/MilkStream.Client/Components/JobRow.razor b/MilkStream.Client/Components/JobRow.razor index 471f7ee..046cd58 100644 --- a/MilkStream.Client/Components/JobRow.razor +++ b/MilkStream.Client/Components/JobRow.razor @@ -63,30 +63,33 @@ }; string RowClass => Job.Status switch { - EJobStatus.Completed => "", - EJobStatus.Failed => "border-start border-2 border-danger", - EJobStatus.Canceled => "", - _ => "" + EJobStatus.Completed => "", + EJobStatus.CompletedWithErrors => "border-start border-2 border-warning", + EJobStatus.Failed => "border-start border-2 border-danger", + EJobStatus.Canceled => "", + _ => "" }; string DotColor => Job.Status switch { - EJobStatus.Queued => "#6c757d", - EJobStatus.Running => "#0d6efd", - EJobStatus.Waiting => "#0dcaf0", - EJobStatus.Completed => "#198754", - EJobStatus.Failed => "#dc3545", - EJobStatus.Canceled => "#6c757d", - _ => "#6c757d" + EJobStatus.Queued => "#6c757d", + EJobStatus.Running => "#0d6efd", + EJobStatus.Waiting => "#0dcaf0", + EJobStatus.Completed => "#198754", + EJobStatus.CompletedWithErrors => "#fd7e14", + EJobStatus.Failed => "#dc3545", + EJobStatus.Canceled => "#6c757d", + _ => "#6c757d" }; string StatusColor => Job.Status switch { - EJobStatus.Queued => "secondary", - EJobStatus.Running => "primary", - EJobStatus.Waiting => "info", - EJobStatus.Completed => "success", - EJobStatus.Failed => "danger", - EJobStatus.Canceled => "secondary", - _ => "secondary" + EJobStatus.Queued => "secondary", + EJobStatus.Running => "primary", + EJobStatus.Waiting => "info", + EJobStatus.Completed => "success", + EJobStatus.CompletedWithErrors => "warning", + EJobStatus.Failed => "danger", + EJobStatus.Canceled => "secondary", + _ => "secondary" }; bool CanCancel => Job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting; @@ -98,9 +101,9 @@ EJobStatus.Queued => now - Job.Created.ToLocalTime(), EJobStatus.Running or EJobStatus.Waiting when Job.Started.HasValue => now - Job.Started.Value.ToLocalTime(), - EJobStatus.Completed or EJobStatus.Failed or EJobStatus.Canceled when Job.Finished.HasValue && Job.Started.HasValue + EJobStatus.Completed or EJobStatus.CompletedWithErrors or EJobStatus.Failed or EJobStatus.Canceled when Job.Finished.HasValue && Job.Started.HasValue => Job.Finished.Value.ToLocalTime() - Job.Started.Value.ToLocalTime(), - EJobStatus.Completed or EJobStatus.Failed or EJobStatus.Canceled when Job.Finished.HasValue + EJobStatus.Completed or EJobStatus.CompletedWithErrors or EJobStatus.Failed or EJobStatus.Canceled when Job.Finished.HasValue => Job.Finished.Value.ToLocalTime() - Job.Created.ToLocalTime(), _ => null }; diff --git a/MilkStream.Client/Components/JobTree.razor b/MilkStream.Client/Components/JobTree.razor index 641cfee..a77d369 100644 --- a/MilkStream.Client/Components/JobTree.razor +++ b/MilkStream.Client/Components/JobTree.razor @@ -15,10 +15,27 @@ ExpandedChanged="(bool e) => OnExpanded(root.Id, e)" /> @if (_expandedParents.Contains(root.Id) && hasFetched && hasChildren) { - + var groups = children!.GroupBy(j => j.Status).OrderBy(g => (int)g.Key); + @foreach (var group in groups) { + var groupKey = $"{root.Id}-{(int)group.Key}"; + var isGroupExpanded = _expandedGroups.Contains(groupKey); +
+
+ + + @(StatusLabel(group.Key)) + @group.Count() +
+ @if (isGroupExpanded) { + + } +
+ } } } } @@ -34,6 +51,7 @@ : Jobs?.OrderBy(j => j.Created).ToList() ?? []; readonly HashSet _expandedParents = []; + readonly HashSet _expandedGroups = []; readonly Dictionary?> _childrenCache = []; string? ChildSummary(JobStatusDto job, bool hasFetched, List? children) { @@ -55,6 +73,28 @@ return string.Join(" · ", parts); } + static string StatusColor(EJobStatus status) => status switch { + EJobStatus.Queued => "#6c757d", + EJobStatus.Running => "#0d6efd", + EJobStatus.Waiting => "#0dcaf0", + EJobStatus.Completed => "#198754", + EJobStatus.CompletedWithErrors => "#fd7e14", + EJobStatus.Failed => "#dc3545", + EJobStatus.Canceled => "#6c757d", + _ => "#6c757d" + }; + + static string StatusLabel(EJobStatus status) => status switch { + EJobStatus.Completed => "Succeeded", + EJobStatus.CompletedWithErrors => "Completed with errors", + EJobStatus.Failed => "Failed", + EJobStatus.Canceled => "Canceled", + EJobStatus.Queued => "Queued", + EJobStatus.Running => "Running", + EJobStatus.Waiting => "Waiting", + _ => status.ToString() + }; + async Task OnExpanded(Guid id, bool expanded) { if (expanded) { _expandedParents.Add(id); @@ -65,4 +105,9 @@ _expandedParents.Remove(id); } } + + void ToggleGroup(string groupKey) { + if (!_expandedGroups.Remove(groupKey)) + _expandedGroups.Add(groupKey); + } }