Compare commits
3
Commits
82ab09adcb
...
9eb333093a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb333093a | ||
|
|
30ad404efd | ||
|
|
7823d9f4d4 |
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user