24 Commits

Author SHA1 Message Date
Samuele Lorefice
4c85fee004 Settings WIP 2025-09-09 22:01:55 +02:00
Samuele Lorefice
3039fdfd39 General Cleanup (and async/awaiting rules enforcing) 2025-09-09 19:32:19 +02:00
Samuele Lorefice
f2db2e5204 Added User Page 2025-09-09 18:49:20 +02:00
Samuele Lorefice
021e186611 Small Refactor 2025-09-09 17:30:33 +02:00
Samuele Lorefice
9d5e810cc6 Added missing settings and power slider. Broken state 2025-09-05 04:56:53 +02:00
REDCODE
be1e02bcf2 Fixed Race condition on Thumbnail job signaling.
Moved compose to use proper docker configs instead of normal development configs.
2025-09-05 00:48:18 +02:00
Samuele Lorefice
9821cb1c46 Some more failsafes 2025-09-04 20:40:22 +02:00
Samuele Lorefice
a0ec82f6b9 Tasks now fail when they fail. 2025-09-04 20:34:03 +02:00
Samuele Lorefice
6f54e7a6ba I am slowly losing sanity 2025-09-04 20:20:48 +02:00
Samuele Lorefice
c17fd84f0a Strictly enforces calling the correct methods to change the job status, ensuring events are fired correctly 2025-09-04 20:10:05 +02:00
Samuele Lorefice
02a938bba0 Refactoring for readability in thumb job 2025-09-04 19:53:21 +02:00
Samuele Lorefice
af9e3e25da WIP debug for Thumbnailjob 2025-09-04 19:50:52 +02:00
Samuele Lorefice
9ff4f976d6 Wasn't done with the time yet. 2025-09-04 19:25:23 +02:00
Samuele Lorefice
c771e1446c fix(frontend): Fixes all of the special HttpClients to not have been specialized due to a small typo in the program init.
fix(frontend): Fixes time being interpreted as local time instead of UTC time for job cards.
feat(frontend): Adds more job types buttons on the job page
2025-09-04 19:13:59 +02:00
Samuele Lorefice
b0406ac4c8 Moved to a compatible pgvector db variant, implemented Thumbnails job 2025-09-04 04:59:15 +02:00
Samuele Lorefice
a32e062d07 Enabled vector extensions 2025-09-04 02:40:29 +02:00
REDCODE
8ddd9c0dac PHash Job implementation 2025-09-03 23:37:56 +02:00
Samuele Lorefice
67dd365ffc Added libraries for image processing and hashing 2025-09-03 20:56:29 +02:00
Samuele Lorefice
bf176e0ce5 Front end work on Jobs page 2025-09-03 20:55:57 +02:00
REDCODE
565b888620 FrontEnd work (partial) 2025-09-03 03:45:03 +02:00
REDCODE
e371d73601 Added IntegrityCheck job 2025-09-03 01:02:20 +02:00
REDCODE
909d4d5e68 Leveled logging settings 2025-09-02 22:35:27 +02:00
Samuele Lorefice
bd8af5b28f Added scheduler as singleton to allow JobsController to use it 2025-09-02 21:09:56 +02:00
Samuele Lorefice
4986e401c4 Scaffolded Jobs controller basics 2025-09-02 20:40:16 +02:00
62 changed files with 3023 additions and 179 deletions

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ProjectTasksOptions"> <component name="ProjectTasksOptions">
<TaskOptions isEnabled="true"> <TaskOptions isEnabled="false">
<option name="arguments" value="$FileName$:$FileNameWithoutExtension$.css" /> <option name="arguments" value="$FileName$:$FileNameWithoutExtension$.css" />
<option name="checkSyntaxErrors" value="true" /> <option name="checkSyntaxErrors" value="true" />
<option name="description" /> <option name="description" />

View File

@@ -0,0 +1,29 @@
using Butter.Types;
namespace Butter.Dtos.Jobs;
public class JobStatusDto {
public Guid Id { get; init; }
public string Name { get; init; } = null!;
public EJobType JobType { get; init; }
public EJobStatus Status { get; init; }
public DateTime Created { get; init; }
public DateTime? Started { get; init; }
public DateTime? Finished { get; init; }
public string? Message { get; init; }
public float Progress { get; init; }
}
public enum EJobType {
FileSystemScan,
ThumbnailGeneration,
PreviewGeneration,
MetadataExtraction,
IntegrityCheck,
PHashGeneration
}
public class JobRequestDto {
public EJobType JobType { get; init; }
public List<string> Parameters { get; init; } = new();
}

View File

@@ -3,9 +3,12 @@ using Butter.Settings;
namespace Butter.Dtos.Settings; namespace Butter.Dtos.Settings;
public class SettingDto { public class SettingDto {
public required string Name { get; set; } = string.Empty; public required string Name { get; set; } = string.Empty;
public string? Value { get; set; } = string.Empty; public string? Value { get; set; } = string.Empty;
public required string? Description { get; set; } public required string? Description { get; set; }
public required EType Type { get; set; } public required EType Type { get; set; }
public required DisplayType DisplayType { get; set; } public required DisplayType DisplayType { get; set; }
// for range type settings min max and step are used.
//
public string[]? Options { get; set; }
} }

View File

@@ -8,7 +8,8 @@ public enum DisplayType {
Checkbox = 4, Checkbox = 4,
Switch = 5, Switch = 5,
DateTimePicker = 6, DateTimePicker = 6,
TimePicker = 7 TimePicker = 7,
Po2W = 8, // Power of 2
} }
public static class DisplayTypeExtension { public static class DisplayTypeExtension {

View File

@@ -6,6 +6,11 @@ public enum Settings {
FolderScanInterval, //Interval in minutes for folder scanning FolderScanInterval, //Interval in minutes for folder scanning
FileUploadEnabled, //Enable or disable file upload FileUploadEnabled, //Enable or disable file upload
FileUploadMaxSize, //Maximum file size for uploads in bytes FileUploadMaxSize, //Maximum file size for uploads in bytes
ThumbnailPath, //Path to store thumbnails
PreviewPath, //Path to store previews
ThumbnailSize, //Longest side size for thumbnails
PreviewSize, //Longest side size for previews
MaxConcurrentJobs //Maximum number of concurrent jobs
} }
public static class SettingsExtensions { public static class SettingsExtensions {
@@ -16,6 +21,11 @@ public static class SettingsExtensions {
Settings.FolderScanInterval => "Folder Scan Interval", Settings.FolderScanInterval => "Folder Scan Interval",
Settings.FileUploadEnabled => "File Upload Enabled", Settings.FileUploadEnabled => "File Upload Enabled",
Settings.FileUploadMaxSize => "File Upload MaxSize", Settings.FileUploadMaxSize => "File Upload MaxSize",
Settings.ThumbnailPath => "Thumbnail Path",
Settings.PreviewPath => "Preview Path",
Settings.ThumbnailSize => "Thumbnail Size",
Settings.PreviewSize => "Preview Size",
Settings.MaxConcurrentJobs => "Max Concurrent Jobs",
_ => setting.ToString() // Fallback to the enum name _ => setting.ToString() // Fallback to the enum name
}; };
} }

View File

@@ -1,4 +1,4 @@
namespace Lactose.Jobs; namespace Butter.Types;
public enum EJobStatus { public enum EJobStatus {
Queued, // Not yet started Queued, // Not yet started

View File

@@ -15,6 +15,7 @@ public class LactoseDbContext : DbContext {
public LactoseDbContext(DbContextOptions options) : base(options) {} public LactoseDbContext(DbContextOptions options) : base(options) {}
protected override void OnModelCreating(ModelBuilder modelBuilder) { protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.HasPostgresExtension("vector");
//Album Relationships //Album Relationships
modelBuilder.Entity<Album>().HasOne(e => e.UserOwner).WithMany(e => e.OwnedAlbums); modelBuilder.Entity<Album>().HasOne(e => e.UserOwner).WithMany(e => e.OwnedAlbums);
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums); modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);

View File

@@ -0,0 +1,97 @@
using Butter.Dtos.Jobs;
using Butter.Types;
using Lactose.Jobs;
using Lactose.Mapper;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Lactose.Controllers;
[ApiController]
[Authorize(Roles = "Admin")]
[Route("api/[controller]")]
public class JobsController(JobManager jobManager, JobScheduler jobScheduler, LactoseAuthService authService) : ControllerBase {
[HttpGet]
[Authorize(Roles = "Admin")]
public ActionResult<List<JobStatusDto>> GetAll() {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
return Ok(jobManager.Jobs.Select(j => j.ToJobStatusDto()).ToList());
}
[HttpGet("{jobId}")]
[Authorize(Roles = "Admin")]
public ActionResult<JobStatusDto> Get(Guid jobId) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var job = jobManager.GetJob(jobId);
if (job == null) { return NotFound(); }
return Ok(job.ToJobStatusDto());
}
[HttpDelete("{jobId}")]
[Authorize(Roles = "Admin")]
public ActionResult Cancel(Guid jobId) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var job = jobManager.GetJob(jobId);
if (job == null) { return NotFound(); }
job.Cancel();
return Ok();
}
[HttpPut]
[Authorize(Roles = "Admin")]
public ActionResult Enqueue([FromBody] JobRequestDto jobRequest) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
switch (jobRequest.JobType) {
case EJobType.FileSystemScan:
if (jobRequest.Parameters.Count != 0)
return BadRequest("FullFileSystemScan job does not require any parameters.");
jobScheduler.QueueFileSystemCrawl(null);
return Ok();
case EJobType.ThumbnailGeneration:
if (jobRequest.Parameters.Count != 0)
return BadRequest("ThumbnailGeneration job does not require any parameters.");
jobScheduler.QueueThumbnailGeneration(null);
return Ok();
case EJobType.PreviewGeneration:
if (jobRequest.Parameters.Count != 0)
return BadRequest("PreviewGeneration job does not require any parameters.");
jobScheduler.QueuePreviewGeneration(null);
return Ok();
case EJobType.MetadataExtraction:
if (jobRequest.Parameters.Count != 0)
return BadRequest("MetadataExtraction job does not require any parameters.");
jobScheduler.QueueMetadataExtraction(null);
return Ok();
case EJobType.IntegrityCheck:
if (jobRequest.Parameters.Count != 0)
return BadRequest("IntegrityCheck job does not require any parameters.");
jobScheduler.QueueIntegrityCheck(null);
return Ok();
case EJobType.PHashGeneration:
if (jobRequest.Parameters.Count > 1)
return BadRequest("PHashGeneration job takes zero or one parameter (the asset ID).");
jobScheduler.QueuePHashGeneration(jobRequest.Parameters.FirstOrDefault());
return Ok();
default: return BadRequest("Invalid job type.");
}
}
}

View File

@@ -1,37 +1,92 @@
[ [
{ {
"Name": "User Registration Enabled", "Name": "User Registration Enabled",
"Value" : "false", "Value": "false",
"Description": "Sets if user registration is enabled or not.", "Description": "Sets if user registration is enabled or not.",
"Type" : 2, "Type": 2,
"DisplayType" : 5 "DisplayType": 5
}, },
{ {
"Name": "Folder Scan Enabled", "Name": "Folder Scan Enabled",
"Value" : "true", "Value": "true",
"Description": "Sets if the folder scan service should be running or not.", "Description": "Sets if the folder scan service should be running or not.",
"Type" : 2, "Type": 2,
"DisplayType" : 5 "DisplayType": 5
}, },
{ {
"Name": "Folder Scan Interval", "Name": "Folder Scan Interval",
"Value" : "", "Value": "",
"Description": "Time interval after the previous scan has finished before a new folder scan is started.", "Description": "Time interval after the previous scan has finished before a new folder scan is started.",
"Type" : 4, "Type": 4,
"DisplayType" : 7 "DisplayType": 7,
"Options": [
"5",
"360",
"5"
]
}, },
{ {
"Name": "File Upload Enabled", "Name": "File Upload Enabled",
"Value" : "false", "Value": "false",
"Description": "NOT IMPLEMENTED YET: Sets if the file upload service should be running or not.", "Description": "NOT IMPLEMENTED YET: Sets if the file upload service should be running or not.",
"Type" : 2, "Type": 2,
"DisplayType" : 5 "DisplayType": 5
}, },
{ {
"Name": "File Upload MaxSize", "Name": "File Upload MaxSize",
"Value" : "0", "Value": "0",
"Description": "Max file size for uploads in bytes.", "Description": "Max file size for uploads in bytes.",
"Type" : 1, "Type": 1,
"DisplayType" :2 "DisplayType": 2
},
{
"Name": "Thumbnail Path",
"Value": "",
"Description": "Path where thumbnails are stored.",
"Type": 0,
"DisplayType": 0
},
{
"Name": "Preview Path",
"Value": "",
"Description": "Path where thumbnails are stored.",
"Type": 0,
"DisplayType": 0
},
{
"Name": "Thumbnail Size",
"Value": "512",
"Description": "Size for longest side of the thumbnail in pixels.",
"Type": 1,
"DisplayType": 8,
"Options": [
"8",
"12",
"1"
]
},
{
"Name": "Preview Size",
"Value": "2048",
"Description": "Size for longest side of the preview in pixels.",
"Type": 1,
"DisplayType": 8,
"Options": [
"8",
"12",
"1"
]
},
{
"Name": "Max Concurrent Jobs",
"Value": "4",
"Description": "Max number of concurrent jobs",
"Type": 1,
"DisplayType": 2,
"Options": [
"1",
"32",
"1"
]
} }
] ]

