Compare commits
24 Commits
develop
...
4c85fee004
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c85fee004 | ||
|
|
3039fdfd39 | ||
|
|
f2db2e5204 | ||
|
|
021e186611 | ||
|
|
9d5e810cc6 | ||
|
|
be1e02bcf2 | ||
|
|
9821cb1c46 | ||
|
|
a0ec82f6b9 | ||
|
|
6f54e7a6ba | ||
|
|
c17fd84f0a | ||
|
|
02a938bba0 | ||
|
|
af9e3e25da | ||
|
|
9ff4f976d6 | ||
|
|
c771e1446c | ||
|
|
b0406ac4c8 | ||
|
|
a32e062d07 | ||
|
|
8ddd9c0dac | ||
|
|
67dd365ffc | ||
|
|
bf176e0ce5 | ||
|
|
565b888620 | ||
|
|
e371d73601 | ||
|
|
909d4d5e68 | ||
|
|
bd8af5b28f | ||
|
|
4986e401c4 |
6
.idea/.idea.MilkyShots/.idea/copilot.data.migration.agent.xml
generated
Normal file
6
.idea/.idea.MilkyShots/.idea/copilot.data.migration.agent.xml
generated
Normal 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>
|
||||
6
.idea/.idea.MilkyShots/.idea/copilot.data.migration.edit.xml
generated
Normal file
6
.idea/.idea.MilkyShots/.idea/copilot.data.migration.edit.xml
generated
Normal 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>
|
||||
2
.idea/.idea.MilkyShots/.idea/watcherTasks.xml
generated
2
.idea/.idea.MilkyShots/.idea/watcherTasks.xml
generated
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectTasksOptions">
|
||||
<TaskOptions isEnabled="true">
|
||||
<TaskOptions isEnabled="false">
|
||||
<option name="arguments" value="$FileName$:$FileNameWithoutExtension$.css" />
|
||||
<option name="checkSyntaxErrors" value="true" />
|
||||
<option name="description" />
|
||||
|
||||
29
Butter/Dtos/Jobs/JobStatusDto.cs
Normal file
29
Butter/Dtos/Jobs/JobStatusDto.cs
Normal 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();
|
||||
}
|
||||
@@ -3,9 +3,12 @@ using Butter.Settings;
|
||||
namespace Butter.Dtos.Settings;
|
||||
|
||||
public class SettingDto {
|
||||
public required string Name { get; set; } = string.Empty;
|
||||
public string? Value { get; set; } = string.Empty;
|
||||
public required string? Description { get; set; }
|
||||
public required EType Type { get; set; }
|
||||
public required string Name { get; set; } = string.Empty;
|
||||
public string? Value { get; set; } = string.Empty;
|
||||
public required string? Description { get; set; }
|
||||
public required EType Type { get; set; }
|
||||
public required DisplayType DisplayType { get; set; }
|
||||
}
|
||||
// for range type settings min max and step are used.
|
||||
//
|
||||
public string[]? Options { get; set; }
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ public enum DisplayType {
|
||||
Checkbox = 4,
|
||||
Switch = 5,
|
||||
DateTimePicker = 6,
|
||||
TimePicker = 7
|
||||
TimePicker = 7,
|
||||
Po2W = 8, // Power of 2
|
||||
}
|
||||
|
||||
public static class DisplayTypeExtension {
|
||||
|
||||
@@ -6,6 +6,11 @@ public enum Settings {
|
||||
FolderScanInterval, //Interval in minutes for folder scanning
|
||||
FileUploadEnabled, //Enable or disable file upload
|
||||
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 {
|
||||
@@ -16,6 +21,11 @@ public static class SettingsExtensions {
|
||||
Settings.FolderScanInterval => "Folder Scan Interval",
|
||||
Settings.FileUploadEnabled => "File Upload Enabled",
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Lactose.Jobs;
|
||||
namespace Butter.Types;
|
||||
|
||||
public enum EJobStatus {
|
||||
Queued, // Not yet started
|
||||
@@ -15,6 +15,7 @@ public class LactoseDbContext : DbContext {
|
||||
public LactoseDbContext(DbContextOptions options) : base(options) {}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||
modelBuilder.HasPostgresExtension("vector");
|
||||
//Album Relationships
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.UserOwner).WithMany(e => e.OwnedAlbums);
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);
|
||||
|
||||
97
Lactose/Controllers/JobsController.cs
Normal file
97
Lactose/Controllers/JobsController.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,92 @@
|
||||
[
|
||||
{
|
||||
"Name": "User Registration Enabled",
|
||||
"Value" : "false",
|
||||
"Value": "false",
|
||||
"Description": "Sets if user registration is enabled or not.",
|
||||
"Type" : 2,
|
||||
"DisplayType" : 5
|
||||
"Type": 2,
|
||||
"DisplayType": 5
|
||||
},
|
||||
{
|
||||
{
|
||||
"Name": "Folder Scan Enabled",
|
||||
"Value" : "true",
|
||||
"Value": "true",
|
||||
"Description": "Sets if the folder scan service should be running or not.",
|
||||
"Type" : 2,
|
||||
"DisplayType" : 5
|
||||
"Type": 2,
|
||||
"DisplayType": 5
|
||||
},
|
||||
{
|
||||
"Name": "Folder Scan Interval",
|
||||
"Value" : "",
|
||||
"Value": "",
|
||||
"Description": "Time interval after the previous scan has finished before a new folder scan is started.",
|
||||
"Type" : 4,
|
||||
"DisplayType" : 7
|
||||
"Type": 4,
|
||||
"DisplayType": 7,
|
||||
"Options": [
|
||||
"5",
|
||||
"360",
|
||||
"5"
|
||||
]
|
||||
},
|
||||
{
|
||||
{
|
||||
"Name": "File Upload Enabled",
|
||||
"Value" : "false",
|
||||
"Value": "false",
|
||||
"Description": "NOT IMPLEMENTED YET: Sets if the file upload service should be running or not.",
|
||||
"Type" : 2,
|
||||
"DisplayType" : 5
|
||||
"Type": 2,
|
||||
"DisplayType": 5
|
||||
},
|
||||
{
|
||||
{
|
||||
"Name": "File Upload MaxSize",
|
||||
"Value" : "0",
|
||||
"Value": "0",
|
||||
"Description": "Max file size for uploads in bytes.",
|
||||
"Type" : 1,
|
||||
"DisplayType" :2
|
||||
"Type": 1,
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -2,14 +2,15 @@ using Butter;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using System.Collections;
|
||||
using System.Data;
|
||||
using static System.String;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
public class FileSystemCrawlJob : Job {
|
||||
public override string Name { get; } = "Filesystem Crawl Job";
|
||||
public override JobStatus Status { get; }
|
||||
public sealed class FileSystemCrawlJob : Job {
|
||||
public override string Name { get; } = "Directory Scan: ";
|
||||
public override JobStatus JobStatus { get; }
|
||||
IAssetRepository assetRepository;
|
||||
string workingPath;
|
||||
ILogger<FileSystemCrawlJob> logger;
|
||||
@@ -22,15 +23,16 @@ public class FileSystemCrawlJob : Job {
|
||||
IAssetRepository assetRepository,
|
||||
JobManager jobManager
|
||||
) {
|
||||
Status = new(this);
|
||||
JobStatus = new(this);
|
||||
this.logger = logger;
|
||||
this.assetRepository = assetRepository;
|
||||
this.workingPath = workingPath;
|
||||
this.jobManager = jobManager;
|
||||
Name += $"{workingPath}";
|
||||
}
|
||||
|
||||
///<inheritdoc />
|
||||
protected override void TaskJob(CancellationToken token) {
|
||||
protected override async Task TaskJob(CancellationToken token) {
|
||||
logger.LogInformation($"Started crawling directory {workingPath}");
|
||||
|
||||
DirectoryInfo dir = new(workingPath);
|
||||
@@ -54,7 +56,10 @@ public class FileSystemCrawlJob : Job {
|
||||
|
||||
foreach (var folder in folders) {
|
||||
if (token.IsCancellationRequested) {
|
||||
Status.Cancel();
|
||||
// Cancel all child jobs
|
||||
childJobs.ForEach(j => j.Cancel());
|
||||
// Report this job as canceled
|
||||
JobStatus.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,17 +70,20 @@ public class FileSystemCrawlJob : Job {
|
||||
childJobs.Add(job);
|
||||
jobManager.EnqueueJob(job);
|
||||
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) {
|
||||
if (token.IsCancellationRequested) {
|
||||
Status.Cancel();
|
||||
// Cancel all child jobs
|
||||
childJobs.ForEach(j => j.Cancel());
|
||||
// Report this job as canceled
|
||||
JobStatus.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -94,19 +102,19 @@ public class FileSystemCrawlJob : Job {
|
||||
}
|
||||
|
||||
// Wait for all child jobs to complete
|
||||
Status.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Waiting for {childJobs.Count} child jobs to complete.");
|
||||
Status.Status = EJobStatus.Waiting;
|
||||
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Waiting for {childJobs.Count} child jobs to complete.");
|
||||
JobStatus.Wait();
|
||||
|
||||
while (childJobs.Count > 0) {
|
||||
if (token.IsCancellationRequested) {
|
||||
// Cancel all child jobs
|
||||
childJobs.ForEach(j => j.Cancel());
|
||||
// Cancel this job as well
|
||||
Status.Cancel();
|
||||
JobStatus.Cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
Status.Complete("Crawl completed successfully.");
|
||||
JobStatus.Complete("Crawl completed successfully.");
|
||||
}
|
||||
|
||||
Asset? AssetFromPath(string filePath) {
|
||||
@@ -141,7 +149,7 @@ public class FileSystemCrawlJob : Job {
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
},
|
||||
FileSize = finfo.Length,
|
||||
Hash = []
|
||||
Hash = new BitArray(64)
|
||||
};
|
||||
|
||||
logger.LogDebug(
|
||||
|
||||
188
Lactose/Jobs/IntegrityCheckJob.cs
Normal file
188
Lactose/Jobs/IntegrityCheckJob.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
@@ -8,33 +10,40 @@ namespace Lactose.Jobs;
|
||||
public abstract class Job {
|
||||
public virtual Guid Id { get; } = Guid.NewGuid();
|
||||
public virtual string Name { get; } = "Unnamed Job";
|
||||
public virtual JobStatus Status { get; }
|
||||
public virtual JobStatus JobStatus { get; }
|
||||
Task? task;
|
||||
CancellationTokenSource? cts;
|
||||
|
||||
protected Job() => Status = new JobStatus(this);
|
||||
protected Job() => JobStatus = new JobStatus(this);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public virtual void Start() {
|
||||
Status.Start();
|
||||
JobStatus.Start();
|
||||
cts = new CancellationTokenSource();
|
||||
task = Task.Run(() => TaskJob(cts.Token));
|
||||
|
||||
task.ContinueWith(t => {
|
||||
if (t.IsCanceled) {
|
||||
Status.Cancel("Job was canceled");
|
||||
Canceled?.Invoke(this, Status);
|
||||
} else if (t.IsFaulted) {
|
||||
Status.Fail(t.Exception?.Message ?? "Job failed with an unknown error");
|
||||
Failed?.Invoke(this, Status);
|
||||
} else {
|
||||
Status.Complete("Job completed successfully");
|
||||
Completed?.Invoke(this, Status);
|
||||
switch (JobStatus.Status) {
|
||||
case EJobStatus.Canceled:
|
||||
Canceled?.Invoke(this, JobStatus);
|
||||
break;
|
||||
case EJobStatus.Failed:
|
||||
Failed?.Invoke(this, JobStatus);
|
||||
break;
|
||||
case EJobStatus.Running: // If still running or waiting, mark as completed
|
||||
case EJobStatus.Waiting: //somebody forgot to update the status and the method returned.
|
||||
case EJobStatus.Completed:
|
||||
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.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
/// Cancels the job if it's running. Canceled event will be called by the callbacks on the task.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Types;
|
||||
using Lactose.Utils;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
@@ -14,7 +15,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of jobs that can run concurrently.
|
||||
/// </summary>
|
||||
public int MaxConcurrentJobs { get; set; } = 8;
|
||||
public int MaxConcurrentJobs { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a job to the manager.
|
||||
@@ -45,7 +46,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
public void RemoveJob(Guid id) {
|
||||
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.Waiting:
|
||||
// Schedule job removal from the dictionary once it's canceled
|
||||
@@ -68,8 +69,9 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
/// <summary>
|
||||
/// Injects required dependencies and creates a new instance of the specified job type.
|
||||
/// </summary>
|
||||
public Job CreateJob<T>() where T : Job {
|
||||
var job = ActivatorUtilities.CreateInstance<T>(serviceProvider);
|
||||
public T CreateJob<T>() where T : Job {
|
||||
var scope = serviceProvider.CreateScope();
|
||||
var job = ActivatorUtilities.CreateInstance<T>(scope.ServiceProvider);
|
||||
logger.LogInformation($"Job with ID {job.Id} has been created.");
|
||||
return job;
|
||||
}
|
||||
@@ -77,7 +79,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
/// <summary>
|
||||
/// Injects required dependencies and creates a new instance of the specified job type, passing the provided arguments to its constructor.
|
||||
/// </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 job = ActivatorUtilities.CreateInstance<T>(scope.ServiceProvider, args);
|
||||
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(() => {
|
||||
while (!stoppingToken.IsCancellationRequested) {
|
||||
// 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;
|
||||
|
||||
//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)
|
||||
.Take(MaxConcurrentJobs - activeRunningJobs)
|
||||
.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();
|
||||
logger.LogInformation($"Job {job.Id} : {job.Name} has been started.");
|
||||
}
|
||||
|
||||
@@ -1,48 +1,55 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
public class JobStatus(Job job) {
|
||||
public Guid Id => job.Id;
|
||||
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? Started { get; private set; }
|
||||
public DateTime? Finished { get; private set; }
|
||||
public string? Message { get; private set; }
|
||||
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") {
|
||||
Status = EJobStatus.Running;
|
||||
Started = DateTime.UtcNow;
|
||||
Progress = 0;
|
||||
Message = message;
|
||||
}
|
||||
|
||||
|
||||
public void Wait(string message = "Job waiting") {
|
||||
Status = EJobStatus.Waiting;
|
||||
Message = message;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
|
||||
Progress = progress;
|
||||
if (message != null) Message = message;
|
||||
}
|
||||
|
||||
|
||||
public void Complete(string? message = null) {
|
||||
Status = EJobStatus.Completed;
|
||||
Finished = DateTime.UtcNow;
|
||||
Progress = 1;
|
||||
if (message != null) Message = message;
|
||||
}
|
||||
|
||||
|
||||
public void Fail(string? message = null) {
|
||||
Status = EJobStatus.Failed;
|
||||
Finished = DateTime.UtcNow;
|
||||
if (message != null) Message = message;
|
||||
}
|
||||
|
||||
|
||||
public void Cancel(string? message = null) {
|
||||
Status = EJobStatus.Canceled;
|
||||
Finished = DateTime.UtcNow;
|
||||
if (message != null) Message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
122
Lactose/Jobs/PHashJob.cs
Normal file
122
Lactose/Jobs/PHashJob.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
200
Lactose/Jobs/ThumbnailJob.cs
Normal file
200
Lactose/Jobs/ThumbnailJob.cs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" 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>
|
||||
</PackageReference>
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
25
Lactose/Mapper/JobMapper.cs
Normal file
25
Lactose/Mapper/JobMapper.cs
Normal 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))
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,8 @@ public static class SettingsMapper {
|
||||
Value = setting.Value,
|
||||
Description = setting.Description,
|
||||
Type = setting.Type,
|
||||
DisplayType = setting.DisplayType
|
||||
DisplayType = setting.DisplayType,
|
||||
Options = setting.Options
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -29,6 +30,7 @@ public static class SettingsMapper {
|
||||
Value = settingDto.Value,
|
||||
Description = settingDto.Description,
|
||||
Type = settingDto.Type,
|
||||
DisplayType = settingDto.DisplayType
|
||||
DisplayType = settingDto.DisplayType,
|
||||
Options = settingDto.Options
|
||||
};
|
||||
}
|
||||
465
Lactose/Migrations/20250903205921_ULongHash.Designer.cs
generated
Normal file
465
Lactose/Migrations/20250903205921_ULongHash.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Lactose/Migrations/20250903205921_ULongHash.cs
Normal file
36
Lactose/Migrations/20250903205921_ULongHash.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
468
Lactose/Migrations/20250904003848_VectorExtensions.Designer.cs
generated
Normal file
468
Lactose/Migrations/20250904003848_VectorExtensions.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
43
Lactose/Migrations/20250904003848_VectorExtensions.cs
Normal file
43
Lactose/Migrations/20250904003848_VectorExtensions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
471
Lactose/Migrations/20250905024004_AddedSettingsOptions.Designer.cs
generated
Normal file
471
Lactose/Migrations/20250905024004_AddedSettingsOptions.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Lactose/Migrations/20250905024004_AddedSettingsOptions.cs
Normal file
29
Lactose/Migrations/20250905024004_AddedSettingsOptions.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
@@ -20,6 +21,7 @@ namespace Lactose.Migrations
|
||||
.HasAnnotation("ProductVersion", "8.0.15")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vectors");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
@@ -108,9 +110,9 @@ namespace Lactose.Migrations
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<byte[]>("Hash")
|
||||
b.Property<BitArray>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("BYTEA");
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<bool>("IsPubliclyShared")
|
||||
.HasColumnType("boolean");
|
||||
@@ -256,6 +258,9 @@ namespace Lactose.Migrations
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string[]>("Options")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Types;
|
||||
using System.Collections;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
@@ -108,8 +109,8 @@ public class Asset {
|
||||
/// <summary>
|
||||
/// Computed hash of the asset.
|
||||
/// </summary>
|
||||
[Required][Column(TypeName = "BYTEA")]
|
||||
public byte[] Hash { get; set; } = [];
|
||||
[Required][Column(TypeName = "bit(64)")]
|
||||
public BitArray Hash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or Sets which folder the asset is in.
|
||||
|
||||
@@ -16,4 +16,6 @@ public class Setting {
|
||||
public required EType Type { get; set; }
|
||||
|
||||
public required DisplayType DisplayType { get; set; }
|
||||
|
||||
public string[]? Options { get; set; }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Npgsql;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -48,7 +49,7 @@ builder.Services.AddDbContext<LactoseDbContext>(
|
||||
#endif
|
||||
;*/
|
||||
|
||||
options.UseNpgsql(strBuilder.ConnectionString)
|
||||
options.UseNpgsql(strBuilder.ConnectionString, o => o.UseVector())
|
||||
#if DEBUG
|
||||
.LogTo(Console.WriteLine)
|
||||
.EnableSensitiveDataLogging()
|
||||
@@ -76,6 +77,7 @@ builder.Services.AddTransient<IMediaRepository, MediaRepository>();
|
||||
builder.Services.AddTransient<ITokenService, TokenService>();
|
||||
builder.Services.AddTransient<LactoseAuthService>();
|
||||
builder.Services.AddSingleton<JobManager>();
|
||||
builder.Services.AddSingleton<JobScheduler>();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddControllers();
|
||||
|
||||
@@ -153,7 +155,7 @@ builder.Services
|
||||
|
||||
//Add the job manager service
|
||||
builder.Services.AddHostedService<JobManager>(p => p.GetRequiredService<JobManager>());
|
||||
builder.Services.AddHostedService<JobScheduler>();
|
||||
builder.Services.AddHostedService<JobScheduler>(p => p.GetRequiredService<JobScheduler>());
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope()) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
using System.Collections;
|
||||
using System.Data;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
@@ -42,6 +44,34 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids) => context.Assets.Where(a => ids.Contains(a.Id));
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Lactose.Models;
|
||||
using System.Collections;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -71,4 +72,13 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <param name="filePath">The file path of the asset.</param>
|
||||
/// <returns>Null if not found, otherwise the requested asset.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ interface IDbInitializer {
|
||||
void Initialize();
|
||||
}
|
||||
|
||||
class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHasher) : IDbInitializer {
|
||||
internal class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHasher) : IDbInitializer {
|
||||
public void Initialize() {
|
||||
ArgumentNullException.ThrowIfNull(dbContext, nameof(dbContext));
|
||||
|
||||
@@ -52,19 +52,33 @@ class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHa
|
||||
if (settings is not null) {
|
||||
//remove all settings that are not in the default settings file
|
||||
var existingSettings = dbContext.Settings.ToList();
|
||||
foreach (Setting existingSetting in existingSettings
|
||||
.Where(existingSetting => settings.All(s => s.Name != existingSetting.Name)))
|
||||
dbContext.Settings.Remove(existingSetting);
|
||||
// add or update settings from the default settings file leaving value unchanged
|
||||
|
||||
foreach (Setting existingSetting in existingSettings.Where(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
|
||||
foreach (var setting in settings) {
|
||||
if (!dbContext.Settings.Any(s => s.Name == setting.Name)) { dbContext.Settings.Add(setting); } else {
|
||||
var dbSetting = dbContext.Settings.First(s => s.Name == setting.Name);
|
||||
dbSetting.Description = setting.Description;
|
||||
dbSetting.Type = setting.Type;
|
||||
dbSetting.DisplayType = setting.DisplayType;
|
||||
dbSetting.Options = setting.Options;
|
||||
dbContext.Settings.Update(dbSetting);
|
||||
}
|
||||
}
|
||||
|
||||
dbContext.SaveChanges();
|
||||
} //else, we are a bit fucked.
|
||||
|
||||
@@ -84,5 +98,6 @@ class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHa
|
||||
dbContext.SaveChanges();
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Jobs;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
@@ -30,14 +31,6 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
||||
lifetime.ApplicationStarted.Register(() => service.Start());
|
||||
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() {
|
||||
logger?.LogInformation("Fetching settings...");
|
||||
@@ -89,4 +82,69 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
using System.Collections;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Lactose.Utils;
|
||||
|
||||
public static class EnumerableExtensions {
|
||||
public static void ForEach<TSource>(this IEnumerable<TSource> source, Action<TSource> action) {
|
||||
|
||||
foreach (var item in source) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Lactose.Services" : "Trace"
|
||||
"Lactose.Services" : "Information"
|
||||
}
|
||||
},
|
||||
"SignKey": {
|
||||
@@ -12,6 +12,6 @@
|
||||
},
|
||||
"DatabaseAddress": {
|
||||
"Host": "database",
|
||||
"Port": 5432
|
||||
"Port": 3306
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"System": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Lactose.Services" : "Trace"
|
||||
}
|
||||
"LogLevel": {
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Lactose.Services" : "Trace"
|
||||
}
|
||||
},
|
||||
"DatabaseAddress": {
|
||||
"Host": "database",
|
||||
"Port": 3306
|
||||
"Port": 5432
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Lactose": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<body>
|
||||
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
|
||||
<script src="lib/js/bootstrap.bundle.min.js"></script>
|
||||
@* ReSharper disable once Html.PathError *@
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body 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"/>
|
||||
<button class="btn btn-sm btn-success @Edit" @onclick="OnConfirmEdit"><i class="bi bi-check"></i></button>
|
||||
<div class="ms-auto form-switch">
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
bool editing = false;
|
||||
bool editing;
|
||||
string Display => editing ? "d-none" : "";
|
||||
string Edit => editing ? "" : "d-none";
|
||||
|
||||
|
||||
86
MilkStream/Components/JobCard.razor
Normal file
86
MilkStream/Components/JobCard.razor
Normal 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);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<NavMenu/>
|
||||
<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
|
||||
</div>
|
||||
</main>
|
||||
@@ -10,7 +10,6 @@
|
||||
<span class="text-primary m-auto">Footer (duh!)</span>
|
||||
</footer>
|
||||
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
@using Butter.Types
|
||||
|
||||
@inherits LayoutComponentBase
|
||||
@inject NavigationManager navigation
|
||||
@inject UserService userService
|
||||
@inject LoginService loginService
|
||||
@inject ProtectedLocalStorage localStorage
|
||||
@inject NavigationManager Navigation
|
||||
@inject UserService UserService
|
||||
@inject LoginService LoginService
|
||||
@inject ProtectedLocalStorage LocalStorage
|
||||
|
||||
<div class="navbar navbar-expand-sm bg-dark-subtle">
|
||||
<div class="container-xl">
|
||||
@@ -47,18 +47,25 @@
|
||||
<i class="bi bi-search-heart"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-2">@loginService.LoggedUser?.Username</div>
|
||||
<div class="mx-2">@LoginService.LoggedUser?.Username</div>
|
||||
<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>
|
||||
</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">
|
||||
<li><a class="dropdown-item" href="#"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
|
||||
@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="OnProfileClick"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
|
||||
@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="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>
|
||||
</div>
|
||||
}
|
||||
@@ -68,15 +75,15 @@
|
||||
</div>
|
||||
|
||||
@code{
|
||||
bool LoggedIn => loginService.IsLoggedIn;
|
||||
bool Admin => loginService.LoggedUser?.AccessLevel == EAccessLevel.Admin;
|
||||
bool isConnected;
|
||||
bool needsUpdate;
|
||||
bool LoggedIn => LoginService.IsLoggedIn;
|
||||
bool Admin => LoginService.LoggedUser?.AccessLevel == EAccessLevel.Admin;
|
||||
bool isConnected;
|
||||
bool needsUpdate;
|
||||
|
||||
protected override void OnInitialized() {
|
||||
base.OnInitialized();
|
||||
loginService.LoggedUserChanged += (sender, dto) => needsUpdate = true;
|
||||
LoadStateAsync().ConfigureAwait(false);
|
||||
protected async override Task OnInitializedAsync() {
|
||||
LoginService.LoggedUserChanged += (_, _) => needsUpdate = true;
|
||||
await base.OnInitializedAsync();
|
||||
//await LoadStateAsync();
|
||||
}
|
||||
|
||||
protected async override Task OnAfterRenderAsync(bool firstRender) {
|
||||
@@ -89,27 +96,34 @@
|
||||
}
|
||||
|
||||
private async Task LoadStateAsync() {
|
||||
var auth = await localStorage.GetAsync<AuthInfo>("auth");
|
||||
var auth = await LocalStorage.GetAsync<AuthInfo>("auth");
|
||||
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() {
|
||||
loginService.LoggedUser = await userService.GetUserAsync();
|
||||
LoginService.LoggedUser = await UserService.GetUserAsync();
|
||||
|
||||
if (loginService.LoggedUser != null) needsUpdate = true;
|
||||
else navigation.NavigateTo("/login");
|
||||
if (LoginService.LoggedUser != null) needsUpdate = true;
|
||||
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() {
|
||||
_ = loginService.Logout();
|
||||
await localStorage.DeleteAsync("auth");
|
||||
navigation.NavigateTo("/", true);
|
||||
_ = LoginService.Logout();
|
||||
await LocalStorage.DeleteAsync("auth");
|
||||
Navigation.NavigateTo("/", true);
|
||||
}
|
||||
|
||||
void OnSettings1Click() => Navigation.NavigateTo("/Settings1");
|
||||
|
||||
void OnProfileClick() => Navigation.NavigateTo($"/User/{LoginService.AuthInfo?.UserId}", true);
|
||||
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
@page "/"
|
||||
@using Butter.Dtos;
|
||||
@using MilkStream.Components.Layout
|
||||
@using MilkStream.Services
|
||||
|
||||
@inject NavigationManager navigationManager
|
||||
@inject LoginService loginService
|
||||
@inject ProtectedSessionStorage sessionStorage
|
||||
@inject ProtectedLocalStorage localStorage
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject LoginService LoginService
|
||||
@inject ProtectedSessionStorage SessionStorage
|
||||
@inject ProtectedLocalStorage LocalStorage
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
@@ -17,7 +16,7 @@
|
||||
@if (mediaList.Count == 0) {
|
||||
<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>
|
||||
@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>
|
||||
} else {
|
||||
<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) {
|
||||
if (firstRender) {
|
||||
loginService.LoggedUserChanged += (sender, dto) => StateHasChanged();
|
||||
LoginService.LoggedUserChanged += (_, _) => StateHasChanged();
|
||||
//do nothing for now
|
||||
await Task.Delay(1);
|
||||
isLoading = false;
|
||||
|
||||
54
MilkStream/Components/Pages/Jobs.razor
Normal file
54
MilkStream/Components/Pages/Jobs.razor
Normal 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);
|
||||
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
switch (option.DisplayType) {
|
||||
// TODO: should implement text and password at some point
|
||||
case DisplayType.Text:
|
||||
<input type="text" id="@option.Name" class="form-control" @bind="@option.Value"/>
|
||||
<SettingString Setting="option"/>
|
||||
break;
|
||||
case DisplayType.Password:
|
||||
<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>
|
||||
break;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
94
MilkStream/Components/Pages/SettingsRework.razor
Normal file
94
MilkStream/Components/Pages/SettingsRework.razor
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
62
MilkStream/Components/Pages/User.razor
Normal file
62
MilkStream/Components/Pages/User.razor
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,13 +5,13 @@
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public required SettingDto Setting { get; set; }
|
||||
public required SettingDto? Setting { get; set; }
|
||||
|
||||
protected virtual SettingDto GetSetting() {
|
||||
protected virtual SettingDto? GetSetting() {
|
||||
return Setting;
|
||||
}
|
||||
|
||||
protected override void OnInitialized() {
|
||||
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting());
|
||||
SettingsService.BeginSave += (_, _) => SettingsService.UpdateSetting(GetSetting());
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,16 @@
|
||||
private float currentValue;
|
||||
|
||||
protected override void OnInitialized() {
|
||||
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting());
|
||||
if (float.TryParse(Setting.Value, out float value)) {
|
||||
SettingsService.BeginSave += (_, _) => SettingsService.UpdateSetting(GetSetting());
|
||||
if (float.TryParse(Setting?.Value, out float value)) {
|
||||
currentValue = value;
|
||||
} else {
|
||||
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);
|
||||
return Setting;
|
||||
}
|
||||
|
||||
@@ -8,24 +8,37 @@
|
||||
<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">
|
||||
<div class="mx-2">@currentValue</div>
|
||||
<input type="range" id="@Setting?.Name" class="form-range" @bind="@currentValue"/>
|
||||
<div class="mx-2">@(IsPowerOfTwo ? Math.Pow(2, currentValue) : currentValue)</div>
|
||||
<input type="range" id="@Setting?.Name" class="form-range" @bind="@currentValue" min="@MinValue" max="@MaxValue"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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;
|
||||
|
||||
|
||||
protected override void OnInitialized() {
|
||||
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting());
|
||||
if (int.TryParse(Setting.Value, out int value)) { currentValue = value; } else {
|
||||
currentValue = 0; // Default value if parsing fails
|
||||
SettingsService.BeginSave += (_, _) => SettingsService.UpdateSetting(GetSetting());
|
||||
|
||||
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() {
|
||||
Setting.Value = currentValue.ToString();
|
||||
protected override SettingDto? GetSetting() {
|
||||
if (Setting == null) return null;
|
||||
Setting.Value = IsPowerOfTwo ? ((int)Math.Pow(2, currentValue)).ToString() : currentValue.ToString();
|
||||
return Setting;
|
||||
}
|
||||
|
||||
|
||||
30
MilkStream/Components/SettingBoxes/SettingString.razor
Normal file
30
MilkStream/Components/SettingBoxes/SettingString.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
@using Butter.Dtos.Settings
|
||||
@using Butter.Settings
|
||||
@using MilkStream.Services
|
||||
@inherits SettingBox
|
||||
@inject SettingsService SettingsService
|
||||
@@ -18,13 +17,14 @@
|
||||
private bool isChecked;
|
||||
|
||||
protected override void OnInitialized() {
|
||||
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting());
|
||||
if (bool.TryParse(Setting.Value, out var parsedValue)) {
|
||||
SettingsService.BeginSave += (_, _) => SettingsService.UpdateSetting(GetSetting());
|
||||
if (bool.TryParse(Setting?.Value, out var parsedValue)) {
|
||||
isChecked = parsedValue;
|
||||
}
|
||||
}
|
||||
|
||||
protected override SettingDto GetSetting() {
|
||||
protected override SettingDto? GetSetting() {
|
||||
if (Setting == null) return null;
|
||||
Setting.Value = isChecked.ToString();
|
||||
return Setting;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<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/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_005C_005FFRAMEWORK/@EntryIndexedValue">wwwroot\_framework</s:String></wpf:ResourceDictionary>
|
||||
@@ -15,13 +15,14 @@ builder.Services.AddSassCompiler();
|
||||
#endif
|
||||
|
||||
//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.AddScoped<LoginService>();
|
||||
builder.Services.AddScoped<MediaService>();
|
||||
builder.Services.AddScoped<UserService>();
|
||||
builder.Services.AddScoped<SettingsService>();
|
||||
builder.Services.AddScoped<FoldersService>();
|
||||
builder.Services.AddScoped<JobsService>();
|
||||
//http listener for all requests with automatic JWT token refresh on 401 Unauthorized responses.
|
||||
builder.Services.AddScoped<JwtTokenRefresher>();
|
||||
builder.Services.AddLogging();
|
||||
|
||||
36
MilkStream/Services/JobsService.cs
Normal file
36
MilkStream/Services/JobsService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,15 @@ public sealed class SettingsService : AuthServiceBase {
|
||||
|
||||
public async Task<List<SettingDto>?> GetAllSettings() {
|
||||
var response = await Client.GetAsync("api/settings");
|
||||
|
||||
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<List<SettingDto>>();
|
||||
|
||||
return null;
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<List<SettingDto>>();
|
||||
}
|
||||
|
||||
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}");
|
||||
var response = Client.PostAsJsonAsync("api/settings", setting).Result;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,6 +8,9 @@ EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lactose", "Lactose\Lactose.csproj", "{054E855F-A730-4FA4-895B-5147AF1877D9}"
|
||||
EndProject
|
||||
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
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Butter", "Butter\Butter.csproj", "{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}"
|
||||
EndProject
|
||||
@@ -21,13 +24,16 @@ Global
|
||||
{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.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.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.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.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.Build.0 = Release|Any CPU
|
||||
{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -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_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_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_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>
|
||||
@@ -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_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_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_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>
|
||||
@@ -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_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_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_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>
|
||||
@@ -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_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_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_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_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_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>
|
||||
@@ -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_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/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/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"><AssemblyExplorer>
|
||||
<Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.16\ref\net8.0\System.Collections.Concurrent.dll" />
|
||||
</AssemblyExplorer></s:String></wpf:ResourceDictionary>
|
||||
|
||||
@@ -7,17 +7,20 @@
|
||||
context: .
|
||||
dockerfile: Lactose/Dockerfile
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- ASPNETCORE_ENVIRONMENT=Docker
|
||||
ports:
|
||||
- "5162:8080"
|
||||
depends_on:
|
||||
- database
|
||||
volumes:
|
||||
- "/root/MilkyShots/storageImages:/diary:ro"
|
||||
- "/root/MilkyShots/thumbs:/app/thumbnails:rw"
|
||||
- "/root/MilkyShots/previews:/app/previews:rw"
|
||||
#- "./storageImages:/diary:ro"
|
||||
|
||||
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
|
||||
environment:
|
||||
POSTGRES_USER: root
|
||||
|
||||
Reference in New Issue
Block a user