Merge branch 'feature/thumbnailInvalidation' into develop
This commit is contained in:
@@ -12,6 +12,8 @@ public enum EJobStatus {
|
||||
Waiting,
|
||||
/// <summary>Successfully finished.</summary>
|
||||
Completed,
|
||||
/// <summary>Finished, but some sub-jobs or steps had errors.</summary>
|
||||
CompletedWithErrors,
|
||||
/// <summary>Finished with an error.</summary>
|
||||
Failed,
|
||||
/// <summary>Canceled by user or system.</summary>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -132,6 +132,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> 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.");
|
||||
|
||||
@@ -88,6 +88,17 @@ public class JobStatus(Job job) {
|
||||
if (message != null) Message = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the job as completed, but some sub-jobs or steps had errors.
|
||||
/// </summary>
|
||||
/// <param name="message">Optional completion message.</param>
|
||||
public void CompleteWithErrors(string? message = null) {
|
||||
Status = EJobStatus.CompletedWithErrors;
|
||||
Finished = DateTime.UtcNow;
|
||||
Progress = 1;
|
||||
if (message != null) Message = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the job as failed.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using SixLabors.ImageSharp;
|
||||
@@ -8,15 +9,17 @@ using SixLabors.ImageSharp.Processing;
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ThumbnailJob : Job {
|
||||
ThumbnailJob? ParentJob;
|
||||
List<ThumbnailJob>? 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<ThumbnailJob> Logger;
|
||||
ISettingsRepository SettingsRepository;
|
||||
@@ -100,6 +103,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 +142,7 @@ public class ThumbnailJob : Job {
|
||||
);
|
||||
|
||||
Asset.ThumbnailPath = path;
|
||||
Asset.ThumbnailSize = ThumbnailSize;
|
||||
AssetRepository.Update(Asset);
|
||||
AssetRepository.Save();
|
||||
} catch (Exception ex) {
|
||||
@@ -140,7 +151,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 +164,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<ThumbnailJob>();
|
||||
|
||||
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,11 +192,13 @@ public class ThumbnailJob : Job {
|
||||
}
|
||||
|
||||
// Create a sub-job for each asset
|
||||
var job = JobManager.CreateJob<ThumbnailJob>(this, asset, 256, thumbPath.Value!);
|
||||
var job = JobManager.CreateJob<ThumbnailJob>(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);
|
||||
@@ -197,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() {
|
||||
|
||||
513
Lactose/Migrations/20260706175732_AddThumbnailSizeToAssets.Designer.cs
generated
Normal file
513
Lactose/Migrations/20260706175732_AddThumbnailSizeToAssets.Designer.cs
generated
Normal file
@@ -0,0 +1,513 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("AlbumsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AlbumsId", "AssetsId");
|
||||
|
||||
b.HasIndex("AssetsId");
|
||||
|
||||
b.ToTable("AlbumAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AssetsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<Guid?>("UserOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("UserOwnerId");
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<float?>("Duration")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<BitArray>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<bool>("IsPubliclyShared")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("OwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BoundingBoxX1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxX2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BasePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Folders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(256)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string[]>("Options")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddThumbnailSizeToAssets : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ThumbnailSize",
|
||||
table: "Assets",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ThumbnailSize",
|
||||
table: "Assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,9 @@ namespace Lactose.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ public class Asset {
|
||||
[Column(TypeName = "VARCHAR(2048)")]
|
||||
public string ThumbnailPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the longest-side size in pixels of the generated thumbnail.
|
||||
/// Zero means no thumbnail has been generated.
|
||||
/// </summary>
|
||||
public int ThumbnailSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the preview path of the asset.
|
||||
/// </summary>
|
||||
|
||||
@@ -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 == "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetAssetsMissingMetadata(out int count) {
|
||||
|
||||
@@ -96,6 +96,14 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <returns>Assets that do not have a thumbnail path set.</returns>
|
||||
IEnumerable<Asset> GetAssetsMissingThumbnail(out int totalAssets);
|
||||
|
||||
/// <summary>
|
||||
/// Finds all assets that need thumbnails — either missing entirely, or with wrong dimensions.
|
||||
/// </summary>
|
||||
/// <param name="thumbnailSize">The expected longest-side size in pixels.</param>
|
||||
/// <param name="totalAssets">The total number of assets needing thumbnails.</param>
|
||||
/// <returns>Assets that need thumbnails regenerated.</returns>
|
||||
IEnumerable<Asset> GetAssetsMissingOrWrongThumbnail(int thumbnailSize, out int totalAssets);
|
||||
|
||||
/// <summary>
|
||||
/// Finds all image assets that are missing resolution metadata (ResolutionWidth/ResolutionHeight = 0).
|
||||
/// </summary>
|
||||
|
||||
@@ -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];
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<JobRecord> GetPastRootJobs(int page, int pageSize, out int total) {
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
|
||||
</Found>
|
||||
</Router>
|
||||
@inject ILocalStorageService localStorage
|
||||
@inject LoginService loginService
|
||||
@inject UserService userService
|
||||
|
||||
@if (_initialized) {
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
|
||||
</Found>
|
||||
</Router>
|
||||
} else {
|
||||
<div class="d-flex justify-content-center align-items-center vh-100 text-muted">
|
||||
<div class="spinner-border me-2" role="status"></div>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
bool _initialized;
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
AuthInfo? auth = null;
|
||||
try {
|
||||
auth = await localStorage.GetItemAsync<AuthInfo>("auth");
|
||||
} catch {
|
||||
try { await localStorage.RemoveItemAsync("auth"); } catch { }
|
||||
}
|
||||
|
||||
if (auth != null) {
|
||||
loginService.AuthInfo = auth;
|
||||
loginService.LoggedUser = await userService.GetUserAsync();
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -15,10 +15,27 @@
|
||||
ExpandedChanged="(bool e) => OnExpanded(root.Id, e)" />
|
||||
|
||||
@if (_expandedParents.Contains(root.Id) && hasFetched && hasChildren) {
|
||||
<JobTree Jobs="children!"
|
||||
Level="@(Level + 1)"
|
||||
FetchChildren="@FetchChildren"
|
||||
EmptyText="" />
|
||||
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);
|
||||
<div class="ms-@((Level + 1) * 3)">
|
||||
<div class="small fw-semibold py-1 d-flex align-items-center gap-2 user-select-none"
|
||||
@onclick="() => ToggleGroup(groupKey)" role="button">
|
||||
<i class="bi @(isGroupExpanded ? "bi-chevron-down" : "bi-chevron-right")"></i>
|
||||
<span class="d-inline-block rounded-circle flex-shrink-0"
|
||||
style="width: 8px; height: 8px; background: @(StatusColor(group.Key))"></span>
|
||||
<span>@(StatusLabel(group.Key))</span>
|
||||
<span class="text-muted">@group.Count()</span>
|
||||
</div>
|
||||
@if (isGroupExpanded) {
|
||||
<JobTree Jobs="group.ToList()"
|
||||
Level="@(Level + 2)"
|
||||
FetchChildren="@FetchChildren"
|
||||
EmptyText="" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,12 +46,12 @@
|
||||
[Parameter] public string EmptyText { get; set; } = "No jobs";
|
||||
[Parameter] public Func<Guid, Task<List<JobStatusDto>>>? FetchChildren { get; set; }
|
||||
|
||||
List<JobStatusDto> Roots => Jobs?
|
||||
.Where(j => j.ParentJobId is null)
|
||||
.OrderBy(j => j.Created)
|
||||
.ToList() ?? [];
|
||||
List<JobStatusDto> Roots => Level == 0
|
||||
? Jobs?.Where(j => j.ParentJobId is null).OrderBy(j => j.Created).ToList() ?? []
|
||||
: Jobs?.OrderBy(j => j.Created).ToList() ?? [];
|
||||
|
||||
readonly HashSet<Guid> _expandedParents = [];
|
||||
readonly HashSet<string> _expandedGroups = [];
|
||||
readonly Dictionary<Guid, List<JobStatusDto>?> _childrenCache = [];
|
||||
|
||||
string? ChildSummary(JobStatusDto job, bool hasFetched, List<JobStatusDto>? children) {
|
||||
@@ -56,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);
|
||||
@@ -66,4 +105,9 @@
|
||||
_expandedParents.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
void ToggleGroup(string groupKey) {
|
||||
if (!_expandedGroups.Remove(groupKey))
|
||||
_expandedGroups.Add(groupKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AuthInfo>("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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<h3 class="mb-0">Jobs</h3>
|
||||
@@ -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 ──
|
||||
|
||||
@@ -8,3 +8,4 @@
|
||||
@using Microsoft.JSInterop
|
||||
@using MilkStream.Client
|
||||
@using MilkStream.Client.Components
|
||||
@using MilkStream.Client.Services
|
||||
|
||||
Reference in New Issue
Block a user