View File

@@ -2,14 +2,15 @@ using Butter;
using Butter.Types; using Butter.Types;
using Lactose.Models; using Lactose.Models;
using Lactose.Repositories; using Lactose.Repositories;
using System.Collections;
using System.Data; using System.Data;
using static System.String; using static System.String;
namespace Lactose.Jobs; namespace Lactose.Jobs;
public class FileSystemCrawlJob : Job { public sealed class FileSystemCrawlJob : Job {
public override string Name { get; } = "Filesystem Crawl Job"; public override string Name { get; } = "Directory Scan: ";
public override JobStatus Status { get; } public override JobStatus JobStatus { get; }
IAssetRepository assetRepository; IAssetRepository assetRepository;
string workingPath; string workingPath;
ILogger<FileSystemCrawlJob> logger; ILogger<FileSystemCrawlJob> logger;
@@ -22,15 +23,16 @@ public class FileSystemCrawlJob : Job {
IAssetRepository assetRepository, IAssetRepository assetRepository,
JobManager jobManager JobManager jobManager
) { ) {
Status = new(this); JobStatus = new(this);
this.logger = logger; this.logger = logger;
this.assetRepository = assetRepository; this.assetRepository = assetRepository;
this.workingPath = workingPath; this.workingPath = workingPath;
this.jobManager = jobManager; this.jobManager = jobManager;
Name += $"{workingPath}";
} }
///<inheritdoc /> ///<inheritdoc />
protected override void TaskJob(CancellationToken token) { protected override async Task TaskJob(CancellationToken token) {
logger.LogInformation($"Started crawling directory {workingPath}"); logger.LogInformation($"Started crawling directory {workingPath}");
DirectoryInfo dir = new(workingPath); DirectoryInfo dir = new(workingPath);
@@ -54,7 +56,10 @@ public class FileSystemCrawlJob : Job {
foreach (var folder in folders) { foreach (var folder in folders) {
if (token.IsCancellationRequested) { if (token.IsCancellationRequested) {
Status.Cancel(); // Cancel all child jobs
childJobs.ForEach(j => j.Cancel());
// Report this job as canceled
JobStatus.Cancel();
return; return;
} }
@@ -65,17 +70,20 @@ public class FileSystemCrawlJob : Job {
childJobs.Add(job); childJobs.Add(job);
jobManager.EnqueueJob(job); jobManager.EnqueueJob(job);
logger.LogDebug($"Enqueued crawl job for folder {folder.FullName}"); logger.LogDebug($"Enqueued crawl job for folder {folder.FullName}");
Status.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing folder {folder.FullName}"); JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing folder {folder.FullName}");
} }
foreach (var file in files) { foreach (var file in files) {
if (token.IsCancellationRequested) { if (token.IsCancellationRequested) {
Status.Cancel(); // Cancel all child jobs
childJobs.ForEach(j => j.Cancel());
// Report this job as canceled
JobStatus.Cancel();
return; return;
} }
steps++; steps++;
Status.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing file {file.FullName}"); JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing file {file.FullName}");
if (assetRepository.FindByPath(file.FullName) != null) continue; if (assetRepository.FindByPath(file.FullName) != null) continue;
@@ -94,19 +102,19 @@ public class FileSystemCrawlJob : Job {
} }
// Wait for all child jobs to complete // Wait for all child jobs to complete
Status.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Waiting for {childJobs.Count} child jobs to complete."); JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Waiting for {childJobs.Count} child jobs to complete.");
Status.Status = EJobStatus.Waiting; JobStatus.Wait();
while (childJobs.Count > 0) { while (childJobs.Count > 0) {
if (token.IsCancellationRequested) { if (token.IsCancellationRequested) {
// Cancel all child jobs // Cancel all child jobs
childJobs.ForEach(j => j.Cancel()); childJobs.ForEach(j => j.Cancel());
// Cancel this job as well // Cancel this job as well
Status.Cancel(); JobStatus.Cancel();
return; return;
} }
} }
Status.Complete("Crawl completed successfully."); JobStatus.Complete("Crawl completed successfully.");
} }
Asset? AssetFromPath(string filePath) { Asset? AssetFromPath(string filePath) {
@@ -141,7 +149,7 @@ public class FileSystemCrawlJob : Job {
_ => throw new ArgumentOutOfRangeException() _ => throw new ArgumentOutOfRangeException()
}, },
FileSize = finfo.Length, FileSize = finfo.Length,
Hash = [] Hash = new BitArray(64)
}; };
logger.LogDebug( logger.LogDebug(

View File

@@ -0,0 +1,188 @@
using Butter.Settings;
using Lactose.Context;
using Lactose.Models;
using Lactose.Utils;
namespace Lactose.Jobs;
public class IntegrityCheckJob (ILogger<IntegrityCheckJob> logger, IServiceProvider serviceProvider): Job{
const float totalSteps = 8;
public override string Name => "Integrity Check Job";
void IncrementProgress(float f) => JobStatus.UpdateProgress(f/totalSteps, "Database connection successful.");
protected override async Task TaskJob(CancellationToken token) {
float progress = 0;
logger.LogInformation("Starting integrity check job...");
var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
//Step 1
// Check database connection
if (!dbContext.Database.CanConnect()) {
logger.LogError("Cannot connect to the database.");
JobStatus.Fail("Cannot connect to the database.");
return;
}
IncrementProgress(++progress);
// Step 2
// Check for pending migrations
var pendingMigrations = dbContext.Database.GetPendingMigrations().ToList();
if (pendingMigrations.Any()) {
logger.LogWarning("There are pending migrations:");
foreach (var migration in pendingMigrations) logger.LogWarning($"- {migration}");
// Apply migrations
try {
dbContext.Database.Migrate();
logger.LogInformation("Applied pending migrations successfully.");
} catch (Exception ex) {
logger.LogError(ex, "Error applying migrations.");
JobStatus.Fail("Error applying migrations.");
return;
}
}
IncrementProgress(++progress);
// A bit of context: Load() will load all entities from the table into the DbContext.
// Essentially it will ask the DB for all rows in the table and create entities for them in the DbContext.
// This will basically warm up the cache of the DbContext so that when we later access all the rows in the table,
// it won't lazy load them one by one, one call at the time.
// This is important because lazy loading one by one would be a huge performance hit,
// especially if there are many rows in the table.
// While for folders it might not be a big deal, this is just for consistency with all the other steps,
// especially the asset one.
// Step 3
// Check for folders existence
dbContext.Folders.Load();
foreach (Folder folder in dbContext.Folders.ToList()) {
if (!Directory.Exists(folder.BasePath)) {
logger.LogWarning($"Folder {folder.Id} does not exist.");
folder.Active = false;
dbContext.Folders.Update(folder);
}
var affectedAssets = dbContext.Assets.Where(a => a.FolderId == folder.Id);
dbContext.Assets.RemoveRange(affectedAssets);
dbContext.SaveChanges();
logger.LogWarning("Removed {Count} assets associated with folder {FolderId}.", affectedAssets.Count(), folder.Id);
}
IncrementProgress(++progress);
// Step 4
// Check for assets existence
dbContext.Assets.Load();
foreach (Asset asset in dbContext.Assets) {
if (File.Exists(asset.OriginalPath)) continue;
dbContext.Assets.Remove(asset);
logger.LogWarning($"Asset {asset.Id} does not exist. Removing from database.");
}
int changes = dbContext.SaveChanges();
logger.LogInformation("Removed {Changes} invalid entries from the database.", changes);
IncrementProgress(++progress);
// Step 5
// Check for empty albums
dbContext.Albums.Load();
dbContext.Albums.Include(a => a.Assets)
.Where(a => a.Assets!.Count == 0)
.ToList().ForEach(a => {
logger.LogWarning($"Album {a.Id} is empty. Removing from database.");
dbContext.Albums.Remove(a);
});
changes = dbContext.SaveChanges();
logger.LogInformation("Removed {Changes} empty albums from the database.", changes);
IncrementProgress(++progress);
// Step 6
// Check for Persons without faces
dbContext.People.Include(p => p.Faces)
.Where(p => p.Faces != null && p.Faces.Count == 0)
.AsEnumerable()
.ForEach(p => {
logger.LogWarning($"Person {p.Id} has no faces. Removing from database.");
dbContext.People.Remove(p);
});
changes = dbContext.SaveChanges();
logger.LogInformation("Removed {Changes} people without faces from the database.", changes);
IncrementProgress(++progress);
// Step 7
// Check for orphaned thumbnails
dbContext.Settings.Load();
dbContext.Assets.Load();
var thumbnailPath = dbContext.Settings.First(s => s.Name == Settings.ThumbnailPath.AsString()).Value;
if (!Directory.Exists(thumbnailPath)) {
logger.LogWarning("Thumbnail path does not exist. Creating...");
try {
if (thumbnailPath != null)
Directory.CreateDirectory(thumbnailPath);
} catch (Exception e) {
logger.LogError(e, "Could not create thumbnail directory.");
}
logger.LogWarning("Marking all assets as missing thumbnails.");
dbContext.Assets.ForEach(a => a.ThumbnailPath = "");
changes = dbContext.SaveChanges();
logger.LogInformation("Marked {Changes} assets as missing thumbnails.", changes);
} else {
dbContext.Assets.Where(a => !String.IsNullOrEmpty(a.ThumbnailPath)).AsEnumerable()
.Where(a => !File.Exists(a.ThumbnailPath)).AsEnumerable()
.ForEach(a => {
logger.LogWarning($"Asset {a.Id} has a missing thumbnail. Marking as missing.");
a.ThumbnailPath = "";
dbContext.Assets.Update(a);
});
changes = dbContext.SaveChanges();
logger.LogInformation("Marked {Changes} assets as missing thumbnails.", changes);
}
IncrementProgress(++progress);
// Step 8
// Check for orphaned previews
var previewPath = dbContext.Settings.First(s => s.Name == Settings.PreviewPath.AsString()).Value;
if (!Directory.Exists(previewPath)) {
logger.LogWarning("Preview path does not exist. Creating...");
try {
if (previewPath != null)
Directory.CreateDirectory(previewPath);
} catch (Exception e) {
logger.LogError(e, "Could not create preview directory.");
}
logger.LogWarning("Marking all assets as missing previews.");
dbContext.Assets.ForEach(a => a.PreviewPath = "");
changes = dbContext.SaveChanges();
logger.LogInformation("Marked {Changes} assets as missing previews.", changes);
} else {
dbContext.Assets.Where(a => !String.IsNullOrEmpty(a.PreviewPath)).AsEnumerable()
.Where(a => !File.Exists(a.PreviewPath)).AsEnumerable()
.ForEach(a => {
logger.LogWarning($"Asset {a.Id} has a missing preview. Marking as missing.");
a.PreviewPath = "";
dbContext.Assets.Update(a);
});
}
changes = dbContext.SaveChanges();
logger.LogInformation("Marked {Changes} assets as missing previews.", changes);
IncrementProgress(++progress);
// Complete
logger.LogInformation("Integrity check completed successfully.");
JobStatus.Complete("Integrity check completed successfully.");
}
}

View File

@@ -1,3 +1,5 @@
using Butter.Types;
namespace Lactose.Jobs; namespace Lactose.Jobs;
/// <summary> /// <summary>
@@ -8,33 +10,40 @@ namespace Lactose.Jobs;
public abstract class Job { public abstract class Job {
public virtual Guid Id { get; } = Guid.NewGuid(); public virtual Guid Id { get; } = Guid.NewGuid();
public virtual string Name { get; } = "Unnamed Job"; public virtual string Name { get; } = "Unnamed Job";
public virtual JobStatus Status { get; } public virtual JobStatus JobStatus { get; }
Task? task; Task? task;
CancellationTokenSource? cts; CancellationTokenSource? cts;
protected Job() => Status = new JobStatus(this); protected Job() => JobStatus = new JobStatus(this);
/// <summary> /// <summary>
/// Sets up the task and the cancellation token, then starts the task (triggering the Started event) /// Sets up the task and the cancellation token, then starts the task (triggering the Started event)
/// Also sets up a continuation to handle completion, failure, or cancellation of the task. /// Also sets up a continuation to handle completion, failure, or cancellation of the task.
/// </summary> /// </summary>
public virtual void Start() { public virtual void Start() {
Status.Start(); JobStatus.Start();
cts = new CancellationTokenSource(); cts = new CancellationTokenSource();
task = Task.Run(() => TaskJob(cts.Token)); task = Task.Run(() => TaskJob(cts.Token));
task.ContinueWith(t => { task.ContinueWith(t => {
if (t.IsCanceled) { switch (JobStatus.Status) {
Status.Cancel("Job was canceled"); case EJobStatus.Canceled:
Canceled?.Invoke(this, Status); Canceled?.Invoke(this, JobStatus);
} else if (t.IsFaulted) { break;
Status.Fail(t.Exception?.Message ?? "Job failed with an unknown error"); case EJobStatus.Failed:
Failed?.Invoke(this, Status); Failed?.Invoke(this, JobStatus);
} else { break;
Status.Complete("Job completed successfully"); case EJobStatus.Running: // If still running or waiting, mark as completed
Completed?.Invoke(this, Status); case EJobStatus.Waiting: //somebody forgot to update the status and the method returned.
case EJobStatus.Completed:
Completed?.Invoke(this, JobStatus);
break;
case EJobStatus.Queued: // This should never happen
default:
Completed?.Invoke(this, JobStatus);
break;
} }
Done?.Invoke(this, Status); Done?.Invoke(this, JobStatus);
} }
); );
@@ -46,7 +55,7 @@ public abstract class Job {
/// Invoke ProgressChanged event to report progress updates. /// Invoke ProgressChanged event to report progress updates.
/// </summary> /// </summary>
/// <param name="token">Cancellation toke to retrieve when a task cancellation has been requested</param> /// <param name="token">Cancellation toke to retrieve when a task cancellation has been requested</param>
protected abstract void TaskJob(CancellationToken token); protected abstract Task TaskJob(CancellationToken token);
/// <summary> /// <summary>
/// Cancels the job if it's running. Canceled event will be called by the callbacks on the task. /// Cancels the job if it's running. Canceled event will be called by the callbacks on the task.

View File

@@ -1,3 +1,4 @@
using Butter.Types;
using Lactose.Utils; using Lactose.Utils;
using System.Collections.Concurrent; using System.Collections.Concurrent;
@@ -14,7 +15,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
/// <summary> /// <summary>
/// Gets or sets the maximum number of jobs that can run concurrently. /// Gets or sets the maximum number of jobs that can run concurrently.
/// </summary> /// </summary>
public int MaxConcurrentJobs { get; set; } = 8; public int MaxConcurrentJobs { get; set; } = 4;
/// <summary> /// <summary>
/// Adds a job to the manager. /// Adds a job to the manager.
@@ -45,7 +46,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
public void RemoveJob(Guid id) { public void RemoveJob(Guid id) {
if (!jobs.ContainsKey(id)) throw new InvalidOperationException($"Job with ID {id} doesn't exist"); if (!jobs.ContainsKey(id)) throw new InvalidOperationException($"Job with ID {id} doesn't exist");
switch (jobs[id].Status.Status) { switch (jobs[id].JobStatus.Status) {
case EJobStatus.Running: case EJobStatus.Running:
case EJobStatus.Waiting: case EJobStatus.Waiting:
// Schedule job removal from the dictionary once it's canceled // Schedule job removal from the dictionary once it's canceled
@@ -68,8 +69,9 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
/// <summary> /// <summary>
/// Injects required dependencies and creates a new instance of the specified job type. /// Injects required dependencies and creates a new instance of the specified job type.
/// </summary> /// </summary>
public Job CreateJob<T>() where T : Job { public T CreateJob<T>() where T : Job {
var job = ActivatorUtilities.CreateInstance<T>(serviceProvider); var scope = serviceProvider.CreateScope();
var job = ActivatorUtilities.CreateInstance<T>(scope.ServiceProvider);
logger.LogInformation($"Job with ID {job.Id} has been created."); logger.LogInformation($"Job with ID {job.Id} has been created.");
return job; return job;
} }
@@ -77,7 +79,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
/// <summary> /// <summary>
/// Injects required dependencies and creates a new instance of the specified job type, passing the provided arguments to its constructor. /// Injects required dependencies and creates a new instance of the specified job type, passing the provided arguments to its constructor.
/// </summary> /// </summary>
public Job CreateJob<T>(params object[] args) where T : Job { public T CreateJob<T>(params object[] args) where T : Job {
var scope = serviceProvider.CreateScope(); var scope = serviceProvider.CreateScope();
var job = ActivatorUtilities.CreateInstance<T>(scope.ServiceProvider, args); var job = ActivatorUtilities.CreateInstance<T>(scope.ServiceProvider, args);
logger.LogInformation($"Job with ID {job.Id} has been created."); logger.LogInformation($"Job with ID {job.Id} has been created.");
@@ -94,15 +96,18 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
var service = new Task(() => { var service = new Task(() => {
while (!stoppingToken.IsCancellationRequested) { while (!stoppingToken.IsCancellationRequested) {
// Skip adding more jobs if we reached the max concurrent jobs limit // Skip adding more jobs if we reached the max concurrent jobs limit
int activeRunningJobs = jobs.Count(job => job.Value.Status.Status == EJobStatus.Running); int activeRunningJobs = jobs.Count(job => job.Value.JobStatus.Status == EJobStatus.Running);
if (activeRunningJobs >= MaxConcurrentJobs) continue; if (activeRunningJobs >= MaxConcurrentJobs) continue;
//Take jobs from the jobs list that are queued and start them until we reach the max concurrent jobs limit //Take jobs from the jobs list that are queued and start them until we reach the max concurrent jobs limit
jobs.Where(job => job.Value.Status.Status == EJobStatus.Queued) jobs.Where(job => job.Value.JobStatus.Status == EJobStatus.Queued)
.Select(job => job.Value) .Select(job => job.Value)
.Take(MaxConcurrentJobs - activeRunningJobs) .Take(MaxConcurrentJobs - activeRunningJobs)
.ForEach(job => { .ForEach(job => {
job.Done += (sender, status) => jobs.Remove(status.Id, out _); job.Done += (sender, status) => {
Task.Delay(10000).Wait();
jobs.Remove(status.Id, out _);
};
job.Start(); job.Start();
logger.LogInformation($"Job {job.Id} : {job.Name} has been started."); logger.LogInformation($"Job {job.Id} : {job.Name} has been started.");
} }

View File

@@ -1,16 +1,18 @@
using Butter.Types;
namespace Lactose.Jobs; namespace Lactose.Jobs;
public class JobStatus(Job job) { public class JobStatus(Job job) {
public Guid Id => job.Id; public Guid Id => job.Id;
public string Name => job.Name; public string Name => job.Name;
public EJobStatus Status { get; set; } = EJobStatus.Queued; public EJobStatus Status { get; private set; } = EJobStatus.Queued;
public DateTime Created { get; private set; } = DateTime.UtcNow; public DateTime Created { get; private set; } = DateTime.UtcNow;
public DateTime? Started { get; private set; } public DateTime? Started { get; private set; }
public DateTime? Finished { get; private set; } public DateTime? Finished { get; private set; }
public string? Message { get; private set; } public string? Message { get; private set; }
public float Progress { get; private set; } = 0; public float Progress { get; private set; } = 0;
public override string ToString() => $"{Name} - {Status} - {Progress * 100}%"; public override string ToString() => $"{Name} - {Status} - {Progress*100}%";
public void Start(string message = "Job started") { public void Start(string message = "Job started") {
Status = EJobStatus.Running; Status = EJobStatus.Running;
@@ -19,6 +21,11 @@ public class JobStatus(Job job) {
Message = message; Message = message;
} }
public void Wait(string message = "Job waiting") {
Status = EJobStatus.Waiting;
Message = message;
}
public void UpdateProgress(float progress, string? message = null) { public void UpdateProgress(float progress, string? message = null) {
if (progress < 0 || progress > 1) if (progress < 0 || progress > 1)
throw new ArgumentOutOfRangeException(nameof(progress), "Progress must be between 0 and 1"); throw new ArgumentOutOfRangeException(nameof(progress), "Progress must be between 0 and 1");

122
Lactose/Jobs/PHashJob.cs Normal file
View File

@@ -0,0 +1,122 @@
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;
using CoenM.ImageHash.HashAlgorithms;
using Lactose.Utils;
using SixLabors.ImageSharp.PixelFormats;
using System.Collections;
namespace Lactose.Jobs;
public sealed class PHashJob : Job {
private readonly PHashJob? ParentJob;
private readonly Asset? asset;
private readonly IAssetRepository assetRepository;
private readonly JobManager? jobManager;
private int processedAssets = 0;
private int totalAssets = 1; // Avoid division by zero
private List<PHashJob> subJobs = [];
private ILogger<PHashJob> Logger { get; }
public override string Name { get; }
public PHashJob(ILogger<PHashJob> logger, IAssetRepository assetRepository, JobManager jobManager) : base() {
Name = "Image Hashing";
Logger = logger;
this.assetRepository = assetRepository;
this.jobManager = jobManager;
}
// For the sub jobs
public PHashJob(PHashJob parentJob, Asset asset, ILogger<PHashJob> logger, IAssetRepository assetRepository) : base() {
ParentJob = parentJob;
Name = "Perceptual Hash Calculation";
Logger = logger;
this.asset = asset;
this.assetRepository = assetRepository;
}
protected override async Task TaskJob(CancellationToken token) {
if (ParentJob == null) {
JobStatus.Start();
Logger.LogInformation("Starting master PHash job for all assets missing pHash");
var MissingPHashAssets = assetRepository.GetAssetsMissingPHash(out totalAssets);
Logger.LogInformation($"Found {totalAssets} assets missing pHash.");
foreach (var missingPHashAsset in MissingPHashAssets) {
token.ThrowIfCancellationRequested();
// Create a sub-job for each asset
if (jobManager == null) {
Logger.LogError("JobManager is not available. Cannot create sub-jobs.");
subJobs.ForEach(j => j.Cancel());
JobStatus.Fail();
return;
}
var job = jobManager.CreateJob<PHashJob>(this, missingPHashAsset);
job.Done += (_, _) => IncrementProcessedAssets(); // Increment processed count when sub-job is done
subJobs.Add((PHashJob)job);
jobManager.EnqueueJob(job);
}
JobStatus.Wait("Waiting for sub-jobs to complete");
// Wait for all sub-jobs to complete
while (processedAssets < totalAssets) {
if (!token.IsCancellationRequested) continue;
subJobs.ForEach(j => j.Cancel());
JobStatus.Cancel("Cancellation requested from user.");
return;
}
} else {
// This is a sub-job
if (asset == null) {
Logger.LogError("Sub-job started without an asset. Job failed.");
JobStatus.Fail("Sub-job started without an asset.");
return;
}
JobStatus.Start();
Logger.LogDebug($"Calculating pHash for asset {asset.Id} | {asset.OriginalPath}");
try {
PerceptualHash pHash = new();
using var image = Image.Load<Rgba32>(asset.OriginalPath);
asset.Hash = pHash.Hash(image).ToBitArray();
} catch (ArgumentNullException ex) {
Logger.LogError(ex, $"Image at path {asset.OriginalPath} could not be found. Setting hash to 0.");
asset.Hash = new BitArray(64);
JobStatus.Fail(ex.Message);
} catch (UnknownImageFormatException ex) {
Logger.LogError(
ex,
$"Image at path {asset.OriginalPath} is in an unknown or unsupported format. Setting hash to 0."
);
asset.Hash = new BitArray(64);
JobStatus.Fail(ex.Message);
} catch (InvalidImageContentException ex) {
Logger.LogError(ex, $"Image at path {asset.OriginalPath} is corrupted or unreadable. Setting hash to 0.");
asset.Hash = new BitArray(64);
JobStatus.Fail(ex.Message);
} catch (Exception ex) {
Logger.LogError(
ex,
$"Failed to calculate pHash for asset {asset.Id} | {asset.OriginalPath}. Setting hash to 0."
);
asset.Hash = new BitArray(64);
JobStatus.Fail(ex.Message);
}
assetRepository.Update(asset);
assetRepository.Save();
var msg = $"Completed pHash calculation for asset {asset.Id} | {asset.OriginalPath}";
Logger.LogInformation(msg);
JobStatus.Complete(msg);
}
}
void IncrementProcessedAssets() {
processedAssets++;
JobStatus.UpdateProgress((float)processedAssets / totalAssets);
}
}

View File

@@ -0,0 +1,200 @@
using Butter.Settings;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
namespace Lactose.Jobs;
public class ThumbnailJob : Job {
ThumbnailJob? ParentJob;
List<ThumbnailJob>? subJobs;
Asset? Asset;
string? ThumbnailPath;
int processedAssets = 0;
int totalAssets = 1; // Avoid division by zero
int ThumbnailSize;
ILogger<ThumbnailJob> Logger;
ISettingsRepository SettingsRepository;
IAssetRepository AssetRepository;
JobManager? JobManager;
public override string Name { get; }
public ThumbnailJob(
ILogger<ThumbnailJob> logger,
ISettingsRepository settingsRepository,
IAssetRepository assetRepository,
JobManager jobManager
) {
Logger = logger;
SettingsRepository = settingsRepository;
AssetRepository = assetRepository;
JobManager = jobManager;
Name = "Thumbnail Generation Job";
}
public ThumbnailJob(
ThumbnailJob parentJob,
Asset asset,
int thumbnailSize,
string thumbnailPath,
ILogger<ThumbnailJob> logger,
ISettingsRepository settingsRepository,
IAssetRepository assetRepository
) {
Logger = logger;
SettingsRepository = settingsRepository;
AssetRepository = assetRepository;
Asset = asset;
Name = $"Thumbnail Gen for {Asset?.OriginalPath}";
ParentJob = parentJob;
ThumbnailSize = thumbnailSize;
ThumbnailPath = thumbnailPath;
}
protected override async Task TaskJob(CancellationToken token) {
if (ParentJob == null) MasterJob(token);
else SlaveJob(token);
}
void SlaveJob(CancellationToken token) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
}
if (Asset == null) {
Logger.LogError("Sub-job started without an asset. Canceling job.");
JobStatus.Fail("Sub-job started without an asset. Canceling job.");
return;
}
JobStatus.Start();
try {
if (string.IsNullOrEmpty(Asset.OriginalPath) || !File.Exists(Asset.OriginalPath)) {
var msg = $"Original file for asset ID {Asset.Id} not found at path {Asset.OriginalPath}";
Logger.LogWarning(msg);
JobStatus.Fail(msg);
return;
}
using var image = Image.Load(Asset.OriginalPath);
image.Mutate(x => x.Resize(
new ResizeOptions {
Size = new Size(ThumbnailSize),
Mode = ResizeMode.Max,
Sampler = KnownResamplers.CatmullRom
}
)
);
if (ThumbnailPath == null) {
var msg = "Thumbnail path is not set. Cannot save thumbnail.";
Logger.LogError(msg);
JobStatus.Fail(msg);
return;
}
var path = PathFromGuid(Asset.Id, ThumbnailPath);
if (!Directory.Exists(path)) Directory.CreateDirectory(Path.GetDirectoryName(path)!);
image.SaveAsJpeg(
path,
new JpegEncoder() {
ColorType = JpegEncodingColor.Rgb,
Quality = 75,
SkipMetadata = true
}
);
Asset.ThumbnailPath = path;
AssetRepository.Update(Asset);
AssetRepository.Save();
} catch (Exception ex) {
Logger.LogError(ex, $"Failed to generate thumbnail for asset ID {Asset.Id}");
JobStatus.Fail($"Error: {ex.Message}");
return;
}
Logger.LogInformation($"Thumbnail generated for asset ID {Asset.Id} at {Asset.ThumbnailPath}");
JobStatus.Complete($"Thumbnail generated for asset ID {Asset.Id} at {Asset.ThumbnailPath}");
}
void MasterJob(CancellationToken token) {
if (JobManager == null) {
Logger.LogError("JobManager is not available. Cannot create sub-jobs.");
JobStatus.Fail("JobManager is not available. Cannot create sub-jobs.");
return;
}
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) {
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.");
subJobs = new List<ThumbnailJob>();
foreach (var asset in missingThumbnail) {
if (token.IsCancellationRequested) {
lock (subJobs) subJobs.ForEach(j => j.Cancel());
JobStatus.Cancel("Cancellation requested from user.");
return;
}
// Create a sub-job for each asset
var job = JobManager.CreateJob<ThumbnailJob>(this, asset, 256, thumbPath.Value!);
job.Done += (o, _) => {
IncrementProcessedAssets();
lock (subJobs) subJobs.Remove((o as ThumbnailJob)!);
};
lock (subJobs) subJobs.Add(job);
JobManager.EnqueueJob(job);
}
JobStatus.Wait();
// Wait for all sub-jobs to complete
while (subJobs.Count > 0) {
if (token.IsCancellationRequested) {
lock (subJobs) subJobs.ForEach(j => j.Cancel());
JobStatus.Cancel("User requested cancellation.");
return;
}
}
JobStatus.Complete("All thumbnails generated successfully.");
}
void IncrementProcessedAssets() {
processedAssets++;
JobStatus.UpdateProgress((float)processedAssets / totalAssets, $"Waiting for {subJobs.Count} sub-jobs to complete.");
}
static string PathFromGuid(Guid id, string root) {
var s = id.ToString("N"); // 32 chars, no dashes
return Path.Combine(
root,
s[..2], // 3F
s.Substring(2, 2), // 25
s.Substring(4, 2), // 04
$"{s}.jpg"
);
}
}

View File

@@ -10,6 +10,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.15" /> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.15" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.15" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.15" />
@@ -22,6 +23,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,25 @@
using Butter.Dtos.Jobs;
using Lactose.Jobs;
namespace Lactose.Mapper;
public static class JobMapper {
public static JobStatusDto ToJobStatusDto(this Job job) => new JobStatusDto() {
Id = job.Id,
Name = job.Name,
Status = job.JobStatus.Status,
Progress = job.JobStatus.Progress,
Message = job.JobStatus.Message,
Created = job.JobStatus.Created,
Started = job.JobStatus.Started,
Finished = job.JobStatus.Finished,
JobType = job switch {
FileSystemCrawlJob => EJobType.FileSystemScan,
ThumbnailJob => EJobType.ThumbnailGeneration,
IntegrityCheckJob => EJobType.IntegrityCheck,
PHashJob => EJobType.PHashGeneration,
_ => throw new ArgumentOutOfRangeException(nameof(job))
}
};
}

View File

@@ -17,7 +17,8 @@ public static class SettingsMapper {
Value = setting.Value, Value = setting.Value,
Description = setting.Description, Description = setting.Description,
Type = setting.Type, Type = setting.Type,
DisplayType = setting.DisplayType DisplayType = setting.DisplayType,
Options = setting.Options
}; };
/// <summary> /// <summary>
@@ -29,6 +30,7 @@ public static class SettingsMapper {
Value = settingDto.Value, Value = settingDto.Value,
Description = settingDto.Description, Description = settingDto.Description,
Type = settingDto.Type, Type = settingDto.Type,
DisplayType = settingDto.DisplayType DisplayType = settingDto.DisplayType,
Options = settingDto.Options
}; };
} }

View File

@@ -0,0 +1,465 @@
// <auto-generated />
using System;
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("20250903205921_ULongHash")]
partial class ULongHash
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
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<decimal>("Hash")
.HasColumnType("numeric(20,0)");
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>("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.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<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
}
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class ULongHash : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Hash",
table: "Assets");
migrationBuilder.AddColumn<decimal>(
name: "Hash",
table: "Assets",
type: "numeric(20,0)",
nullable: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Hash",
table: "Assets");
migrationBuilder.AddColumn<byte[]>(
name: "Hash",
table: "Assets",
type: "BYTEA",
nullable: false);
}
}
}

View File

@@ -0,0 +1,468 @@
// <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("20250904003848_VectorExtensions")]
partial class VectorExtensions
{
/// <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>("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.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<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
}
}
}

View File

@@ -0,0 +1,43 @@
using System.Collections;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class VectorExtensions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:vector", ",,");
migrationBuilder.DropColumn(
name: "Hash",
table: "Assets");
migrationBuilder.AddColumn<BitArray>(
name: "Hash",
table: "Assets",
type: "bit(64)",
nullable: false,
defaultValue: new BitArray(64));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
migrationBuilder.DropColumn("Hash", "Assets");
migrationBuilder.AddColumn<decimal>(
name: "Hash",
table: "Assets",
type: "numeric(20,0)",
nullable: false);
}
}
}

View File

@@ -0,0 +1,471 @@
// <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("20250905024004_AddedSettingsOptions")]
partial class AddedSettingsOptions
{
/// <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, "vectors");
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>("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.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
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddedSettingsOptions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string[]>(
name: "Options",
table: "Settings",
type: "text[]",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Options",
table: "Settings");
}
}
}

View File

@@ -1,5 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using System.Collections;
using Lactose.Context; using Lactose.Context;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -20,6 +21,7 @@ namespace Lactose.Migrations
.HasAnnotation("ProductVersion", "8.0.15") .HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vectors");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b => modelBuilder.Entity("AlbumAsset", b =>
@@ -108,9 +110,9 @@ namespace Lactose.Migrations
b.Property<float?>("FrameRate") b.Property<float?>("FrameRate")
.HasColumnType("real"); .HasColumnType("real");
b.Property<byte[]>("Hash") b.Property<BitArray>("Hash")
.IsRequired() .IsRequired()
.HasColumnType("BYTEA"); .HasColumnType("bit(64)");
b.Property<bool>("IsPubliclyShared") b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -256,6 +258,9 @@ namespace Lactose.Migrations
b.Property<int>("DisplayType") b.Property<int>("DisplayType")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type") b.Property<int>("Type")
.HasColumnType("integer"); .HasColumnType("integer");

View File

@@ -1,4 +1,5 @@
using Butter.Types; using Butter.Types;
using System.Collections;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
@@ -108,8 +109,8 @@ public class Asset {
/// <summary> /// <summary>
/// Computed hash of the asset. /// Computed hash of the asset.
/// </summary> /// </summary>
[Required][Column(TypeName = "BYTEA")] [Required][Column(TypeName = "bit(64)")]
public byte[] Hash { get; set; } = []; public BitArray Hash { get; set; }
/// <summary> /// <summary>
/// Gets or Sets which folder the asset is in. /// Gets or Sets which folder the asset is in.

View File

@@ -16,4 +16,6 @@ public class Setting {
public required EType Type { get; set; } public required EType Type { get; set; }
public required DisplayType DisplayType { get; set; } public required DisplayType DisplayType { get; set; }
public string[]? Options { get; set; }
} }

View File

@@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Npgsql; using Npgsql;
using Pgvector.EntityFrameworkCore;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -48,7 +49,7 @@ builder.Services.AddDbContext<LactoseDbContext>(
#endif #endif
;*/ ;*/
options.UseNpgsql(strBuilder.ConnectionString) options.UseNpgsql(strBuilder.ConnectionString, o => o.UseVector())
#if DEBUG #if DEBUG
.LogTo(Console.WriteLine) .LogTo(Console.WriteLine)
.EnableSensitiveDataLogging() .EnableSensitiveDataLogging()
@@ -76,6 +77,7 @@ builder.Services.AddTransient<IMediaRepository, MediaRepository>();
builder.Services.AddTransient<ITokenService, TokenService>(); builder.Services.AddTransient<ITokenService, TokenService>();
builder.Services.AddTransient<LactoseAuthService>(); builder.Services.AddTransient<LactoseAuthService>();
builder.Services.AddSingleton<JobManager>(); builder.Services.AddSingleton<JobManager>();
builder.Services.AddSingleton<JobScheduler>();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddControllers(); builder.Services.AddControllers();
@@ -153,7 +155,7 @@ builder.Services
//Add the job manager service //Add the job manager service
builder.Services.AddHostedService<JobManager>(p => p.GetRequiredService<JobManager>()); builder.Services.AddHostedService<JobManager>(p => p.GetRequiredService<JobManager>());
builder.Services.AddHostedService<JobScheduler>(); builder.Services.AddHostedService<JobScheduler>(p => p.GetRequiredService<JobScheduler>());
WebApplication app = builder.Build(); WebApplication app = builder.Build();
using (var scope = app.Services.CreateScope()) { using (var scope = app.Services.CreateScope()) {

View File

@@ -1,5 +1,7 @@
using Lactose.Context; using Lactose.Context;
using Lactose.Models; using Lactose.Models;
using Pgvector.EntityFrameworkCore;
using System.Collections;
using System.Data; using System.Data;
namespace Lactose.Repositories; namespace Lactose.Repositories;
@@ -43,5 +45,33 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
public Asset? FindByPath(string path) => context.Assets.FirstOrDefault(a => a.OriginalPath == path); public Asset? FindByPath(string path) => context.Assets.FirstOrDefault(a => a.OriginalPath == path);
public IEnumerable<Asset> GetAssetsMissingPHash(out int count) {
var empty = new BitArray(64);
count = context.Assets.Count(a => a.Hash == empty);
return context.Assets.Where(a => a.Hash == empty);
}
public IEnumerable<List<Asset>> GetDuplicates() => context.Assets
.GroupBy(a => new { a.Hash })
.Where(g => g.Count() > 1)
.Select(g => g.ToList());
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, int distance) {
if(distance < 0 || distance > 63)
throw new ArgumentOutOfRangeException(nameof(distance), "Distance must be between 0 and 64.");
return context.Assets
.OrderBy(a => a.HammingDistance(phash))
.Where(a => a.Hash.HammingDistance(phash) <= distance);
}
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, float distance)
=> GetWithinHammingDistance(phash, (int)(distance * 64));
public IEnumerable<Asset> GetAssetsMissingThumbnail(out int count) {
count = context.Assets.Count(a => a.ThumbnailPath == null || a.ThumbnailPath == "");
return context.Assets.Where(a => a.ThumbnailPath == null || a.ThumbnailPath == "");
}
public void Dispose() => context.Dispose(); public void Dispose() => context.Dispose();
} }

View File

@@ -1,4 +1,5 @@
using Lactose.Models; using Lactose.Models;
using System.Collections;
namespace Lactose.Repositories; namespace Lactose.Repositories;
@@ -71,4 +72,13 @@ public interface IAssetRepository : IDisposable {
/// <param name="filePath">The file path of the asset.</param> /// <param name="filePath">The file path of the asset.</param>
/// <returns>Null if not found, otherwise the requested asset.</returns> /// <returns>Null if not found, otherwise the requested asset.</returns>
Asset? FindByPath(string filePath); Asset? FindByPath(string filePath);
/// <summary>
/// Finds all assets that are missing a perceptual hash (pHash).
/// </summary>
IEnumerable<Asset> GetAssetsMissingPHash(out int count);
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, float distance);
IEnumerable<Asset> GetAssetsMissingThumbnail(out int totalAssets);
} }

View File

@@ -13,7 +13,7 @@ interface IDbInitializer {
void Initialize(); void Initialize();
} }
class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHasher) : IDbInitializer { internal class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHasher) : IDbInitializer {
public void Initialize() { public void Initialize() {
ArgumentNullException.ThrowIfNull(dbContext, nameof(dbContext)); ArgumentNullException.ThrowIfNull(dbContext, nameof(dbContext));
@@ -52,9 +52,21 @@ class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHa
if (settings is not null) { if (settings is not null) {
//remove all settings that are not in the default settings file //remove all settings that are not in the default settings file
var existingSettings = dbContext.Settings.ToList(); var existingSettings = dbContext.Settings.ToList();
foreach (Setting existingSetting in existingSettings
.Where(existingSetting => settings.All(s => s.Name != existingSetting.Name))) foreach (Setting existingSetting in existingSettings.Where(existingSetting
dbContext.Settings.Remove(existingSetting); => settings.All(s => s.Name != existingSetting.Name)
)) dbContext.Settings.Remove(existingSetting);
settings.First(s => s.Name == Settings.ThumbnailPath.AsString()).Value
= Path.Combine(Environment.CurrentDirectory, "thumbnails");
settings.First(s => s.Name == Settings.PreviewPath.AsString()).Value
= Path.Combine(Environment.CurrentDirectory, "previews");
// set max concurrent jobs to number of processors - 2, at least one. (who the fuck is using a single core cpu?)
settings.First(s => s.Name == Settings.MaxConcurrentJobs.AsString()).Value
= (Math.Max(Environment.ProcessorCount - 2, 1)).ToString();
// add or update settings from the default settings file leaving value unchanged // add or update settings from the default settings file leaving value unchanged
foreach (var setting in settings) { foreach (var setting in settings) {
if (!dbContext.Settings.Any(s => s.Name == setting.Name)) { dbContext.Settings.Add(setting); } else { if (!dbContext.Settings.Any(s => s.Name == setting.Name)) { dbContext.Settings.Add(setting); } else {
@@ -62,9 +74,11 @@ class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHa
dbSetting.Description = setting.Description; dbSetting.Description = setting.Description;
dbSetting.Type = setting.Type; dbSetting.Type = setting.Type;
dbSetting.DisplayType = setting.DisplayType; dbSetting.DisplayType = setting.DisplayType;
dbSetting.Options = setting.Options;
dbContext.Settings.Update(dbSetting); dbContext.Settings.Update(dbSetting);
} }
} }
dbContext.SaveChanges(); dbContext.SaveChanges();
} //else, we are a bit fucked. } //else, we are a bit fucked.
@@ -84,5 +98,6 @@ class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHa
dbContext.SaveChanges(); dbContext.SaveChanges();
#endregion #endregion
} }
} }

View File

@@ -1,4 +1,5 @@
using Butter.Settings; using Butter.Settings;
using Butter.Types;
using Lactose.Jobs; using Lactose.Jobs;
using Lactose.Models; using Lactose.Models;
using Lactose.Repositories; using Lactose.Repositories;
@@ -31,14 +32,6 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
return service; return service;
} }
void QueueFileSystemCrawl(object? state) {
// don't queue if there's already a crawl job in progress
if(jobManager.Jobs.Any(j => j is FileSystemCrawlJob)) return;
// queue a crawl job for each active folder
folders?.ForEach(f => jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.BasePath)));
logger?.LogInformation("Queued file system crawl jobs.");
}
void FetchSettings() { void FetchSettings() {
logger?.LogInformation("Fetching settings..."); logger?.LogInformation("Fetching settings...");
var scope = serviceProvider.CreateScope(); var scope = serviceProvider.CreateScope();
@@ -89,4 +82,69 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
break; break;
} }
} }
internal void QueueFileSystemCrawl(object? state) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue file system crawl.");
return;
}
// don't queue if there's already a crawl job in progress
if (jobManager.Jobs.Any(j => j is FileSystemCrawlJob && j.JobStatus.Status is EJobStatus.Running or EJobStatus.Queued)) {
logger?.LogWarning("A file system crawl job is already in progress, cannot queue another at this moment.");
return;
}
// queue a crawl job for each active folder
folders?.ForEach(f => { jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.BasePath)); });
logger?.LogInformation("Queued file system crawl jobs.");
}
internal void QueueThumbnailGeneration(object? o) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue thumbnail generation.");
return;
}
// don't queue if there's already a thumbnail job in progress
if (jobManager.Jobs.Any(j => j is ThumbnailJob && j.JobStatus.Status is EJobStatus.Running or EJobStatus.Queued)) {
logger?.LogInformation("Thumbnail job is already in progress, cannot queue another at this moment.");
return;
}
jobManager.EnqueueJob(jobManager.CreateJob<ThumbnailJob>());
logger?.LogInformation("Queued thumbnail job.");
}
internal void QueuePreviewGeneration(object? o) {
throw new NotImplementedException();
}
internal void QueueMetadataExtraction(object? o) {
throw new NotImplementedException();
}
internal void QueueIntegrityCheck(object? o) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue integrity check.");
return;
}
// don't queue if there's already an integrity check job in progress
if (jobManager.Jobs.Any(j => j is IntegrityCheckJob && j.JobStatus.Status is EJobStatus.Running or EJobStatus.Queued)) {
logger?.LogInformation("Integrity check job is already in progress, cannot queue another at this moment.");
return;
}
jobManager.EnqueueJob(jobManager.CreateJob<IntegrityCheckJob>());
logger?.LogInformation("Queued integrity check job.");
}
internal void QueuePHashGeneration(string? firstOrDefault) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue pHash generation.");
return;
}
// don't queue if there's already a pHash job in progress
if (jobManager.Jobs.Any(j => j is PHashJob && j.JobStatus.Status is EJobStatus.Running or EJobStatus.Queued)) {
logger?.LogInformation("pHash job is already in progress, cannot queue another at this moment.");
return;
}
jobManager.EnqueueJob(jobManager.CreateJob<PHashJob>());
logger?.LogInformation("Queued pHash job.");
}
} }

