3 Commits
8 changed files with 814 additions and 52 deletions
+4 -3
View File
@@ -113,9 +113,10 @@ public class LactoseDbContext : DbContext {
modelBuilder.Entity<Asset>().HasIndex(e => new { e.ResolutionWidth, e.ResolutionHeight, e.Type })
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
// Keyset pagination for scan jobs: WHERE FolderId = X AND DeletedAt IS NULL AND Id > @last ORDER BY Id
modelBuilder.Entity<Asset>().HasIndex(e => new { e.FolderId, e.Id })
.HasDatabaseName("IX_Assets_FolderId_Id")
// Keyset pagination for scan jobs:
// WHERE FolderId = X AND DeletedAt IS NULL AND IngestedAt >= @cursor AND Id > @last ORDER BY Id
modelBuilder.Entity<Asset>().HasIndex(e => new { e.FolderId, e.IngestedAt, e.Id })
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
.HasFilter("\"DeletedAt\" IS NULL");
// People
modelBuilder.Entity<Person>().HasIndex(p => p.Name)
+2 -1
View File
@@ -357,7 +357,8 @@ public class AssetController(
Duration = dto.Duration ?? 0,
FrameRate = dto.FrameRate ?? 0,
OriginalPath = String.Empty,
OriginalFilename = String.Empty
OriginalFilename = String.Empty,
IngestedAt = DateTime.UtcNow
};
assetRepository.Insert(asset);
+1 -1
View File
@@ -93,7 +93,7 @@ public class CreateAlbumsJob(
var batch = await db.Assets.AsNoTracking()
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null && a.Id > lastId
&& (cursor == null || a.CreatedAt >= cursor.Value))
&& (cursor == null || a.IngestedAt >= cursor.Value))
.OrderBy(a => a.Id)
.Take(ScanBatchSize)
.Select(a => new { a.Id, a.OriginalPath })
+22 -43
View File
@@ -69,18 +69,22 @@ public sealed class FileSystemCrawlJob : Job {
var existingStates = await db.DirectoryScanStates
.Where(s => s.FolderId == folderId)
.ToDictionaryAsync(s => s.Path, StringComparer.OrdinalIgnoreCase, token);
.ToDictionaryAsync(s => s.Path, StringComparer.Ordinal, token);
JobStatus.UpdateProgress(0.02f, "Scanning directory structure...");
int totalDirs = CountDirectories(root, token);
if (totalDirs <= 0) totalDirs = Math.Max(existingStates.Count, 1);
logger.LogInformation("Directory structure for {Path}: {Total} directories.", workingPath, totalDirs);
JobStatus.UpdateProgress(0.05f, $"Found {totalDirs:N0} directories. Scanning...");
// Progress denominator: reuse the number of directories recorded by the previous scan, which is
// accurate once a first run has completed. On the very first run (no state yet) there is no
// denominator, so the bar ramps heuristically while the live message shows real progress.
int totalDirs = Math.Max(existingStates.Count, 1);
bool haveDenominator = existingStates.Count > 0;
logger.LogInformation("Crawling {Path}: {Total} known directories.", workingPath, totalDirs);
JobStatus.UpdateProgress(0.02f, haveDenominator
? $"Scanning {totalDirs:N0} directories..."
: "Scanning... (first run)");
int dirsProcessed = 0, dirsSkipped = 0, walkedDirs = 0,
filesAdded = 0, filesUpdated = 0, filesDeleted = 0;
var lastProgressUpdate = DateTime.UtcNow;
var seenPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var seenPaths = new HashSet<string>(StringComparer.Ordinal);
var stack = new Stack<string>();
stack.Push(root.FullName);
@@ -141,9 +145,15 @@ public sealed class FileSystemCrawlJob : Job {
if (walkedDirs % ProgressUpdateEveryDirs == 0
|| DateTime.UtcNow - lastProgressUpdate > TimeSpan.FromMilliseconds(500)) {
lastProgressUpdate = DateTime.UtcNow;
float progress = totalDirs <= 0 ? 0.9f : Math.Clamp(0.05f + 0.90f * walkedDirs / totalDirs, 0.05f, 0.95f);
// Ramp heuristically when we have no accurate denominator (first run); otherwise
// progress is walkedDirs/totalDirs. Cap before the final 0.95 "saving" step.
float progress = haveDenominator
? Math.Clamp(0.05f + 0.90f * walkedDirs / totalDirs, 0.05f, 0.90f)
: Math.Clamp(0.05f + 0.80f * Math.Min(1f, walkedDirs / 5000f), 0.05f, 0.85f);
JobStatus.UpdateProgress(progress,
$"Scanning {workingPath}: {walkedDirs:N0}/{totalDirs:N0} dirs — {filesAdded:N0} added, {filesUpdated:N0} updated, {filesDeleted:N0} removed");
haveDenominator
? $"Scanning {workingPath}: {walkedDirs:N0}/{totalDirs:N0} dirs — {filesAdded:N0} added, {filesUpdated:N0} updated, {filesDeleted:N0} removed"
: $"Scanning {workingPath}: {walkedDirs:N0} dirs — {filesAdded:N0} added, {filesUpdated:N0} updated, {filesDeleted:N0} removed");
}
}
@@ -165,38 +175,6 @@ public sealed class FileSystemCrawlJob : Job {
JobStatus.Complete($"Scanned {workingPath}: {filesAdded:N0} added, {filesUpdated:N0} updated, {filesDeleted:N0} removed, {dirsSkipped:N0} dirs unchanged.");
}
/// <summary>
/// Counts the directories in the given tree (including the root) without enumerating any files.
/// Used to provide an accurate progress denominator for the crawl.
/// </summary>
/// <param name="root">The root directory.</param>
/// <param name="token">Cancellation token.</param>
/// <returns>The number of directories found; 0 if the root is empty or unreadable.</returns>
int CountDirectories(DirectoryInfo root, CancellationToken token) {
int count = 1;
var stack = new Stack<string>();
stack.Push(root.FullName);
while (stack.Count > 0) {
token.ThrowIfCancellationRequested();
var path = stack.Pop();
DirectoryInfo[] subdirs;
try {
subdirs = new DirectoryInfo(path).GetDirectories();
} catch (Exception ex) {
logger.LogWarning(ex, "Could not enumerate directory {Path} while counting.", path);
continue;
}
count += subdirs.Length;
foreach (var sub in subdirs)
stack.Push(sub.FullName);
}
return count;
}
/// <summary>
/// Reconciles the direct children of a directory against the database: inserts new assets,
/// updates changed ones, and soft-deletes previously tracked files that no longer exist.
@@ -222,7 +200,7 @@ public sealed class FileSystemCrawlJob : Job {
.Where(a => a.OriginalPath.CompareTo(lo) >= 0 && a.OriginalPath.CompareTo(hi) < 0)
.ToListAsync(token))
.Where(a => !a.OriginalPath.AsSpan(prefix.Length).Contains(Path.DirectorySeparatorChar))
.ToDictionary(a => a.OriginalPath, StringComparer.OrdinalIgnoreCase);
.ToDictionary(a => a.OriginalPath, StringComparer.Ordinal);
// Exact-path fallback: OriginalPath is globally unique, so a row can exist under a different
// folder (e.g. a recreated folder with a new ID) or fall outside the range query above.
@@ -230,7 +208,7 @@ public sealed class FileSystemCrawlJob : Job {
var unmatched = files
.Select(f => f.FullName)
.Where(p => !known.ContainsKey(p))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Distinct(StringComparer.Ordinal)
.ToList();
foreach (var chunk in unmatched.Chunk(1000)) {
var existing = await assetDb.Assets.AsNoTracking()
@@ -323,6 +301,7 @@ public sealed class FileSystemCrawlJob : Job {
UploadedBy = uploaderId,
CreatedAt = finfo.CreationTimeUtc,
UpdatedAt = finfo.LastWriteTimeUtc,
IngestedAt = DateTime.UtcNow,
MimeType = type.Value switch {
EAssetType.Image => mimeImg.MimeType,
EAssetType.Video => mimeVid.MimeType,
@@ -0,0 +1,713 @@
// <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("20260819224929_AddAssetIngestedAt")]
partial class AddAssetIngestedAt
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
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<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with 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<DateTime>("IngestedAt")
.HasColumnType("timestamp with time zone");
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<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
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 with time zone");
b.Property<Guid?>("UploadedBy")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Hash")
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
b.HasIndex("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("PreviewPath")
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("ThumbnailPath")
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("Type")
.HasDatabaseName("IX_Assets_Stats_Type")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "CreatedAt")
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("FolderId", "IngestedAt", "Id")
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AlbumName")
.HasColumnType("VARCHAR(255)");
b.Property<Guid>("AssetId")
.HasColumnType("uuid");
b.Property<string>("PersonName")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.HasKey("Id");
b.HasIndex("AssetId")
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
b.HasIndex("PersonName")
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
b.ToTable("AssetAlbumStaging");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("FolderId")
.HasColumnType("uuid");
b.Property<DateTime>("LastMtime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.HasIndex("FolderId", "Path")
.IsUnique()
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
b.ToTable("DirectoryScanStates");
});
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.Property<DateTime?>("LastDiscoveryScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastDiscoveryScannedRegex")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastFileScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(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 with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with 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 with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.Property<Guid>("PersonId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("PersonId", "UserId");
b.HasIndex("UserId");
b.ToTable("PersonMaintainers");
});
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.PrimitiveCollection<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<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
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.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany()
.HasForeignKey("AssetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Asset");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany()
.HasForeignKey("FolderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Folder");
});
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.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.HasOne("Lactose.Models.Person", "Person")
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("User");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
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("UploadedAssets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddAssetIngestedAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_FolderId_Id",
table: "Assets");
migrationBuilder.AddColumn<DateTime>(
name: "IngestedAt",
table: "Assets",
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now()");
// Backfill existing rows so an incremental discovery scan does not reprocess the whole library:
// for assets already in the database, ingestion time is taken to be their creation time.
migrationBuilder.Sql("""
UPDATE "Assets"
SET "IngestedAt" = "CreatedAt";
""");
migrationBuilder.CreateIndex(
name: "IX_Assets_FolderId_IngestedAt_Id",
table: "Assets",
columns: new[] { "FolderId", "IngestedAt", "Id" },
filter: "\"DeletedAt\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_FolderId_IngestedAt_Id",
table: "Assets");
migrationBuilder.DropColumn(
name: "IngestedAt",
table: "Assets");
migrationBuilder.CreateIndex(
name: "IX_Assets_FolderId_Id",
table: "Assets",
columns: new[] { "FolderId", "Id" },
filter: "\"DeletedAt\" IS NULL");
}
}
}
@@ -123,6 +123,9 @@ namespace Lactose.Migrations
.IsRequired()
.HasColumnType("bit(64)");
b.Property<DateTime>("IngestedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
@@ -208,10 +211,6 @@ namespace Lactose.Migrations
b.HasIndex("UploadedBy");
b.HasIndex("FolderId", "Id")
.HasDatabaseName("IX_Assets_FolderId_Id")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "CreatedAt")
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
@@ -220,6 +219,10 @@ namespace Lactose.Migrations
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("FolderId", "IngestedAt", "Id")
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
+8
View File
@@ -101,6 +101,14 @@ public class Asset {
/// </summary>
public DateTime? DeletedAt { get; set; }
/// <summary>
/// Gets or sets the timestamp when this asset was ingested into the system.
/// Unlike <see cref="CreatedAt"/> (the file's on-disk creation time), this reflects when the
/// asset was first inserted into the database, which is what incremental discovery scans use as their cursor.
/// </summary>
[Required]
public required DateTime IngestedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets the MIME type of the asset.
/// </summary>