View File

@@ -1,10 +1,25 @@
using System.Collections;
using System.Runtime.CompilerServices;
namespace Lactose.Utils; namespace Lactose.Utils;
public static class EnumerableExtensions { public static class EnumerableExtensions {
public static void ForEach<TSource>(this IEnumerable<TSource> source, Action<TSource> action) { public static void ForEach<TSource>(this IEnumerable<TSource> source, Action<TSource> action) {
foreach (var item in source) { foreach (var item in source) {
action(item); action(item);
} }
} }
} }
public static class HashExtensions {
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static BitArray ToBitArray(this ulong hash) => new BitArray(BitConverter.GetBytes(hash));
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ulong ToUlong(this BitArray bits) {
if (bits.Length > 64) throw new ArgumentException("BitArray length must be at most 64 bits.");
var array = new byte[8];
bits.CopyTo(array, 0);
return BitConverter.ToUInt64(array, 0);
}
}

View File

@@ -1,10 +1,10 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Debug", "Default": "Warning",
"Microsoft.AspNetCore": "Information", "Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning",
"Lactose.Services" : "Trace" "Lactose.Services" : "Information"
} }
}, },
"SignKey": { "SignKey": {
@@ -12,6 +12,6 @@
}, },
"DatabaseAddress": { "DatabaseAddress": {
"Host": "database", "Host": "database",
"Port": 5432 "Port": 3306
} }
} }

View File

@@ -1,15 +1,14 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Debug", "Default": "Warning",
"System": "Warning", "Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning", "Lactose.Services" : "Trace"
"Lactose.Services" : "Trace" }
}
}, },
"DatabaseAddress": { "DatabaseAddress": {
"Host": "database", "Host": "database",
"Port": 3306 "Port": 5432
} }
} }

View File

@@ -1,8 +1,10 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Warning",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"Lactose": "Information"
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",

View File

@@ -21,6 +21,7 @@
<body> <body>
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" /> <Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<script src="lib/js/bootstrap.bundle.min.js"></script> <script src="lib/js/bootstrap.bundle.min.js"></script>
@* ReSharper disable once Html.PathError *@
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
</body> </body>

View File

@@ -6,7 +6,7 @@
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body align-items-center"> <div class="card-body align-items-center">
<div class="d-flex flex-row flex-grow-1 align-items-center"> <div class="d-flex flex-row flex-grow-1 align-items-center">
<i class="bi bi-folder2 ms-3 mx-1"></i><p class="mx-1 mb-0 @Display">@(Folder.BasePath ?? "Unknown Folder")</p> <i class="bi bi-folder2 ms-3 mx-1"></i><p class="mx-1 mb-0 @Display">@Folder.BasePath</p>
<input class="form-control form-control-sm mx-1 @Edit" type="text" @bind="Folder.BasePath"/> <input class="form-control form-control-sm mx-1 @Edit" type="text" @bind="Folder.BasePath"/>
<button class="btn btn-sm btn-success @Edit" @onclick="OnConfirmEdit"><i class="bi bi-check"></i></button> <button class="btn btn-sm btn-success @Edit" @onclick="OnConfirmEdit"><i class="bi bi-check"></i></button>
<div class="ms-auto form-switch"> <div class="ms-auto form-switch">
@@ -19,7 +19,7 @@
</div> </div>
@code { @code {
bool editing = false; bool editing;
string Display => editing ? "d-none" : ""; string Display => editing ? "d-none" : "";
string Edit => editing ? "" : "d-none"; string Edit => editing ? "" : "d-none";

View File

@@ -0,0 +1,86 @@
@using Butter.Dtos.Jobs
@using Butter.Types
@using MilkStream.Services
@inject JobsService JobsService
<div class="card m-2 @BorderColor h-100">
<div class="card-header h-100">
@switch (Job.JobType) {
case EJobType.FileSystemScan: <i class="me-2 bi bi-folder2-open"></i> break;
case EJobType.ThumbnailGeneration: <i class="me-2 bi bi-pip"></i> break;
case EJobType.PreviewGeneration: <i class="me-2 bi bi-file-image"></i> break;
case EJobType.MetadataExtraction: <i class="me-2 bi bi-file-earmark-break"></i> break;
case EJobType.IntegrityCheck: <i class="me-2 bi bi-file-earmark-check"></i> break;
}
@Job.Name <span class="badge @TextColor">@Job.Status</span>
</div>
<div class="card-body">
<div class="progress mb-2" role="progressbar">
<div class="@BgColor" style="width: @(Job.Progress*100)%">@(Job.Progress*100)%</div>
</div>
<p class="card-text"><i class="me-2 bi bi-stopwatch"></i>
@switch (Job.Status) {
case EJobStatus.Queued:
@Job.Created.ToLocalTime().ToString("g")
<p class="card-text"><i class="me-2 bi bi-hourglass-split"></i>@(DateTime.Now - Job.Created.ToLocalTime())</p>
break;
case EJobStatus.Running:
case EJobStatus.Waiting:
@Job.Started?.ToLocalTime().ToString("g")
<p class="card-text"><i class="me-2 bi bi-hourglass-split"></i>@(DateTime.Now - Job.Started?.ToLocalTime())</p>
break;
case EJobStatus.Completed:
case EJobStatus.Canceled:
case EJobStatus.Failed:
@Job.Finished?.ToLocalTime().ToString("g")
<p class="card-text"><i class="me-2 bi bi-hourglass-split"></i>@(Job.Finished?.ToLocalTime() - Job.Started?.ToLocalTime())</p>
break;
}
</p>
<button class="btn btn-sm btn-danger" @onclick="Cancel"><i class="bi bi-trash"></i></button>
<div class="accordion accordion-flush" id="@Job.Id">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#flush-@Job.Id" aria-expanded="false" aria-controls="flush-collapseOne">
Message:
</button>
</h2>
<div id="flush-@Job.Id" class="accordion-collapse collapse" data-bs-parent="#@Job.Id">
@Job.Message
</div>
</div>
</div>
</div>
</div>
@code {
[Parameter] public required JobStatusDto Job { get; set; }
string BorderColor => Job.Status switch {
EJobStatus.Queued => "border-secondary",
EJobStatus.Running => "border-primary",
EJobStatus.Waiting => "border-info",
EJobStatus.Completed => "border-success",
EJobStatus.Failed => "border-danger",
_ => "border-dark",
};
string TextColor => Job.Status switch {
EJobStatus.Queued => "text-bg-secondary",
EJobStatus.Running => "text-bg-primary",
EJobStatus.Waiting => "text-bg-info",
EJobStatus.Completed => "text-bg-success",
EJobStatus.Failed => "text-bg-danger",
_ => "text-bg-dark",
};
string BgColor => Job.Status switch {
EJobStatus.Queued => "progress-bar text-bg-secondary",
EJobStatus.Running => "progress-bar text-bg-primary",
EJobStatus.Waiting => "progress-bar text-bg-info",
EJobStatus.Completed => "progress-bar text-bg-success",
EJobStatus.Failed => "progress-bar text-bg-danger",
_ => "progress-bar text-bg-dark",
};
public async Task Cancel() => await JobsService.CancelJob(Job);
}

View File

@@ -2,7 +2,7 @@
<NavMenu/> <NavMenu/>
<main class="container-xxl d-flex flex-grow-1"> <main class="container-xxl d-flex flex-grow-1">
<div class="d-flex flex-column border border-1 border-opacity-50 border-secondary-subtle rounded-3 m-lg-5 m-1 p-lg-5 p-1 flex-grow-1"> <div class="d-flex flex-column border border-1 border-opacity-50 border-secondary-subtle rounded-3 mt-4 m-lg-2 m-1 p-lg-4 p-2 flex-grow-1">
@Body @Body
</div> </div>
</main> </main>
@@ -10,7 +10,6 @@
<span class="text-primary m-auto">Footer (duh!)</span> <span class="text-primary m-auto">Footer (duh!)</span>
</footer> </footer>
<div id="blazor-error-ui"> <div id="blazor-error-ui">
An unhandled error has occurred. An unhandled error has occurred.
<a href="" class="reload">Reload</a> <a href="" class="reload">Reload</a>

View File

@@ -2,10 +2,10 @@
@using Butter.Types @using Butter.Types
@inherits LayoutComponentBase @inherits LayoutComponentBase
@inject NavigationManager navigation @inject NavigationManager Navigation
@inject UserService userService @inject UserService UserService
@inject LoginService loginService @inject LoginService LoginService
@inject ProtectedLocalStorage localStorage @inject ProtectedLocalStorage LocalStorage
<div class="navbar navbar-expand-sm bg-dark-subtle"> <div class="navbar navbar-expand-sm bg-dark-subtle">
<div class="container-xl"> <div class="container-xl">
@@ -47,18 +47,25 @@
<i class="bi bi-search-heart"></i> <i class="bi bi-search-heart"></i>
</button> </button>
</div> </div>
<div class="mx-2">@loginService.LoggedUser?.Username</div> <div class="mx-2">@LoginService.LoggedUser?.Username</div>
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-lg btn-outline-light" type="button"> <button class="btn btn-outline-light" type="button">
<i class="bi bi-person-circle"></i> <i class="bi bi-person-circle"></i>
</button> </button>
<button class="btn btn-lg btn-outline-light dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"></button> <button class="btn btn-outline-light dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"></button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#"><i class="bi bi-person-lines-fill"></i> Profile</a></li> <li><a class="dropdown-item" @onclick="OnProfileClick"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
@if(Admin) { @if (Admin) {
<li><a class="dropdown-item" @onclick="OnSettingsClick"><i class="bi bi-gear-wide-connected"></i> Settings</a></li> <li><a class="dropdown-item" @onclick="OnSettingsClick"><i
class="bi bi-gear-wide-connected"></i> Settings</a></li>
<li><a class="dropdown-item" @onclick="OnSettings1Click"><i
class="bi bi-gear-wide-connected"></i> Settings 1</a></li>
<li><a class="dropdown-item" @onclick="OnJobsClick"><i class="bi bi-kanban-fill"></i>
Jobs</a></li>
} }
<li><a class="dropdown-item" @onclick="OnLogout"><i class="bi bi-door-closed"></i> Logout</a></li> <li><a class="dropdown-item" @onclick="OnLogout"><i class="bi bi-door-closed"></i>
Logout</a></li>
</ul> </ul>
</div> </div>
} }
@@ -68,15 +75,15 @@
</div> </div>
@code{ @code{
bool LoggedIn => loginService.IsLoggedIn; bool LoggedIn => LoginService.IsLoggedIn;
bool Admin => loginService.LoggedUser?.AccessLevel == EAccessLevel.Admin; bool Admin => LoginService.LoggedUser?.AccessLevel == EAccessLevel.Admin;
bool isConnected; bool isConnected;
bool needsUpdate; bool needsUpdate;
protected override void OnInitialized() { protected async override Task OnInitializedAsync() {
base.OnInitialized(); LoginService.LoggedUserChanged += (_, _) => needsUpdate = true;
loginService.LoggedUserChanged += (sender, dto) => needsUpdate = true; await base.OnInitializedAsync();
LoadStateAsync().ConfigureAwait(false); //await LoadStateAsync();
} }
protected async override Task OnAfterRenderAsync(bool firstRender) { protected async override Task OnAfterRenderAsync(bool firstRender) {
@@ -89,27 +96,34 @@
} }
private async Task LoadStateAsync() { private async Task LoadStateAsync() {
var auth = await localStorage.GetAsync<AuthInfo>("auth"); var auth = await LocalStorage.GetAsync<AuthInfo>("auth");
if (auth.Success == false) return; if (auth.Success == false) return;
loginService.AuthInfo = auth.Value;
loginService.LoggedUser = await userService.GetUserAsync();//.ConfigureAwait(false); LoginService.AuthInfo = auth.Value;
LoginService.LoggedUser = await UserService.GetUserAsync(); //.ConfigureAwait(false);
} }
async Task OnLoginClick() { async Task OnLoginClick() {
loginService.LoggedUser = await userService.GetUserAsync(); LoginService.LoggedUser = await UserService.GetUserAsync();
if (loginService.LoggedUser != null) needsUpdate = true; if (LoginService.LoggedUser != null) needsUpdate = true;
else navigation.NavigateTo("/login"); else Navigation.NavigateTo("/login");
} }
void OnRegisterClick() => navigation.NavigateTo("/register"); void OnRegisterClick() => Navigation.NavigateTo("/register");
void OnSettingsClick() => navigation.NavigateTo("/settings"); void OnSettingsClick() => Navigation.NavigateTo("/settings");
void OnJobsClick() => Navigation.NavigateTo("/jobs");
async Task OnLogout() { async Task OnLogout() {
_ = loginService.Logout(); _ = LoginService.Logout();
await localStorage.DeleteAsync("auth"); await LocalStorage.DeleteAsync("auth");
navigation.NavigateTo("/", true); Navigation.NavigateTo("/", true);
} }
void OnSettings1Click() => Navigation.NavigateTo("/Settings1");
void OnProfileClick() => Navigation.NavigateTo($"/User/{LoginService.AuthInfo?.UserId}", true);
} }

View File

@@ -1,12 +1,11 @@
@page "/" @page "/"
@using Butter.Dtos; @using Butter.Dtos;
@using MilkStream.Components.Layout
@using MilkStream.Services @using MilkStream.Services
@inject NavigationManager navigationManager @inject NavigationManager NavigationManager
@inject LoginService loginService @inject LoginService LoginService
@inject ProtectedSessionStorage sessionStorage @inject ProtectedSessionStorage SessionStorage
@inject ProtectedLocalStorage localStorage @inject ProtectedLocalStorage LocalStorage
<PageTitle>Home</PageTitle> <PageTitle>Home</PageTitle>
@@ -17,7 +16,7 @@
@if (mediaList.Count == 0) { @if (mediaList.Count == 0) {
<div class="border-warning border-5 border-opacity-100 rounded-3 p-3 m-5 bg-warning-subtle text-center"> <div class="border-warning border-5 border-opacity-100 rounded-3 p-3 m-5 bg-warning-subtle text-center">
<h2 class="text-warning">No media available</h2> <h2 class="text-warning">No media available</h2>
@if (loginService.IsLoggedIn) { @if (LoginService.IsLoggedIn) {
<p class="text-warning-emphasis">You are logged in, but there is no media available at the moment.</p> <p class="text-warning-emphasis">You are logged in, but there is no media available at the moment.</p>
} else { } else {
<p class="text-warning-emphasis">Please <a href="/login" class="link-warning">log-in</a> to be able to browse any media.</p> <p class="text-warning-emphasis">Please <a href="/login" class="link-warning">log-in</a> to be able to browse any media.</p>
@@ -41,7 +40,7 @@
protected async override Task OnAfterRenderAsync(bool firstRender) { protected async override Task OnAfterRenderAsync(bool firstRender) {
if (firstRender) { if (firstRender) {
loginService.LoggedUserChanged += (sender, dto) => StateHasChanged(); LoginService.LoggedUserChanged += (_, _) => StateHasChanged();
//do nothing for now //do nothing for now
await Task.Delay(1); await Task.Delay(1);
isLoading = false; isLoading = false;

View File

@@ -0,0 +1,54 @@
@page "/Jobs"
@using Butter.Dtos.Jobs
@using MilkStream.Services
@inject JobsService JobsService
@inject LoginService LoginService
<h3>Jobs</h3>
<div class="d-flex gap-2 mb-3">
<button class="btn btn-primary" @onclick="LaunchScan">Scan Files</button>
<button class="btn btn-primary" @onclick="LaunchThumb">Make Thumbs</button>
<button class="btn btn-primary" @onclick="LaunchMeta">Scan Metadata</button>
<button class="btn btn-warning" @onclick="LaunchIntegrityCheck">Integrity check</button>
</div>
<div class="row row-cols-1 row-cols-sm-1 row-cols-md-2 row-cols-lg-3 g-1 g-md-2">
@foreach (var job in jobs.OrderBy(j => j.Created)) {
<div class="col"><JobCard Job="job"/></div>
}
</div>
@code {
readonly PeriodicTimer timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
List<JobStatusDto> jobs = [];
protected async override Task OnInitializedAsync() {
LoginService.LoggedUserChanged += async (_, _) => {
jobs = await JobsService.GetJobs();
await RefreshJobs();
StateHasChanged();
};
if (LoginService.IsLoggedIn) {
jobs = await JobsService.GetJobs();
await RefreshJobs();
}
StateHasChanged();
}
async Task RefreshJobs() {
while (await timer.WaitForNextTickAsync()) {
jobs = await JobsService.GetJobs();
StateHasChanged();
}
}
Task LaunchScan() => JobsService.StartJob(EJobType.FileSystemScan);
Task LaunchThumb() => JobsService.StartJob(EJobType.ThumbnailGeneration);
Task LaunchIntegrityCheck() => JobsService.StartJob(EJobType.IntegrityCheck);
Task LaunchMeta() => JobsService.StartJob(EJobType.MetadataExtraction);
}

View File

@@ -19,7 +19,7 @@
switch (option.DisplayType) { switch (option.DisplayType) {
// TODO: should implement text and password at some point // TODO: should implement text and password at some point
case DisplayType.Text: case DisplayType.Text:
<input type="text" id="@option.Name" class="form-control" @bind="@option.Value"/> <SettingString Setting="option"/>
break; break;
case DisplayType.Password: case DisplayType.Password:
<input type="password" id="@option.Name" class="form-control" @bind="@option.Value"/> <input type="password" id="@option.Name" class="form-control" @bind="@option.Value"/>
@@ -37,7 +37,10 @@
<div class="">DATETIME NOT IMPLEMENTED Value: @option.Value</div> <div class="">DATETIME NOT IMPLEMENTED Value: @option.Value</div>
break; break;
case DisplayType.TimePicker: case DisplayType.TimePicker:
<SettingRange Setting="option"/> <SettingRange Setting="option" MinValue="Convert.ToInt32(option.Options?[0])" MaxValue="Convert.ToInt32(option.Options?[1])" Step="Convert.ToInt32(option.Options?[2])"/>
break;
case DisplayType.Po2W:
<SettingRange Setting="option" IsPowerOfTwo="true" MinValue="Convert.ToInt32(option.Options?[0])" MaxValue="Convert.ToInt32(option.Options?[1])" Step="Convert.ToInt32(option.Options?[2])"/>
break; break;
} }
} }

View File

@@ -0,0 +1,94 @@
@page "/Settings1"
@using Butter.Dtos.Folder
@using Butter.Dtos.Settings
@using Butter.Settings
@using Butter.Types
@using MilkStream.Components.SettingBoxes
@using MilkStream.Services
@using System.Linq
@inject NavigationManager NavigationManager
@inject LoginService LoginService
@inject SettingsService SettingsService
@inject FoldersService FoldersService
<h3>Settings</h3>
@if (areSettingsLoading || settings is {Count: 0}) {
<LoadSpinner/>
} else {
<div class="row row-cols-2">
<div class="col">
<!--Folder Scan Enabled-->
<SettingSwitch Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.FolderScanEnabled.AsString())"/>
</div>
<div class="col">
<!--Folder Scan Interval-->
<SettingRange Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.FolderScanInterval.AsString())" MinValue="5" MaxValue="360" Step="5"/>
</div>
<div class="col">
<!--File Upload Enabled-->
<SettingSwitch Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.FileUploadEnabled.AsString())"/>
</div>
<div class="col">
<!--File Upload Max Size-->
<SettingRange Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.FileUploadMaxSize.AsString())" MinValue="1024" MaxValue="1000000" Step="1000"/>
</div>
<div class="col">
<!--Thumbnail Path-->
<SettingString Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.ThumbnailPath.AsString())"/>
</div>
<div class="col">
<!--Thumbnail Size-->
<SettingRange Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.ThumbnailSize.AsString())" IsPowerOfTwo="true" MinValue="8" MaxValue="12" Step="1"/>
</div>
<div class="col">
<!--Previews Path-->
<SettingString Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.PreviewPath.AsString())"/>
</div>
<div class="col">
<!--Previews Size-->
<SettingRange Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.PreviewSize.AsString())" IsPowerOfTwo="true" MinValue="8" MaxValue="14" Step="1"/>
</div>
<div class="col">
<!--Max concurrent jobs -->
<SettingRange Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.MaxConcurrentJobs.AsString())" MinValue="1" MaxValue="32" Step="1"/>
</div>
<div class="col">
<!-- User registration enabled -->
<SettingSwitch Setting="settings!.FirstOrDefault(s => s.Name == Butter.Settings.Settings.UserRegistrationEnabled.AsString())"/>
</div>
</div>
<div>
<button class="btn btn-outline-primary my-2" @onclick="SaveSettings"><i class="bi bi-floppy2 mx-1"></i>Save Changes</button>
</div>
}
<h3>Folders</h3>
@code {
bool areSettingsLoading = true;
List<SettingDto>? settings = new();
protected async override Task OnInitializedAsync() {
if (LoginService.IsLoggedIn) {
await LoadSettings();
} else {
LoginService.AuthInfoChanged += async (_, _) => {
await LoadSettings();
};
}
}
async Task LoadSettings() {
settings = await SettingsService.GetAllSettings();
if(settings != null) areSettingsLoading = false;
StateHasChanged();
}
void SaveSettings() {
if (settings != null) {
SettingsService.UpdateSettings(settings);
}
}
}

View File

@@ -0,0 +1,62 @@
@page "/User/{UserId:guid}"
@using Butter.Dtos.User
@using Butter.Types
@using MilkStream.Services
@inject LoginService LoginService
@inject UserService UserService
<h3>@(user?.Username ?? "Username")</h3>
<div class="row">
@if (isSelf || AdminAccess) {
<div class="col-2"><i class="bi bi-envelope-at"></i> EMail:</div>
<div class="col-2">@user?.Email</div>
}
</div>
<div class="row">
<div class="col-2"><i class="bi bi-person-fill-lock"></i> Role:</div>
<div class="col-2">@user?.AccessLevel.ToString()</div>
</div>
<div class="row">
<div class="col-2"><i class="bi bi-calendar-date"></i> Creation Date:</div>
<div class="col-2">@user?.CreatedAt</div>
</div>
<div class="row">
<div class="col-2"><i class="bi bi-door-open"></i> Last Login:</div>
<div class="col-2">@(user?.LastLogin.ToString() ?? "Never")</div>
</div>
<div class="row">
<div class="col-2"><i class="bi bi-ban"></i>Banned:</div>
<div class="col-2">@(user is {IsBanned: true} ? $"Yes ({user.BannedAt.ToString()})" : "No")</div>
</div>
<div class="row">
@if (AdminAccess) {
<div class="col-2"><i class="bi bi-trash3"></i>Deleted:</div>
<div class="col-2">@(user is not {DeletedAt: null} ? user!.DeletedAt.ToString() : "No")</div>
}
</div>
@code {
[Parameter]
public Guid UserId { get; set; }
bool isSelf;
bool AdminAccess => LoginService.LoggedUser?.AccessLevel == EAccessLevel.Admin;
UserInfoDto? user;
protected async override Task OnInitializedAsync() {
await base.OnInitializedAsync();
if (LoginService.IsLoggedIn) await LoadUser();
LoginService.AuthInfoChanged += async (_, _) => await LoadUser();
}
async Task LoadUser() {
if (UserId == LoginService.AuthInfo?.UserId) isSelf = true;
user = await UserService.GetUserAsync();
StateHasChanged();
}
}

View File

@@ -5,13 +5,13 @@
@code { @code {
[Parameter] [Parameter]
public required SettingDto Setting { get; set; } public required SettingDto? Setting { get; set; }
protected virtual SettingDto GetSetting() { protected virtual SettingDto? GetSetting() {
return Setting; return Setting;
} }
protected override void OnInitialized() { protected override void OnInitialized() {
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting()); SettingsService.BeginSave += (_, _) => SettingsService.UpdateSetting(GetSetting());
} }
} }

View File

@@ -19,15 +19,16 @@
private float currentValue; private float currentValue;
protected override void OnInitialized() { protected override void OnInitialized() {
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting()); SettingsService.BeginSave += (_, _) => SettingsService.UpdateSetting(GetSetting());
if (float.TryParse(Setting.Value, out float value)) { if (float.TryParse(Setting?.Value, out float value)) {
currentValue = value; currentValue = value;
} else { } else {
currentValue = 0; // Default value if parsing fails currentValue = 0; // Default value if parsing fails
} }
} }
protected override SettingDto GetSetting() { protected override SettingDto? GetSetting() {
if (Setting == null) return null;
Setting.Value = currentValue.ToString(CultureInfo.InvariantCulture); Setting.Value = currentValue.ToString(CultureInfo.InvariantCulture);
return Setting; return Setting;
} }

View File

@@ -8,24 +8,37 @@
<div class="card-body d-flex justify-content-between align-items-center"> <div class="card-body d-flex justify-content-between align-items-center">
<p class="card-text">@(Setting?.Description ?? "No description provided")</p> <p class="card-text">@(Setting?.Description ?? "No description provided")</p>
<div class="d-flex"> <div class="d-flex">
<div class="mx-2">@currentValue</div> <div class="mx-2">@(IsPowerOfTwo ? Math.Pow(2, currentValue) : currentValue)</div>
<input type="range" id="@Setting?.Name" class="form-range" @bind="@currentValue"/> <input type="range" id="@Setting?.Name" class="form-range" @bind="@currentValue" min="@MinValue" max="@MaxValue"/>
</div> </div>
</div> </div>
</div> </div>
@code { @code {
[Parameter]
public int MinValue {get; set;}
[Parameter]
public int MaxValue {get; set;} = 100;
[Parameter]
public bool IsPowerOfTwo {get; set;}
[Parameter]
public int Step {get; set;} = 1;
private int currentValue; private int currentValue;
protected override void OnInitialized() { protected override void OnInitialized() {
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting()); SettingsService.BeginSave += (_, _) => SettingsService.UpdateSetting(GetSetting());
if (int.TryParse(Setting.Value, out int value)) { currentValue = value; } else {
currentValue = 0; // Default value if parsing fails if (IsPowerOfTwo) {
var converted = int.TryParse(Setting?.Value, out int value);
currentValue = converted ? (int)Math.Log2(value) : 0; // Default value if parsing fails
} else {
currentValue = int.TryParse(Setting?.Value, out int value) ? value : 0; // Default value if parsing fails
} }
} }
protected override SettingDto GetSetting() { protected override SettingDto? GetSetting() {
Setting.Value = currentValue.ToString(); if (Setting == null) return null;
Setting.Value = IsPowerOfTwo ? ((int)Math.Pow(2, currentValue)).ToString() : currentValue.ToString();
return Setting; return Setting;
} }

View File

@@ -0,0 +1,30 @@
@using Butter.Dtos.Settings
@using MilkStream.Services
@inherits SettingBox
@inject SettingsService SettingsService
<div class="card mb-3">
<div class="card-header">@(Setting?.Name ?? "NO NAME")</div>
<div class="card-body d-flex justify-content-between align-items-center">
<p class="card-text">@(Setting?.Description ?? "No description provided")</p>
<div class="d-flex">
<input type="text" id="@Setting?.Name" class="form-control" @bind="@currentValue" />
</div>
</div>
</div>
@code {
private string currentValue = string.Empty;
protected override void OnInitialized() {
base.OnInitialized();
SettingsService.BeginSave += (_, _) => SettingsService.UpdateSetting(GetSetting());
currentValue = Setting?.Value ?? string.Empty;
}
protected override SettingDto? GetSetting() {
if (Setting == null) return null;
Setting.Value = currentValue;
return Setting;
}
}

View File

@@ -1,5 +1,4 @@
@using Butter.Dtos.Settings @using Butter.Dtos.Settings
@using Butter.Settings
@using MilkStream.Services @using MilkStream.Services
@inherits SettingBox @inherits SettingBox
@inject SettingsService SettingsService @inject SettingsService SettingsService
@@ -18,13 +17,14 @@
private bool isChecked; private bool isChecked;
protected override void OnInitialized() { protected override void OnInitialized() {
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting()); SettingsService.BeginSave += (_, _) => SettingsService.UpdateSetting(GetSetting());
if (bool.TryParse(Setting.Value, out var parsedValue)) { if (bool.TryParse(Setting?.Value, out var parsedValue)) {
isChecked = parsedValue; isChecked = parsedValue;
} }
} }
protected override SettingDto GetSetting() { protected override SettingDto? GetSetting() {
if (Setting == null) return null;
Setting.Value = isChecked.ToString(); Setting.Value = isChecked.ToString();
return Setting; return Setting;
} }

View File

@@ -1,5 +1,7 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/WebPathMapping/IgnoredPaths/=BLAZOR_002EWEB_002EJS/@EntryIndexedValue">blazor.web.js</s:String>
<s:String x:Key="/Default/CodeInspection/WebPathMapping/IgnoredPaths/=WWWROOT_005CMILKSTREAM_002ESTYLES_002ECSS/@EntryIndexedValue">wwwroot\MilkStream.styles.css</s:String> <s:String x:Key="/Default/CodeInspection/WebPathMapping/IgnoredPaths/=WWWROOT_005CMILKSTREAM_002ESTYLES_002ECSS/@EntryIndexedValue">wwwroot\MilkStream.styles.css</s:String>
<s:String x:Key="/Default/CodeInspection/WebPathMapping/MappedPaths/=WWWROOT_005C_005FFRAMEWORK/@EntryIndexedValue"></s:String> <s:String x:Key="/Default/CodeInspection/WebPathMapping/MappedPaths/=WWWROOT_005C_005FFRAMEWORK/@EntryIndexedValue"></s:String>
<s:String x:Key="/Default/CodeInspection/WebPathMapping/PathsInCorrectCasing/=BLAZOR_002EWEB_002EJS/@EntryIndexedValue">blazor.web.js</s:String>
<s:String x:Key="/Default/CodeInspection/WebPathMapping/PathsInCorrectCasing/=WWWROOT_005CMILKSTREAM_002ESTYLES_002ECSS/@EntryIndexedValue">wwwroot\MilkStream.styles.css</s:String> <s:String x:Key="/Default/CodeInspection/WebPathMapping/PathsInCorrectCasing/=WWWROOT_005CMILKSTREAM_002ESTYLES_002ECSS/@EntryIndexedValue">wwwroot\MilkStream.styles.css</s:String>
<s:String x:Key="/Default/CodeInspection/WebPathMapping/PathsInCorrectCasing/=WWWROOT_005C_005FFRAMEWORK/@EntryIndexedValue">wwwroot\_framework</s:String></wpf:ResourceDictionary> <s:String x:Key="/Default/CodeInspection/WebPathMapping/PathsInCorrectCasing/=WWWROOT_005C_005FFRAMEWORK/@EntryIndexedValue">wwwroot\_framework</s:String></wpf:ResourceDictionary>

View File

@@ -15,13 +15,14 @@ builder.Services.AddSassCompiler();
#endif #endif
//TODO: this probably needs a better system for the settings. //TODO: this probably needs a better system for the settings.
builder.Services.AddHttpClient("MilkstreamClient").AddHttpMessageHandler<JwtTokenRefresher>(); builder.Services.AddHttpClient("MilkStreamClient").AddHttpMessageHandler<JwtTokenRefresher>();
builder.Services.Configure<ServiceOptions>(options => { options.BaseUrl = builder.Configuration["BaseUrl"]!; }); builder.Services.Configure<ServiceOptions>(options => { options.BaseUrl = builder.Configuration["BaseUrl"]!; });
builder.Services.AddScoped<LoginService>(); builder.Services.AddScoped<LoginService>();
builder.Services.AddScoped<MediaService>(); builder.Services.AddScoped<MediaService>();
builder.Services.AddScoped<UserService>(); builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<SettingsService>(); builder.Services.AddScoped<SettingsService>();
builder.Services.AddScoped<FoldersService>(); builder.Services.AddScoped<FoldersService>();
builder.Services.AddScoped<JobsService>();
//http listener for all requests with automatic JWT token refresh on 401 Unauthorized responses. //http listener for all requests with automatic JWT token refresh on 401 Unauthorized responses.
builder.Services.AddScoped<JwtTokenRefresher>(); builder.Services.AddScoped<JwtTokenRefresher>();
builder.Services.AddLogging(); builder.Services.AddLogging();

View File

@@ -0,0 +1,36 @@
using Butter.Dtos.Jobs;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Options;
namespace MilkStream.Services;
public sealed class JobsService(
IOptions<ServiceOptions> options,
IHttpClientFactory httpClientFactory,
LoginService loginService,
ILogger<JobsService> logger
) : AuthServiceBase(options, httpClientFactory, loginService, logger) {
public async Task<List<JobStatusDto>> GetJobs() {
var response = await Client.GetAsync("/api/jobs");
response.EnsureSuccessStatusCode();
var jobs = await response.Content.ReadFromJsonAsync<List<JobStatusDto>>();
return jobs ?? new List<JobStatusDto>();
}
public async Task StartJob(EJobType jobType) {
var response = await Client.PutAsJsonAsync("/api/jobs", new JobRequestDto() {
JobType = jobType
});
response.EnsureSuccessStatusCode();
}
public async Task CancelJob(JobStatusDto job) {
var response = await Client.DeleteAsync($"/api/jobs/{job.Id}");
response.EnsureSuccessStatusCode();
}
}

View File

@@ -20,13 +20,15 @@ public sealed class SettingsService : AuthServiceBase {
public async Task<List<SettingDto>?> GetAllSettings() { public async Task<List<SettingDto>?> GetAllSettings() {
var response = await Client.GetAsync("api/settings"); var response = await Client.GetAsync("api/settings");
response.EnsureSuccessStatusCode();
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<List<SettingDto>>(); return await response.Content.ReadFromJsonAsync<List<SettingDto>>();
return null;
} }
public void UpdateSetting(SettingDto setting) { public void UpdateSetting(SettingDto? setting) {
if (setting == null) {
Logger.Log(LogLevel.Warning, "Setting is null, cannot update.");
return;
}
Logger.Log(LogLevel.Information, $"Updating setting {setting.Name} to {setting.Value}"); Logger.Log(LogLevel.Information, $"Updating setting {setting.Name} to {setting.Value}");
var response = Client.PostAsJsonAsync("api/settings", setting).Result; var response = Client.PostAsJsonAsync("api/settings", setting).Result;

View File

@@ -1,7 +1,7 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Debug", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },

View File

@@ -8,6 +8,9 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lactose", "Lactose\Lactose.csproj", "{054E855F-A730-4FA4-895B-5147AF1877D9}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lactose", "Lactose\Lactose.csproj", "{054E855F-A730-4FA4-895B-5147AF1877D9}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MilkStream", "MilkStream\MilkStream.csproj", "{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MilkStream", "MilkStream\MilkStream.csproj", "{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}"
ProjectSection(ProjectDependencies) = postProject
{054E855F-A730-4FA4-895B-5147AF1877D9} = {054E855F-A730-4FA4-895B-5147AF1877D9}
EndProjectSection
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Butter", "Butter\Butter.csproj", "{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Butter", "Butter\Butter.csproj", "{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}"
EndProject EndProject
@@ -21,13 +24,16 @@ Global
{054E855F-A730-4FA4-895B-5147AF1877D9}.Debug|Any CPU.Build.0 = Debug|Any CPU {054E855F-A730-4FA4-895B-5147AF1877D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{054E855F-A730-4FA4-895B-5147AF1877D9}.Release|Any CPU.ActiveCfg = Release|Any CPU {054E855F-A730-4FA4-895B-5147AF1877D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{054E855F-A730-4FA4-895B-5147AF1877D9}.Release|Any CPU.Build.0 = Release|Any CPU {054E855F-A730-4FA4-895B-5147AF1877D9}.Release|Any CPU.Build.0 = Release|Any CPU
{054E855F-A730-4FA4-895B-5147AF1877D9}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Debug|Any CPU.Build.0 = Debug|Any CPU {A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Release|Any CPU.ActiveCfg = Release|Any CPU {A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Release|Any CPU.Build.0 = Release|Any CPU {A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Release|Any CPU.Build.0 = Release|Any CPU
{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Debug|Any CPU.Build.0 = Debug|Any CPU {CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Release|Any CPU.ActiveCfg = Release|Any CPU {CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Release|Any CPU.Build.0 = Release|Any CPU {CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Release|Any CPU.Build.0 = Release|Any CPU
{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@@ -10,6 +10,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConcurrentDictionary_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc3a41f4911b2871fe46ff44169115d8ffb305313e0fe77907cfeca42acf5e9_003FConcurrentDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConcurrentDictionary_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc3a41f4911b2871fe46ff44169115d8ffb305313e0fe77907cfeca42acf5e9_003FConcurrentDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConfigurationExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fb8c1c57742337e59591581af3eb2c647e9ed52d2951655fd74b1facf3ff520_003FConfigurationExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConfigurationExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fb8c1c57742337e59591581af3eb2c647e9ed52d2951655fd74b1facf3ff520_003FConfigurationExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConfigurationManager_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbbcfe7ab04c9c5e854e8084459eb29d1ba45cca1ecfec1ad876fafa1267_003FConfigurationManager_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConfigurationManager_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbbcfe7ab04c9c5e854e8084459eb29d1ba45cca1ecfec1ad876fafa1267_003FConfigurationManager_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F9e99606fc18442e3a934d0cf2081796f1de928_003F8f_003F76fd927b_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003AC_0021_003FUsers_003Fnivek_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F96a4cc66aa48dda8a9d8be8febec4a769fc143f7a86dd53ecdebfb9c3177d_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003AC_0021_003FUsers_003Fnivek_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F96a4cc66aa48dda8a9d8be8febec4a769fc143f7a86dd53ecdebfb9c3177d_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADefaultInterpolatedStringHandler_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fc1c946eaa6d8ddaaea1b63e936ca8ed2791d9316c5b025a41d445891e8a59ecd_003FDefaultInterpolatedStringHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADefaultInterpolatedStringHandler_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fc1c946eaa6d8ddaaea1b63e936ca8ed2791d9316c5b025a41d445891e8a59ecd_003FDefaultInterpolatedStringHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADefaultJsonTypeInfoResolver_002EConverters_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F16171b2c80ba9499f621a6bdb243caf2a6f19af216cfead9653432cd5aae_003FDefaultJsonTypeInfoResolver_002EConverters_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADefaultJsonTypeInfoResolver_002EConverters_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F16171b2c80ba9499f621a6bdb243caf2a6f19af216cfead9653432cd5aae_003FDefaultJsonTypeInfoResolver_002EConverters_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@@ -24,6 +25,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExecutionContext_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9eda537f15ea23cdfae523c19e87eb303a3ded88937ae7e55919387a43f70_003FExecutionContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExecutionContext_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9eda537f15ea23cdfae523c19e87eb303a3ded88937ae7e55919387a43f70_003FExecutionContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFSharpCoreReflectionProxy_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F74e6dceae40f3123c8fc6d1fb41fb1f5bd4eb9214623f4ab4943efe5fc52ad9_003FFSharpCoreReflectionProxy_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFSharpCoreReflectionProxy_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F74e6dceae40f3123c8fc6d1fb41fb1f5bd4eb9214623f4ab4943efe5fc52ad9_003FFSharpCoreReflectionProxy_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFSharpTypeConverterFactory_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff5b0593df95acacf3652937716a4f2a2135151dd5a2ce4d133d4a43608c32af_003FFSharpTypeConverterFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFSharpTypeConverterFactory_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff5b0593df95acacf3652937716a4f2a2135151dd5a2ce4d133d4a43608c32af_003FFSharpTypeConverterFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFuture_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6f90c6c13479b8c2a5be98f3d75dfc3bd885a055652d8a32904ca2448132949e_003FFuture_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpResponseMessage_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F21d9c87abd9d4814ab095cd6be92e1911a6928_003F86_003Fcd4a7567_003FHttpResponseMessage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpResponseMessage_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F21d9c87abd9d4814ab095cd6be92e1911a6928_003F86_003Fcd4a7567_003FHttpResponseMessage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIFileInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fnivek_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F781590a6c8e24e6b9f85d75a1ac30c819918_003F47_003Fda5e789a_003FIFileInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIFileInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fnivek_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F781590a6c8e24e6b9f85d75a1ac30c819918_003F47_003Fda5e789a_003FIFileInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fnivek_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9fcad18e31a1983467a9fd4bfef344ee97d72fa4ab6ba20353b8f78db8437b1_003FFileInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fnivek_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9fcad18e31a1983467a9fd4bfef344ee97d72fa4ab6ba20353b8f78db8437b1_003FFileInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@@ -44,6 +46,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2832e8c2b81f4641b3863f406ce3a519c90938_003F1c_003F3b0d22ec_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2832e8c2b81f4641b3863f406ce3a519c90938_003F1c_003F3b0d22ec_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd882146b4f265f10bcbec2663fce248db9ffec5fa1aeaf76e32a11ba5eafcd6_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd882146b4f265f10bcbec2663fce248db9ffec5fa1aeaf76e32a11ba5eafcd6_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F4363be3555734c96804bc429c863e56ab2e200_003F2f_003F2c688ff0_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F4363be3555734c96804bc429c863e56ab2e200_003F2f_003F2c688ff0_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F36f346b6c0454bc8a6afa7aed38119fe5bbffcc983298d9bfa4dbd5f49461f_003FMonitor_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F36f346b6c0454bc8a6afa7aed38119fe5bbffcc983298d9bfa4dbd5f49461f_003FMonitor_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F36f346b6c0454bc8a6afa7aed38119fe5bbffcc983298d9bfa4dbd5f49461f_003FMonitor_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2b90705926ae4f10aa01bbe3f1025828c90908_003F1a_003F5b817523_003FMonitor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2b90705926ae4f10aa01bbe3f1025828c90908_003F1a_003F5b817523_003FMonitor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANullable_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5acc345db3c207bc9d886a36ff14867ef8d65557432172c2a42f19aeac04d1b_003FNullable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANullable_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5acc345db3c207bc9d886a36ff14867ef8d65557432172c2a42f19aeac04d1b_003FNullable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@@ -61,9 +64,11 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARuntimeCustomAttributeData_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F167c292414d4cdbd966d4cb3bfda345c71ff4f9f149871eb4f4c65b6371ec63_003FRuntimeCustomAttributeData_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARuntimeCustomAttributeData_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F167c292414d4cdbd966d4cb3bfda345c71ff4f9f149871eb4f4c65b6371ec63_003FRuntimeCustomAttributeData_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARuntimeType_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3791ade316feeb2be344648da3c7d31719ff492f7733b643bf8c24d7629883_003FRuntimeType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARuntimeType_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3791ade316feeb2be344648da3c7d31719ff492f7733b643bf8c24d7629883_003FRuntimeType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASerializationExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F1fa9d27d36b95177aad139996d2e72727f1a5e60fcc839c2f1ac67d031ca5_003FSerializationExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASerializationExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F1fa9d27d36b95177aad139996d2e72727f1a5e60fcc839c2f1ac67d031ca5_003FSerializationExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F7d81b2d4f22bee75e5438c707251ae43cb0974c207db91ffc159118c84b4eb9_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASessionMiddlewareExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F7c7ebbdc5d4270fb17dee7c3eb17d64c7845f617f3c679ce4e22ad4bd11192d_003FSessionMiddlewareExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASessionMiddlewareExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F7c7ebbdc5d4270fb17dee7c3eb17d64c7845f617f3c679ce4e22ad4bd11192d_003FSessionMiddlewareExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASessionStorageService_002Eg_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc5abb9d0ed5bcffd706887e8cabcd2f1947e67c_003FSessionStorageService_002Eg_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASessionStorageService_002Eg_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc5abb9d0ed5bcffd706887e8cabcd2f1947e67c_003FSessionStorageService_002Eg_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AString_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2832e8c2b81f4641b3863f406ce3a519c90938_003F06_003F3b8d7821_003FString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AString_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2832e8c2b81f4641b3863f406ce3a519c90938_003F06_003F3b8d7821_003FString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATask_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fa77b231cea33ff338c41dc7869d6c493d4dae137ff51e342173efa61d933_003FTask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATask_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2832e8c2b81f4641b3863f406ce3a519c90938_003F27_003F7cad2316_003FTask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATask_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2832e8c2b81f4641b3863f406ce3a519c90938_003F27_003F7cad2316_003FTask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThreadPoolWorkQueue_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fac24a7c2e0f296e231c484b3fd930268f76c06dc75f4aad1df7aaf09f2c1227_003FThreadPoolWorkQueue_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThreadPoolWorkQueue_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fac24a7c2e0f296e231c484b3fd930268f76c06dc75f4aad1df7aaf09f2c1227_003FThreadPoolWorkQueue_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2c8e7ca976f350cba9836d5565dac56b11e0b56656fa786460eb1395857a6fa_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2c8e7ca976f350cba9836d5565dac56b11e0b56656fa786460eb1395857a6fa_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@@ -72,5 +77,10 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUtf8JsonWriterCache_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F96d87fca98b4167c66ffae61c9ee88dc182d6e7dbc7eb8dbf41297e660eb_003FUtf8JsonWriterCache_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUtf8JsonWriterCache_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F96d87fca98b4167c66ffae61c9ee88dc182d6e7dbc7eb8dbf41297e660eb_003FUtf8JsonWriterCache_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AValueTask_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9ec7c6b688aa4b89a1477dc1b679f62bf856b50196f4b8d19cd77f86df0abc_003FValueTask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AValueTask_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9ec7c6b688aa4b89a1477dc1b679f62bf856b50196f4b8d19cd77f86df0abc_003FValueTask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWriteStackFrame_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcfb06811acbbb25f4c1f3ae4eee74f65da5b2e9bd1bdc01ad979ac9b6745e9_003FWriteStackFrame_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWriteStackFrame_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcfb06811acbbb25f4c1f3ae4eee74f65da5b2e9bd1bdc01ad979ac9b6745e9_003FWriteStackFrame_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/SweaWarningsMode/@EntryValue">ShowAndRun</s:String>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/ACCESSOR_OWNER_BODY/@EntryValue">ExpressionBody</s:String> <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/ACCESSOR_OWNER_BODY/@EntryValue">ExpressionBody</s:String>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/ThisQualifier/INSTANCE_MEMBERS_QUALIFY_DECLARED_IN/@EntryValue">0</s:String></wpf:ResourceDictionary> <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/ThisQualifier/INSTANCE_MEMBERS_QUALIFY_DECLARED_IN/@EntryValue">0</s:String>
<s:Boolean x:Key="/Default/Dpa/IsEnabledInDebug/@EntryValue">True</s:Boolean>
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue">&lt;AssemblyExplorer&gt;&#xD;
&lt;Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.16\ref\net8.0\System.Collections.Concurrent.dll" /&gt;&#xD;
&lt;/AssemblyExplorer&gt;</s:String></wpf:ResourceDictionary>

View File

@@ -7,17 +7,20 @@
context: . context: .
dockerfile: Lactose/Dockerfile dockerfile: Lactose/Dockerfile
environment: environment:
- ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_ENVIRONMENT=Docker
ports: ports:
- "5162:8080" - "5162:8080"
depends_on: depends_on:
- database - database
volumes: volumes:
- "/root/MilkyShots/storageImages:/diary:ro" - "/root/MilkyShots/storageImages:/diary:ro"
- "/root/MilkyShots/thumbs:/app/thumbnails:rw"
- "/root/MilkyShots/previews:/app/previews:rw"
#- "./storageImages:/diary:ro" #- "./storageImages:/diary:ro"
database: database:
image: tensorchord/pgvecto-rs:pg17-v0.4.0 #image: tensorchord/pgvecto-rs:pg17-v0.4.0
image: pgvector/pgvector:pg17-trixie
container_name: database container_name: database
environment: environment:
POSTGRES_USER: root POSTGRES_USER: root