Merge branch 'feature/JobsEndpoint' into develop

This commit is contained in:
2026-07-06 18:30:25 +02:00
80 changed files with 4498 additions and 204 deletions

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/80498c22-67da-4c4a-8106-c4789470ef83/console.sql" value="80498c22-67da-4c4a-8106-c4789470ef83" />
</component>
</project>

View File

@@ -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" />

View File

@@ -0,0 +1,25 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Compose Deployment (Local)" type="docker-deploy" factoryName="docker-compose.yml" activateToolWindowBeforeRun="false" server-name="Docker">
<deployment type="docker-compose.yml">
<settings>
<option name="containerName" value="" />
<option name="envVars">
<list>
<DockerEnvVarImpl>
<option name="name" value="ASPNETCORE_ENVIRONMENT" />
<option name="value" value="Docker" />
</DockerEnvVarImpl>
</list>
</option>
<option name="removeImagesOnComposeDown" value="LOCAL" />
<option name="removeOrphansOnComposeDown" value="false" />
<option name="commandLineOptions" value="--build" />
<option name="sourceFilePath" value="docker-compose.yml" />
<option name="upForceRecreate" value="true" />
<option name="upRemoveOrphans" value="true" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
<method v="2" />
</configuration>
</component>

62
AGENTS.md Normal file
View File

@@ -0,0 +1,62 @@
# MilkyShots Agent Guide
## Projects (4 in solution)
| Project | Role | Entrypoint |
|---|---|---|
| **Butter** | Shared class library (DTOs, enums, MIME types) | — |
| **Lactose** | ASP.NET Core Web API — controllers, EF Core, repos, background jobs | `Lactose/Program.cs` |
| **MilkStream** | Blazor WASM host — serves WASM files + dynamic `/appsettings.json` | `MilkStream/Program.cs` |
| **MilkStream.Client** | Blazor WASM client (runs in browser) — Razor components, SCSS, frontend services | `MilkStream.Client/Program.cs` |
## Build & run
```bash
dotnet build MilkyShots.sln # .NET 8, C# 12
dotnet run --project Lactose # API on :5162 (docker host port) / :8080 (container)
dotnet run --project MilkStream # WASM host on :8080
```
**Prerequisite:** PostgreSQL with `pgvecto-rs` extension (see `docker-compose.yml`). Default DB credentials in `appsettings.json`.
## Docker
```bash
docker compose up -d # starts lactose, milkstream, database
```
- Lactose DB port mapping: host `3306` → container `5432` (postgres default)
- Media volume: `./storageImages:/diary:ro`
- `CorsAllowedOrigins` is semicolon-separated env var; value `*` = allow any origin
- `LactoseBaseUrl` on MilkStream must be the URL the **browser** uses to reach Lactose
## Unconventional conventions
- **`HttpPut` = create, `HttpPost("{id}")` = update** (not REST-idiomatic; do not "fix" without team buy-in)
- **Soft delete only** — set `DeletedAt`, never hard-delete
- **Repository `Save()` must be called explicitly** after insert/update/delete
- **Enum prefix `E`** (e.g. `EAccessLevel`, `EAssetType`, `EJobStatus`)
- **JWT access token expires in 10 min**, refresh token in 60 min
## CORS gotchas
- `CorsAllowedOrigins` env var **replaces** the JSON array entirely
- MilkStream browser origin must be listed in Lactose's CORS
- Lactose URL that the browser sees goes into MilkStream's `LactoseBaseUrl`
## EF Core / Migrations
Migrations are in `Lactose/Migrations/` — generated, do not hand-edit.
```bash
dotnet ef migrations add <Name> --project Lactose
dotnet ef database update --project Lactose
```
## Tests
No test project found in the solution. If adding tests, use the repo's existing tooling style.
## Git
Conventional Commits enforced via `cliff.toml`. Changelog generated with `git-cliff`.

View File

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

View File

@@ -0,0 +1,6 @@
namespace Butter.Dtos.Jobs;
public class PastJobsResponse {
public List<JobStatusDto> Jobs { get; init; } = [];
public int Total { get; init; }
}

View File

@@ -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; }
}

View File

@@ -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 {

View File

@@ -6,6 +6,12 @@ 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
JobRetentionDays //Number of days to keep completed/failed/canceled jobs
}
public static class SettingsExtensions {
@@ -16,6 +22,12 @@ 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",
Settings.JobRetentionDays => "Job Retention Days",
_ => setting.ToString() // Fallback to the enum name
};
}

View File

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

View File

@@ -10,11 +10,13 @@ public class LactoseDbContext : DbContext {
public DbSet<User> Users { get; set; }
public DbSet<Tag> Tags { get; set; }
public DbSet<Folder> Folders { get; set; }
public DbSet<Setting> Settings { get; set; }
public DbSet<Setting> Settings { get; set; }
public DbSet<JobRecord> JobRecords { get; set; }
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);

View File

@@ -0,0 +1,121 @@
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 {
/// <summary>
/// Returns active root jobs only — no children, no past jobs. Lightweight, polled frequently.
/// </summary>
[HttpGet]
[Authorize(Roles = "Admin")]
public ActionResult<List<JobStatusDto>> GetActiveRoots() {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
return Ok(jobManager.GetActiveRootJobs());
}
/// <summary>
/// Returns a page of past root jobs with total count. Polled less frequently or on demand.
/// </summary>
[HttpGet("past")]
[Authorize(Roles = "Admin")]
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 1, [FromQuery] int pageSize = 5) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var (jobs, total) = jobManager.GetPastRootJobs(page, pageSize);
return Ok(new PastJobsResponse { Jobs = jobs, Total = total });
}
/// <summary>
/// Returns children of a specific parent job (active + past).
/// </summary>
[HttpGet("{parentId}/children")]
[Authorize(Roles = "Admin")]
public ActionResult<List<JobStatusDto>> GetChildren(Guid parentId) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
return Ok(jobManager.GetChildren(parentId));
}
[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.GetJobStatus(jobId);
if (job == null) { return NotFound(); }
return Ok(job);
}
[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.");
try { jobScheduler.QueuePreviewGeneration(null); }
catch (NotImplementedException) { return StatusCode(501, "PreviewGeneration is not yet implemented."); }
return Ok();
case EJobType.MetadataExtraction:
if (jobRequest.Parameters.Count != 0)
return BadRequest("MetadataExtraction job does not require any parameters.");
try { jobScheduler.QueueMetadataExtraction(null); }
catch (NotImplementedException) { return StatusCode(501, "MetadataExtraction is not yet implemented."); }
return Ok();
case EJobType.IntegrityCheck:
if (jobRequest.Parameters.Count != 0)
return BadRequest("IntegrityCheck job does not require any parameters.");
jobScheduler.QueueIntegrityCheck(null);
return Ok();
case EJobType.PHashGeneration:
if (jobRequest.Parameters.Count > 1)
return BadRequest("PHashGeneration job takes zero or one parameter (the asset ID).");
jobScheduler.QueuePHashGeneration(jobRequest.Parameters.FirstOrDefault());
return Ok();
default: return BadRequest("Invalid job type.");
}
}
}

View File

@@ -1,37 +1,104 @@
[
{
"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"
]
},
{
"Name": "Job Retention Days",
"Value": "30",
"Description": "Number of days to keep completed/failed/canceled jobs before automatic cleanup.",
"Type": 1,
"DisplayType": 2,
"Options": [
"1",
"365",
"1"
]
}
]

View File

@@ -2,35 +2,40 @@ 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;
Guid folderId;
ILogger<FileSystemCrawlJob> logger;
JobManager jobManager;
List<Job> childJobs = [];
public FileSystemCrawlJob(
Guid folderId,
string workingPath,
ILogger<FileSystemCrawlJob> logger,
IAssetRepository assetRepository,
JobManager jobManager
) {
Status = new(this);
JobStatus = new(this);
this.logger = logger;
this.assetRepository = assetRepository;
this.workingPath = workingPath;
this.folderId = folderId;
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,28 +59,35 @@ public class FileSystemCrawlJob : Job {
foreach (var folder in folders) {
if (token.IsCancellationRequested) {
Status.Cancel();
// Cancel all child jobs
lock (childJobs) { childJobs.ForEach(j => j.Cancel()); }
// Report this job as canceled
JobStatus.Cancel();
return;
}
steps++;
var job = jobManager.CreateJob<FileSystemCrawlJob>(folder.FullName);
var job = jobManager.CreateJob<FileSystemCrawlJob>(folderId, folder.FullName);
job.ParentJobId = Id;
// When the job is done, remove it from the child jobs list
job.Done += (o, _) => childJobs.Remove((o as Job)!);
childJobs.Add(job);
job.Done += (o, _) => { lock (childJobs) { childJobs.Remove((o as Job)!); } };
lock (childJobs) { 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
lock (childJobs) { 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 +106,29 @@ 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;
int pendingCount;
lock (childJobs) { pendingCount = childJobs.Count; }
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Waiting for {pendingCount} child jobs to complete.");
JobStatus.Wait();
while (childJobs.Count > 0) {
while (true) {
lock (childJobs) { if (childJobs.Count == 0) break; }
if (token.IsCancellationRequested) {
// Cancel all child jobs
childJobs.ForEach(j => j.Cancel());
lock (childJobs) { childJobs.ForEach(j => j.Cancel()); }
// Cancel this job as well
Status.Cancel();
JobStatus.Cancel();
return;
}
try {
await Task.Delay(50, token).ConfigureAwait(false);
} catch (OperationCanceledException) {
lock (childJobs) { childJobs.ForEach(j => j.Cancel()); }
JobStatus.Cancel();
return;
}
}
Status.Complete("Crawl completed successfully.");
JobStatus.Complete("Crawl completed successfully.");
}
Asset? AssetFromPath(string filePath) {
@@ -128,7 +150,7 @@ public class FileSystemCrawlJob : Job {
// Create a new asset
var asset = new Asset() {
//TODO: folder ID is missing here
FolderId = folderId,
OriginalPath = finfo.FullName,
OriginalFilename = finfo.Name,
Type = type.Value,
@@ -141,7 +163,7 @@ public class FileSystemCrawlJob : Job {
_ => throw new ArgumentOutOfRangeException()
},
FileSize = finfo.Length,
Hash = []
Hash = new BitArray(64)
};
logger.LogDebug(

View File

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

View File

@@ -1,3 +1,5 @@
using Butter.Types;
namespace Lactose.Jobs;
/// <summary>
@@ -7,34 +9,42 @@ namespace Lactose.Jobs;
/// </summary>
public abstract class Job {
public virtual Guid Id { get; } = Guid.NewGuid();
public virtual Guid? ParentJobId { get; set; }
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 +56,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.

View File

@@ -1,28 +1,107 @@
using Butter.Dtos.Jobs;
using Butter.Types;
using Lactose.Mapper;
using Lactose.Repositories;
using Lactose.Utils;
using System.Collections.Concurrent;
namespace Lactose.Jobs;
public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> logger) : BackgroundService {
readonly ConcurrentDictionary<Guid, Job> jobs = new();
readonly ConcurrentDictionary<Guid, Job> activeJobs = new();
readonly ConcurrentDictionary<Guid, JobStatusDto> pastJobs = new();
/// <summary>
/// Gets all jobs currently managed.
/// Gets all active (in-memory) jobs.
/// </summary>
public IEnumerable<Job> Jobs => jobs.Values;
public IEnumerable<Job> Jobs => activeJobs.Values;
/// <summary>
/// Returns only active root jobs (ParentJobId == null) — no children, no past jobs.
/// </summary>
public List<JobStatusDto> GetActiveRootJobs() {
var results = new List<JobStatusDto>(activeJobs.Count);
foreach (var job in activeJobs.Values) {
if (job.ParentJobId is null) {
results.Add(job.ToJobStatusDto());
}
}
return results;
}
/// <summary>
/// Returns a page of past root jobs from the database.
/// </summary>
public (List<JobStatusDto> Jobs, int TotalCount) GetPastRootJobs(int page, int pageSize) {
using var scope = serviceProvider.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
var records = repo.GetPastRootJobs(page, pageSize, out var total);
logger.LogInformation("GetPastRootJobs page={Page} pageSize={PageSize}: {Count} records out of {Total}", page, pageSize, records.Count, total);
return (records.Select(r => r.ToJobStatusDto()).ToList(), total);
}
/// <summary>
/// Returns children of a given parent job — checks active jobs first, then the database.
/// </summary>
public List<JobStatusDto> GetChildren(Guid parentId) {
var results = new List<JobStatusDto>();
// Active children still in memory
foreach (var job in activeJobs.Values) {
if (job.ParentJobId == parentId) {
results.Add(job.ToJobStatusDto());
}
}
// Past children in the database
try {
using var scope = serviceProvider.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
var records = repo.GetChildren(parentId);
foreach (var record in records) {
results.Add(record.ToJobStatusDto());
}
} catch (Exception ex) {
logger.LogWarning(ex, "Could not load children for parent {ParentId}", parentId);
}
return results;
}
/// <summary>
/// Removes past job entries from the in-memory cache that are older than the cutoff,
/// keeping it in sync with database cleanup.
/// </summary>
public void CleanupPastJobs(DateTime cutoff) {
var ids = pastJobs
.Where(kvp => (kvp.Value.Finished ?? kvp.Value.Created) < cutoff)
.Select(kvp => kvp.Key)
.ToList();
foreach (var id in ids) {
pastJobs.TryRemove(id, out _);
}
logger.LogInformation("Cleaned up {Count} past job(s) from in-memory cache.", ids.Count);
}
/// <summary>
/// Gets a job status DTO by ID, checking active jobs first then past jobs.
/// </summary>
public JobStatusDto? GetJobStatus(Guid id) {
if (activeJobs.TryGetValue(id, out var job)) return job.ToJobStatusDto();
if (pastJobs.TryGetValue(id, out var dto)) return dto;
return null;
}
/// <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.
/// </summary>
/// <param name="job">The job to enqueue.</param>
/// <exception cref="InvalidOperationException"></exception>
public void EnqueueJob(Job job) {
var added = jobs.TryAdd(job.Id, job);
var added = activeJobs.TryAdd(job.Id, job);
if (!added) throw new InvalidOperationException($"Job with ID {job.Id} can't be added");
logger.LogInformation($"Job with ID {job.Id} has been added to the job manager.");
}
@@ -30,37 +109,30 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
/// <summary>
/// Tries to get a job by its ID.
/// </summary>
/// <param name="id">ID of the job to retrieve.</param>
/// <returns> Returns null if the job can't be found. Otherwise a <see cref="Job"/></returns>
public Job? GetJob(Guid id) {
jobs.TryGetValue(id, out var job);
activeJobs.TryGetValue(id, out var job);
return job;
}
/// <summary>
/// Tries to remove a job from the manager. If the job is running, it will be canceled first and removed once the cancellation is complete.
/// </summary>
/// <param name="id">ID of the job that has to be removed</param>
/// <exception cref="InvalidOperationException">Thrown when a job with the provided ID can't be found.</exception>
public void RemoveJob(Guid id) {
if (!jobs.ContainsKey(id)) throw new InvalidOperationException($"Job with ID {id} doesn't exist");
if (!activeJobs.ContainsKey(id)) throw new InvalidOperationException($"Job with ID {id} doesn't exist");
switch (jobs[id].Status.Status) {
switch (activeJobs[id].JobStatus.Status) {
case EJobStatus.Running:
case EJobStatus.Waiting:
// Schedule job removal from the dictionary once it's canceled
jobs[id].Canceled += (sender, status) => jobs.Remove(status.Id, out _);
//request cancellation
jobs[id].Cancel();
activeJobs[id].Canceled += (sender, status) => activeJobs.Remove(status.Id, out _);
activeJobs[id].Cancel();
logger.LogInformation($"Job with ID {id} has been canceled and will be removed from the job manager once the cancellation is complete.");
break;
// for any other cases it's safe to remove the job straight away
case EJobStatus.Queued:
case EJobStatus.Completed:
case EJobStatus.Canceled:
case EJobStatus.Failed:
logger.LogInformation($"Job with ID {id} has been removed from the job manager.");
jobs.Remove(id, out _);
activeJobs.Remove(id, out _);
break;
}
}
@@ -68,8 +140,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 +150,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.");
@@ -87,22 +160,59 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
///<inheritdoc />
protected override Task ExecuteAsync(CancellationToken stoppingToken) {
logger.LogInformation("Preparing JobManager...");
// Obtain the application lifetime service to hook into application start events
// Load past jobs from database on startup
try {
using var scope = serviceProvider.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
foreach (var record in repo.GetAll()) {
pastJobs.TryAdd(record.Id, record.ToJobStatusDto());
}
logger.LogInformation($"Loaded {pastJobs.Count} past job(s) from database.");
} catch (Exception ex) {
logger.LogWarning(ex, "Could not load past jobs from database.");
}
var lifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
// Create a new task that will manage job execution
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 = activeJobs.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)
activeJobs.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) => {
var j = (Job)sender!;
var dto = j.ToJobStatusDto();
try {
using var scope = serviceProvider.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
var record = new Models.JobRecord {
Id = dto.Id,
ParentJobId = dto.ParentJobId,
Name = dto.Name,
JobType = dto.JobType,
Status = dto.Status,
Created = dto.Created,
Started = dto.Started,
Finished = dto.Finished,
Message = dto.Message,
Progress = dto.Progress
};
repo.Insert(record);
repo.Save();
} catch (Exception ex) {
logger.LogError(ex, "Failed to persist job {JobId}", status.Id);
}
pastJobs.TryAdd(j.Id, dto);
activeJobs.Remove(j.Id, out _);
logger.LogInformation("Done handler: job {JobId} ({Name}) moved to past. Active count: {Count}", j.Id, j.Name, activeJobs.Count);
};
job.Start();
logger.LogInformation($"Job {job.Id} : {job.Name} has been started.");
}
@@ -111,7 +221,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
},
stoppingToken
);
// Defer the job management task to when application starts
lifetime.ApplicationStarted.Register(() => service.Start());
return service;
}

View File

@@ -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;
}
}
}

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

@@ -0,0 +1,125 @@
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;
ParentJobId = parentJob.Id;
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;
}
JobStatus.Complete("All pHash calculations completed.");
} else {
// This is a sub-job
if (asset == null) {
Logger.LogError("Sub-job started without an asset. Job failed.");
JobStatus.Fail("Sub-job started without an asset.");
return;
}
JobStatus.Start();
Logger.LogDebug($"Calculating pHash for asset {asset.Id} | {asset.OriginalPath}");
try {
PerceptualHash pHash = new();
using var image = Image.Load<Rgba32>(asset.OriginalPath);
asset.Hash = pHash.Hash(image).ToBitArray();
} catch (ArgumentNullException ex) {
Logger.LogError(ex, $"Image at path {asset.OriginalPath} could not be found. Setting hash to 0.");
asset.Hash = new BitArray(64);
JobStatus.Fail(ex.Message);
} catch (UnknownImageFormatException ex) {
Logger.LogError(
ex,
$"Image at path {asset.OriginalPath} is in an unknown or unsupported format. Setting hash to 0."
);
asset.Hash = new BitArray(64);
JobStatus.Fail(ex.Message);
} catch (InvalidImageContentException ex) {
Logger.LogError(ex, $"Image at path {asset.OriginalPath} is corrupted or unreadable. Setting hash to 0.");
asset.Hash = new BitArray(64);
JobStatus.Fail(ex.Message);
} catch (Exception ex) {
Logger.LogError(
ex,
$"Failed to calculate pHash for asset {asset.Id} | {asset.OriginalPath}. Setting hash to 0."
);
asset.Hash = new BitArray(64);
JobStatus.Fail(ex.Message);
}
assetRepository.Update(asset);
assetRepository.Save();
var msg = $"Completed pHash calculation for asset {asset.Id} | {asset.OriginalPath}";
Logger.LogInformation(msg);
JobStatus.Complete(msg);
}
}
void IncrementProcessedAssets() {
processedAssets++;
JobStatus.UpdateProgress((float)processedAssets / totalAssets);
}
}

View File

@@ -0,0 +1,201 @@
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;
ParentJobId = parentJob.Id;
ThumbnailSize = thumbnailSize;
ThumbnailPath = thumbnailPath;
}
protected override async Task TaskJob(CancellationToken token) {
if (ParentJob == null) MasterJob(token);
else SlaveJob(token);
}
void SlaveJob(CancellationToken token) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
}
if (Asset == null) {
Logger.LogError("Sub-job started without an asset. Canceling job.");
JobStatus.Fail("Sub-job started without an asset. Canceling job.");
return;
}
JobStatus.Start();
try {
if (string.IsNullOrEmpty(Asset.OriginalPath) || !File.Exists(Asset.OriginalPath)) {
var msg = $"Original file for asset ID {Asset.Id} not found at path {Asset.OriginalPath}";
Logger.LogWarning(msg);
JobStatus.Fail(msg);
return;
}
using var image = Image.Load(Asset.OriginalPath);
image.Mutate(x => x.Resize(
new ResizeOptions {
Size = new Size(ThumbnailSize),
Mode = ResizeMode.Max,
Sampler = KnownResamplers.CatmullRom
}
)
);
if (ThumbnailPath == null) {
var msg = "Thumbnail path is not set. Cannot save thumbnail.";
Logger.LogError(msg);
JobStatus.Fail(msg);
return;
}
var path = PathFromGuid(Asset.Id, ThumbnailPath);
if (!Directory.Exists(path)) Directory.CreateDirectory(Path.GetDirectoryName(path)!);
image.SaveAsJpeg(
path,
new JpegEncoder() {
ColorType = JpegEncodingColor.Rgb,
Quality = 75,
SkipMetadata = true
}
);
Asset.ThumbnailPath = path;
AssetRepository.Update(Asset);
AssetRepository.Save();
} catch (Exception ex) {
Logger.LogError(ex, $"Failed to generate thumbnail for asset ID {Asset.Id}");
JobStatus.Fail($"Error: {ex.Message}");
return;
}
Logger.LogInformation($"Thumbnail generated for asset ID {Asset.Id} at {Asset.ThumbnailPath}");
JobStatus.Complete($"Thumbnail generated for asset ID {Asset.Id} at {Asset.ThumbnailPath}");
}
void MasterJob(CancellationToken token) {
if (JobManager == null) {
Logger.LogError("JobManager is not available. Cannot create sub-jobs.");
JobStatus.Fail("JobManager is not available. Cannot create sub-jobs.");
return;
}
JobStatus.Start();
Logger.LogInformation("Starting master Thumbnail job for all assets.");
var thumbPath = SettingsRepository.Get(Settings.ThumbnailPath.AsString());
//TODO: Add thumbnail size setting to the system
if (thumbPath == null) {
Logger.LogError("Thumbnail path not found. Cannot proceed with thumbnail generation.");
JobStatus.Fail("Thumbnail path not found. Cannot proceed with thumbnail generation.");
return;
}
var missingThumbnail = AssetRepository.GetAssetsMissingThumbnail(out totalAssets);
Logger.LogInformation($"Found {totalAssets} assets missing thumbnails.");
subJobs = new List<ThumbnailJob>();
foreach (var asset in missingThumbnail) {
if (token.IsCancellationRequested) {
lock (subJobs) subJobs.ForEach(j => j.Cancel());
JobStatus.Cancel("Cancellation requested from user.");
return;
}
// Create a sub-job for each asset
var job = JobManager.CreateJob<ThumbnailJob>(this, asset, 256, thumbPath.Value!);
job.Done += (o, _) => {
IncrementProcessedAssets();
lock (subJobs) subJobs.Remove((o as ThumbnailJob)!);
};
lock (subJobs) subJobs.Add(job);
JobManager.EnqueueJob(job);
}
JobStatus.Wait();
// Wait for all sub-jobs to complete
while (subJobs.Count > 0) {
if (token.IsCancellationRequested) {
lock (subJobs) subJobs.ForEach(j => j.Cancel());
JobStatus.Cancel("User requested cancellation.");
return;
}
}
JobStatus.Complete("All thumbnails generated successfully.");
}
void IncrementProcessedAssets() {
processedAssets++;
JobStatus.UpdateProgress((float)processedAssets / totalAssets, $"Waiting for {subJobs.Count} sub-jobs to complete.");
}
static string PathFromGuid(Guid id, string root) {
var s = id.ToString("N"); // 32 chars, no dashes
return Path.Combine(
root,
s[..2], // 3F
s.Substring(2, 2), // 25
s.Substring(4, 2), // 04
$"{s}.jpg"
);
}
}

View File

@@ -10,6 +10,7 @@
</PropertyGroup>
<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>

View File

@@ -0,0 +1,26 @@
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,
ParentJobId = job.ParentJobId,
Name = job.Name,
Status = job.JobStatus.Status,
Progress = job.JobStatus.Progress,
Message = job.JobStatus.Message,
Created = job.JobStatus.Created,
Started = job.JobStatus.Started,
Finished = job.JobStatus.Finished,
JobType = job switch {
FileSystemCrawlJob => EJobType.FileSystemScan,
ThumbnailJob => EJobType.ThumbnailGeneration,
IntegrityCheckJob => EJobType.IntegrityCheck,
PHashJob => EJobType.PHashGeneration,
_ => throw new ArgumentOutOfRangeException(nameof(job))
}
};
}

View File

@@ -0,0 +1,19 @@
using Butter.Dtos.Jobs;
using Lactose.Models;
namespace Lactose.Mapper;
public static class JobRecordMapper {
public static JobStatusDto ToJobStatusDto(this JobRecord record) => new() {
Id = record.Id,
ParentJobId = record.ParentJobId,
Name = record.Name,
JobType = record.JobType,
Status = record.Status,
Created = record.Created,
Started = record.Started,
Finished = record.Finished,
Message = record.Message,
Progress = record.Progress
};
}

View File

@@ -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
};
}

View File

@@ -0,0 +1,465 @@
// <auto-generated />
using System;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20250903205921_ULongHash")]
partial class ULongHash
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CreatedAt")
.IsRequired()
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<decimal>("Hash")
.HasColumnType("numeric(20,0)");
b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("OwnerId")
.HasColumnType("uuid");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany("SharedWith")
.HasForeignKey("AssetId");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
b.Navigation("SharedWith");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

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

View File

@@ -0,0 +1,468 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20250904003848_VectorExtensions")]
partial class VectorExtensions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CreatedAt")
.IsRequired()
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("OwnerId")
.HasColumnType("uuid");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany("SharedWith")
.HasForeignKey("AssetId");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
b.Navigation("SharedWith");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

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

View File

@@ -0,0 +1,471 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20250905024004_AddedSettingsOptions")]
partial class AddedSettingsOptions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vectors");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CreatedAt")
.IsRequired()
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("OwnerId")
.HasColumnType("uuid");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.Property<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany("SharedWith")
.HasForeignKey("AssetId");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
b.Navigation("SharedWith");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

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

View File

@@ -0,0 +1,510 @@
// <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("20260706141705_AddJobRecords")]
partial class AddJobRecords
{
/// <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.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp without time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(256)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp without time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.Property<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany("SharedWith")
.HasForeignKey("AssetId");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
b.Navigation("SharedWith");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddJobRecords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "JobRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ParentJobId = table.Column<Guid>(type: "uuid", nullable: true),
Name = table.Column<string>(type: "VARCHAR(256)", nullable: false),
JobType = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
Created = table.Column<DateTime>(type: "timestamp without time zone", nullable: false),
Started = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),
Finished = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),
Message = table.Column<string>(type: "VARCHAR(2048)", nullable: true),
Progress = table.Column<float>(type: "real", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_JobRecords", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JobRecords");
}
}
}

View File

@@ -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, "vector");
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");
@@ -219,6 +221,45 @@ namespace Lactose.Migrations
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp without time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(256)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp without time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
@@ -256,6 +297,9 @@ namespace Lactose.Migrations
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.Property<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");

View File

@@ -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.

View File

@@ -0,0 +1,33 @@
using Butter.Dtos.Jobs;
using Butter.Types;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lactose.Models;
public class JobRecord {
[Key]
public Guid Id { get; init; }
public Guid? ParentJobId { get; set; }
[Required]
[Column(TypeName = "VARCHAR(256)")]
public string Name { get; set; } = null!;
public EJobType JobType { get; set; }
public EJobStatus Status { get; set; }
[Required]
public DateTime Created { get; init; }
public DateTime? Started { get; set; }
public DateTime? Finished { get; set; }
[Column(TypeName = "VARCHAR(2048)")]
public string? Message { get; set; }
public float Progress { get; set; }
}

View File

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

View File

@@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Npgsql;
using Pgvector.EntityFrameworkCore;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -74,7 +75,7 @@ builder.Services.AddDbContext<LactoseDbContext>(
#endif
;*/
options.UseNpgsql(strBuilder.ConnectionString)
options.UseNpgsql(strBuilder.ConnectionString, o => o.UseVector())
#if DEBUG
.LogTo(Console.WriteLine)
.EnableSensitiveDataLogging()
@@ -99,9 +100,11 @@ builder.Services.AddTransient<ITagRepository, TagRepository>();
builder.Services.AddTransient<IAssetRepository, AssetRepository>();
builder.Services.AddTransient<IAlbumRepository, AlbumRepository>();
builder.Services.AddTransient<IMediaRepository, MediaRepository>();
builder.Services.AddTransient<IJobRecordRepository, JobRecordRepository>();
builder.Services.AddTransient<ITokenService, TokenService>();
builder.Services.AddTransient<LactoseAuthService>();
builder.Services.AddSingleton<JobManager>();
builder.Services.AddSingleton<JobScheduler>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddControllers();
@@ -179,7 +182,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()) {

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -0,0 +1,14 @@
using Lactose.Models;
namespace Lactose.Repositories;
public interface IJobRecordRepository : IDisposable {
void Insert(JobRecord job);
void Update(JobRecord job);
List<JobRecord> GetAll();
List<JobRecord> GetPastRootJobs(int page, int pageSize, out int total);
List<JobRecord> GetChildren(Guid parentId);
JobRecord? GetById(Guid id);
int DeleteOldJobs(DateTime cutoff);
void Save();
}

View File

@@ -0,0 +1,59 @@
using Butter.Types;
using Lactose.Context;
using Lactose.Models;
namespace Lactose.Repositories;
public class JobRecordRepository(LactoseDbContext context) : IJobRecordRepository {
public void Insert(JobRecord job) {
context.JobRecords.Add(job);
}
public void Update(JobRecord job) {
context.JobRecords.Update(job);
}
public List<JobRecord> GetAll() =>
context.JobRecords
.OrderByDescending(j => j.Finished ?? j.Created)
.ToList();
static readonly EJobStatus[] PastStatuses = [EJobStatus.Completed, EJobStatus.Failed, EJobStatus.Canceled];
public List<JobRecord> GetPastRootJobs(int page, int pageSize, out int total) {
var query = context.JobRecords
.Where(j => j.ParentJobId == null && PastStatuses.Contains(j.Status));
total = query.Count();
return query
.OrderByDescending(j => j.Finished ?? j.Created)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
}
public List<JobRecord> GetChildren(Guid parentId) =>
context.JobRecords
.Where(j => j.ParentJobId == parentId)
.OrderBy(j => j.Created)
.ToList();
public JobRecord? GetById(Guid id) =>
context.JobRecords.Find(id);
public int DeleteOldJobs(DateTime cutoff) {
var old = context.JobRecords
.Where(j => j.Finished != null && j.Finished < cutoff)
.ToList();
var count = old.Count;
if (count > 0) {
context.JobRecords.RemoveRange(old);
}
return count;
}
public void Save() => context.SaveChanges();
public void Dispose() => context.Dispose();
}

View File

@@ -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
}
}

View File

@@ -1,4 +1,5 @@
using Butter.Settings;
using Butter.Types;
using Lactose.Jobs;
using Lactose.Models;
using Lactose.Repositories;
@@ -8,6 +9,7 @@ namespace Lactose.Services;
public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler> logger) : BackgroundService {
bool folderScanEnabled = false;
Timer? fileSystemCrawl;
Timer? jobCleanup;
List<Folder>? folders;
JobManager? jobManager;
@@ -30,14 +32,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...");
@@ -69,6 +63,41 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
fileSystemCrawl?.Change(TimeSpan.Zero, TimeSpan.FromMinutes(interval));
logger?.LogInformation($"Periodic folder scanning interval updated to {interval} minutes.");
}
// Job cleanup timer
int.TryParse(settingsRepository.Get(Settings.JobRetentionDays.AsString())?.Value, out var retentionDays);
if (retentionDays > 0) {
if (jobCleanup == null) {
jobCleanup = new Timer(RunJobCleanup, null, TimeSpan.Zero, TimeSpan.FromHours(1));
logger?.LogInformation($"Job cleanup is now enabled with {retentionDays} day retention, checking every hour.");
}
} else {
jobCleanup?.Dispose();
jobCleanup = null;
logger?.LogInformation("Job cleanup is now disabled (retention set to 0 or invalid).");
}
}
void RunJobCleanup(object? state) {
try {
using var scope = serviceProvider.CreateScope();
var settingsRepository = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
int.TryParse(settingsRepository.Get(Settings.JobRetentionDays.AsString())?.Value, out var retentionDays);
if (retentionDays <= 0) return;
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
var deleted = repo.DeleteOldJobs(cutoff);
repo.Save();
if (jobManager != null) {
jobManager.CleanupPastJobs(cutoff);
}
logger?.LogInformation("Job cleanup: deleted {Count} old job record(s) older than {Days} day(s).", deleted, retentionDays);
} catch (Exception ex) {
logger?.LogWarning(ex, "Job cleanup failed.");
}
}
void OnFolderAdded(object? sender, Folder e) {
@@ -83,10 +112,76 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
}
void OnSettingsChanged(object? sender, Setting e) {
switch (e) {
case var s when s.Name == Settings.FolderScanEnabled.AsString() || s.Name == Settings.FolderScanInterval.AsString():
FetchSettings();
break;
var name = e.Name;
if (name == Settings.FolderScanEnabled.AsString()
|| name == Settings.FolderScanInterval.AsString()
|| name == Settings.JobRetentionDays.AsString()) {
FetchSettings();
}
}
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.Id, f.BasePath)); });
logger?.LogInformation("Queued file system crawl jobs.");
}
internal void QueueThumbnailGeneration(object? o) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue thumbnail generation.");
return;
}
// don't queue if there's already a thumbnail job in progress
if (jobManager.Jobs.Any(j => j is ThumbnailJob && j.JobStatus.Status is EJobStatus.Running or EJobStatus.Queued)) {
logger?.LogInformation("Thumbnail job is already in progress, cannot queue another at this moment.");
return;
}
jobManager.EnqueueJob(jobManager.CreateJob<ThumbnailJob>());
logger?.LogInformation("Queued thumbnail job.");
}
internal void QueuePreviewGeneration(object? o) {
throw new NotImplementedException();
}
internal void QueueMetadataExtraction(object? o) {
throw new NotImplementedException();
}
internal void QueueIntegrityCheck(object? o) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue integrity check.");
return;
}
// don't queue if there's already an integrity check job in progress
if (jobManager.Jobs.Any(j => j is IntegrityCheckJob && j.JobStatus.Status is EJobStatus.Running or EJobStatus.Queued)) {
logger?.LogInformation("Integrity check job is already in progress, cannot queue another at this moment.");
return;
}
jobManager.EnqueueJob(jobManager.CreateJob<IntegrityCheckJob>());
logger?.LogInformation("Queued integrity check job.");
}
internal void QueuePHashGeneration(string? firstOrDefault) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue pHash generation.");
return;
}
// don't queue if there's already a pHash job in progress
if (jobManager.Jobs.Any(j => j is PHashJob && j.JobStatus.Status is EJobStatus.Running or EJobStatus.Queued)) {
logger?.LogInformation("pHash job is already in progress, cannot queue another at this moment.");
return;
}
jobManager.EnqueueJob(jobManager.CreateJob<PHashJob>());
logger?.LogInformation("Queued pHash job.");
}
}

View File

@@ -1,10 +1,25 @@
using System.Collections;
using System.Runtime.CompilerServices;
namespace Lactose.Utils;
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);
}
}

View File

@@ -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": {

View File

@@ -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
}
}

View File

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

View File

@@ -0,0 +1,37 @@
@* Generic collapsible section with clickable header and chevron toggle *@
<div class="@Class">
<div class="d-flex align-items-center gap-2 cursor-pointer" @onclick="Toggle" role="button">
@if (TitleContent is not null) {
@TitleContent
} else if (!string.IsNullOrEmpty(Title)) {
<span>@Title</span>
}
<span class="ms-auto small @(Expanded ? "bi-chevron-up" : "bi-chevron-down")"></span>
</div>
@if (Expanded) {
<div class="@ContentClass">
@ChildContent
</div>
}
</div>
@code {
[Parameter] public string? Title { get; set; }
[Parameter] public RenderFragment? TitleContent { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public bool InitiallyExpanded { get; set; }
[Parameter] public string? Class { get; set; }
[Parameter] public string? ContentClass { get; set; }
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
bool Expanded { get; set; }
protected override void OnInitialized() {
Expanded = InitiallyExpanded;
}
async Task Toggle() {
Expanded = !Expanded;
await ExpandedChanged.InvokeAsync(Expanded);
}
}

View File

@@ -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";

View File

@@ -0,0 +1,135 @@
@using Butter.Dtos.Jobs
@using Butter.Types
@using MilkStream.Client.Services
@inject JobsService JobsService
<div class="@($"job-row {RowClass}")" style="padding-left: @(Level * 24 + 8)px">
<div class="d-flex align-items-center gap-2 py-1 px-1 job-row-main rounded" @onclick="Toggle" role="button">
<span class="flex-shrink-0">@((MarkupString)Icon)</span>
<span class="text-truncate flex-grow-1 small" title="@Job.Name">@Job.Name</span>
@if (ChildSummaryText is not null) {
<span class="flex-shrink-0 text-muted small me-1">@ChildSummaryText</span>
}
<span class="flex-shrink-0" title="@Job.Status.ToString()">
<span class="d-inline-block rounded-circle" style="width: 8px; height: 8px; background: @DotColor"></span>
</span>
<span class="flex-shrink-0" style="width: 90px;">
<ProgressBar Value="@Job.Progress" Color="@StatusColor" />
</span>
<span class="flex-shrink-0 text-muted small" style="width: 60px; text-align: right;">@Elapsed</span>
@if (CanCancel) {
<button class="btn btn-sm p-0 border-0 text-danger flex-shrink-0 lh-1" @onclick:stopPropagation @onclick="Cancel" title="Cancel">
<i class="bi bi-x-lg" style="font-size: 0.75rem;"></i>
</button>
}
<span class="flex-shrink-0 text-muted small @(IsExpanded ? "bi-chevron-up" : "bi-chevron-down")"></span>
</div>
@if (IsExpanded) {
<div class="job-row-details small text-muted px-2 py-1" style="border-left: 2px solid @DotColor; margin-left: 16px;">
<div class="d-flex flex-wrap gap-x-3 gap-y-0">
<span>Created: @Job.Created.ToLocalTime().ToString("g")</span>
@if (Job.Started.HasValue) {
<span>Started: @Job.Started.Value.ToLocalTime().ToString("g")</span>
}
@if (Job.Finished.HasValue) {
<span>Finished: @Job.Finished.Value.ToLocalTime().ToString("g")</span>
}
@if (Duration is not null) {
<span>Duration: @Duration.Value.ToString(@"h\:mm\:ss")</span>
}
</div>
@if (!string.IsNullOrEmpty(Job.Message)) {
<div class="mt-1" style="white-space: pre-wrap; word-break: break-word;">@Job.Message</div>
}
</div>
}
</div>
@code {
[Parameter] public required JobStatusDto Job { get; set; }
[Parameter] public int Level { get; set; }
[Parameter] public string? ChildSummaryText { get; set; }
[Parameter] public bool IsExpanded { get; set; }
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
string Icon => Job.JobType switch {
EJobType.FileSystemScan => "<i class=\"bi bi-folder2-open\"></i>",
EJobType.ThumbnailGeneration => "<i class=\"bi bi-pip\"></i>",
EJobType.PreviewGeneration => "<i class=\"bi bi-file-image\"></i>",
EJobType.MetadataExtraction => "<i class=\"bi bi-file-earmark-break\"></i>",
EJobType.IntegrityCheck => "<i class=\"bi bi-file-earmark-check\"></i>",
EJobType.PHashGeneration => "<i class=\"bi bi-hash\"></i>",
_ => "<i class=\"bi bi-question-circle\"></i>"
};
string RowClass => Job.Status switch {
EJobStatus.Completed => "",
EJobStatus.Failed => "border-start border-2 border-danger",
EJobStatus.Canceled => "",
_ => ""
};
string DotColor => Job.Status switch {
EJobStatus.Queued => "#6c757d",
EJobStatus.Running => "#0d6efd",
EJobStatus.Waiting => "#0dcaf0",
EJobStatus.Completed => "#198754",
EJobStatus.Failed => "#dc3545",
EJobStatus.Canceled => "#6c757d",
_ => "#6c757d"
};
string StatusColor => Job.Status switch {
EJobStatus.Queued => "secondary",
EJobStatus.Running => "primary",
EJobStatus.Waiting => "info",
EJobStatus.Completed => "success",
EJobStatus.Failed => "danger",
EJobStatus.Canceled => "secondary",
_ => "secondary"
};
bool CanCancel => Job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting;
string? Elapsed {
get {
var now = DateTime.Now;
TimeSpan? ts = Job.Status switch {
EJobStatus.Queued => now - Job.Created.ToLocalTime(),
EJobStatus.Running or EJobStatus.Waiting when Job.Started.HasValue
=> now - Job.Started.Value.ToLocalTime(),
EJobStatus.Completed or EJobStatus.Failed or EJobStatus.Canceled when Job.Finished.HasValue && Job.Started.HasValue
=> Job.Finished.Value.ToLocalTime() - Job.Started.Value.ToLocalTime(),
EJobStatus.Completed or EJobStatus.Failed or EJobStatus.Canceled when Job.Finished.HasValue
=> Job.Finished.Value.ToLocalTime() - Job.Created.ToLocalTime(),
_ => null
};
return ts switch {
null => null,
{ TotalMinutes: < 1 } => $"{(int)ts.Value.TotalSeconds}s",
{ TotalHours: < 1 } => $"{(int)ts.Value.TotalMinutes}m",
_ => $"{(int)ts.Value.TotalHours}h {ts.Value.Minutes}m"
};
}
}
TimeSpan? Duration {
get {
if (Job.Started is null) return null;
var end = Job.Finished ?? DateTime.UtcNow;
return end.ToLocalTime() - Job.Started.Value.ToLocalTime();
}
}
async Task Toggle() {
await ExpandedChanged.InvokeAsync(!IsExpanded);
}
async Task Cancel() {
try {
await JobsService.CancelJob(Job);
} catch {
// Silently handle — next poll will reflect actual state
}
}
}

View File

@@ -0,0 +1,19 @@
@* Section wrapper for a group of jobs with title and count *@
<div class="mb-3">
<div class="d-flex align-items-center mb-1">
<h6 class="mb-0 text-muted text-uppercase small">@Title @(Count is not null ? $"({Count})" : "")</h6>
@if (Extra is not null) {
<span class="ms-auto">@Extra</span>
}
</div>
<div class="border rounded">
@ChildContent
</div>
</div>
@code {
[Parameter] public required string Title { get; set; }
[Parameter] public int? Count { get; set; }
[Parameter] public RenderFragment? Extra { get; set; }
[Parameter] public required RenderFragment ChildContent { get; set; }
}

View File

@@ -0,0 +1,69 @@
@using Butter.Dtos.Jobs
@using Butter.Types
@if (Roots is null || Roots.Count == 0) {
<div class="text-muted small p-2 text-center">@EmptyText</div>
} else {
@foreach (var root in Roots) {
var hasFetched = _childrenCache.TryGetValue(root.Id, out var children);
var hasChildren = hasFetched ? children!.Count > 0 : true;
<JobRow Job="root"
Level="@Level"
ChildSummaryText="@ChildSummary(root, hasFetched, children)"
IsExpanded="@(_expandedParents.Contains(root.Id) && hasFetched)"
ExpandedChanged="(bool e) => OnExpanded(root.Id, e)" />
@if (_expandedParents.Contains(root.Id) && hasFetched && hasChildren) {
<JobTree Jobs="children!"
Level="@(Level + 1)"
FetchChildren="@FetchChildren"
EmptyText="" />
}
}
}
@code {
[Parameter] public required List<JobStatusDto> Jobs { get; set; }
[Parameter] public int Level { get; set; }
[Parameter] public string EmptyText { get; set; } = "No jobs";
[Parameter] public Func<Guid, Task<List<JobStatusDto>>>? FetchChildren { get; set; }
List<JobStatusDto> Roots => Jobs?
.Where(j => j.ParentJobId is null)
.OrderBy(j => j.Created)
.ToList() ?? [];
readonly HashSet<Guid> _expandedParents = [];
readonly Dictionary<Guid, List<JobStatusDto>?> _childrenCache = [];
string? ChildSummary(JobStatusDto job, bool hasFetched, List<JobStatusDto>? children) {
if (FetchChildren is null) return null;
if (!hasFetched) return "sub-jobs...";
if (children!.Count == 0) return null;
return BuildChildSummary(children!);
}
static string BuildChildSummary(List<JobStatusDto> children) {
var parts = new List<string> { $"{children.Count} sub-jobs" };
var counts = children
.GroupBy(j => j.Status.ToString())
.ToDictionary(g => g.Key, g => g.Count());
var breakdown = string.Join(", ", counts.Where(c => c.Value > 0).Select(c => $"{c.Value} {c.Key}"));
if (!string.IsNullOrEmpty(breakdown)) {
parts.Add(breakdown);
}
return string.Join(" · ", parts);
}
async Task OnExpanded(Guid id, bool expanded) {
if (expanded) {
_expandedParents.Add(id);
if (!_childrenCache.ContainsKey(id) && FetchChildren is not null) {
_childrenCache[id] = await FetchChildren(id);
}
} else {
_expandedParents.Remove(id);
}
}
}

View File

@@ -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>

View File

@@ -53,9 +53,11 @@
</button>
<button class="btn btn-lg 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>
<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>
</ul>
@@ -96,6 +98,12 @@
void OnSettingsClick() => navigation.NavigateTo("/settings");
void OnSettings1Click() => navigation.NavigateTo("/Settings1");
void OnJobsClick() => navigation.NavigateTo("/jobs");
void OnProfileClick() => navigation.NavigateTo($"/User/{loginService.AuthInfo?.UserId}", true);
async Task OnLogout() {
_ = loginService.Logout();
await localStorage.RemoveItemAsync("auth");

View File

@@ -0,0 +1,203 @@
@page "/Jobs"
@implements IDisposable
@using Butter.Dtos.Jobs
@using Butter.Types
@using MilkStream.Client.Services
@inject JobsService JobsService
@inject LoginService LoginService
<div class="d-flex align-items-center justify-content-between mb-2">
<h3 class="mb-0">Jobs</h3>
<div class="d-flex align-items-center gap-2">
<span class="small text-muted">Poll:</span>
<PollRateSelector Value="pollInterval" ValueChanged="OnPollIntervalChanged" />
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-primary btn-sm" @onclick="LaunchScan"><i class="bi bi-folder2-open me-1"></i>Scan Files</button>
<button class="btn btn-primary btn-sm" @onclick="LaunchThumb"><i class="bi bi-pip me-1"></i>Make Thumbs</button>
<button class="btn btn-primary btn-sm" @onclick="LaunchPHash"><i class="bi bi-hash me-1"></i>PHash</button>
<button class="btn btn-primary btn-sm" @onclick="LaunchMeta"><i class="bi bi-file-earmark-break me-1"></i>Scan Metadata</button>
<button class="btn btn-warning btn-sm" @onclick="LaunchIntegrityCheck"><i class="bi bi-file-earmark-check me-1"></i>Integrity check</button>
</div>
@if (errorMessage != null) {
<div class="alert alert-danger alert-dismissible py-2 small" role="alert">
@errorMessage
<button type="button" class="btn-close py-2" @onclick="() => errorMessage = null"></button>
</div>
}
@* ── Current Jobs (active roots, polled) ── *@
<JobSection Title="Current Jobs" Count="@activeRoots.Count">
@if (activeRoots.Count == 0) {
<div class="text-muted small p-2 text-center">No active jobs</div>
} else {
<JobTree Jobs="activeRoots" FetchChildren="FetchChildren" EmptyText="" />
}
</JobSection>
@* ── Past Jobs (paginated, loaded on demand) ── *@
<JobSection Title="Past Jobs" Count="@pastJobTotal">
@if (pastJobRoots.Count == 0 && pastJobTotal == 0) {
<div class="text-muted small p-2 text-center">No past jobs</div>
} else if (pastJobRoots.Count == 0) {
<div class="text-muted small p-2 text-center">Loading...</div>
} else {
<JobTree Jobs="pastJobRoots" FetchChildren="FetchChildren" EmptyText="" />
@if (pastJobTotal > pastJobPageSize) {
<div class="d-flex align-items-center justify-content-center gap-2 py-1">
<button class="btn btn-sm btn-outline-secondary" disabled="@(pastJobPage <= 1)" @onclick="() => LoadPastJobs(pastJobPage - 1)">
<i class="bi bi-chevron-left"></i> Prev
</button>
<span class="small text-muted">@pastJobPage / @pastJobTotalPages</span>
<button class="btn btn-sm btn-outline-secondary" disabled="@(pastJobPage >= pastJobTotalPages)" @onclick="() => LoadPastJobs(pastJobPage + 1)">
Next <i class="bi bi-chevron-right"></i>
</button>
</div>
}
}
</JobSection>
@if (activeRoots.Count == 0 && pastJobRoots.Count == 0) {
<div class="text-center mt-4">
<p class="text-muted">No jobs yet. Launch a job above to get started.</p>
</div>
}
@code {
CancellationTokenSource? _pollCts;
CancellationTokenSource? _pastRefreshCts;
List<JobStatusDto> activeRoots = [];
List<JobStatusDto> pastJobRoots = [];
int pastJobPage = 1;
int pastJobPageSize = 5;
int pastJobTotal = 0;
int pastJobTotalPages => Math.Max(1, (int)Math.Ceiling((double)pastJobTotal / pastJobPageSize));
string? errorMessage;
TimeSpan pollInterval = TimeSpan.FromSeconds(1);
protected override async Task OnInitializedAsync() {
LoginService.LoggedUserChanged += async (_, _) => {
await RefreshActiveRoots();
await LoadPastJobs(1);
};
if (LoginService.IsLoggedIn) {
await RefreshActiveRoots();
await LoadPastJobs(1);
StartPolling();
StartPastRefresh();
}
}
// ── Active root polling ──
async Task RefreshActiveRoots() {
try {
activeRoots = await JobsService.GetActiveRootJobs();
errorMessage = null;
} catch (Exception ex) {
errorMessage = ex.Message;
}
StateHasChanged();
}
void StartPolling() {
_pollCts?.Cancel();
_pollCts = new CancellationTokenSource();
_ = PollLoop(_pollCts.Token);
}
async Task PollLoop(CancellationToken ct) {
while (!ct.IsCancellationRequested) {
try {
await Task.Delay(pollInterval, ct);
if (ct.IsCancellationRequested) return;
await RefreshActiveRoots();
} catch (OperationCanceledException) { return; }
catch (ObjectDisposedException) { return; }
}
}
// ── Past job loading (paginated) ──
async Task LoadPastJobs(int page) {
pastJobPage = page;
try {
var (jobs, total) = await JobsService.GetPastRootJobs(pastJobPage, pastJobPageSize);
pastJobRoots = jobs;
pastJobTotal = total;
errorMessage = null;
} catch (Exception ex) {
errorMessage = ex.Message;
}
StateHasChanged();
}
void StartPastRefresh() {
_pastRefreshCts?.Cancel();
_pastRefreshCts = new CancellationTokenSource();
_ = PastRefreshLoop(_pastRefreshCts.Token);
}
async Task PastRefreshLoop(CancellationToken ct) {
while (!ct.IsCancellationRequested) {
try {
await Task.Delay(TimeSpan.FromSeconds(10), ct);
if (ct.IsCancellationRequested) return;
await LoadPastJobs(pastJobPage); // refresh current page
} catch (OperationCanceledException) { return; }
catch (ObjectDisposedException) { return; }
}
}
// ── Lazy children ──
Task<List<JobStatusDto>> FetchChildren(Guid parentId) =>
JobsService.GetChildren(parentId);
// ── Poll rate ──
async Task OnPollIntervalChanged(TimeSpan newInterval) {
pollInterval = newInterval;
if (newInterval == TimeSpan.Zero) {
_pollCts?.Cancel();
_pollCts = null;
} else {
StartPolling();
}
}
// ── Job launch helpers ──
async Task LaunchJob(EJobType jobType) {
try {
await JobsService.StartJob(jobType);
errorMessage = null;
// Refresh past jobs page 1 — a completed job may have just been created
if (pastJobPage != 1) pastJobPage = 1;
await LoadPastJobs(pastJobPage);
} catch (HttpRequestException ex) when ((int?)ex.StatusCode == 501) {
errorMessage = $"{jobType} is not yet implemented on the server.";
} catch (HttpRequestException ex) {
errorMessage = $"Failed to start {jobType} job: {ex.Message}";
}
StateHasChanged();
}
Task LaunchScan() => LaunchJob(EJobType.FileSystemScan);
Task LaunchThumb() => LaunchJob(EJobType.ThumbnailGeneration);
Task LaunchPHash() => LaunchJob(EJobType.PHashGeneration);
Task LaunchIntegrityCheck() => LaunchJob(EJobType.IntegrityCheck);
Task LaunchMeta() => LaunchJob(EJobType.MetadataExtraction);
public void Dispose() {
_pollCts?.Cancel();
_pollCts?.Dispose();
_pastRefreshCts?.Cancel();
_pastRefreshCts?.Dispose();
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,92 @@
@page "/Settings1"
@using Butter.Dtos.Folder
@using Butter.Dtos.Settings
@using Butter.Settings
@using Butter.Types
@using MilkStream.Client.Components.SettingBoxes
@using MilkStream.Client.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() {
SettingsService.OnBeginSave();
}
}

View File

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

View File

@@ -0,0 +1,32 @@
@* Generic polling interval selector dropdown *@
<select class="form-select form-select-sm" @bind="SelectedMs" @bind:after="OnChanged" style="width: auto;">
@foreach (var opt in Options) {
<option value="@opt.Ms">@opt.Label</option>
}
</select>
@code {
[Parameter] public TimeSpan Value { get; set; }
[Parameter] public EventCallback<TimeSpan> ValueChanged { get; set; }
record RateOption(int Ms, string Label);
static readonly RateOption[] Options = [
new(1000, "1s"),
new(2000, "2s"),
new(3000, "3s"),
new(5000, "5s"),
new(10000, "10s"),
new(0, "Off")
];
int SelectedMs { get; set; } = 1000;
protected override void OnInitialized() =>
SelectedMs = Value == TimeSpan.Zero ? 0 : (int)Value.TotalMilliseconds;
async Task OnChanged() {
var ts = SelectedMs == 0 ? TimeSpan.Zero : TimeSpan.FromMilliseconds(SelectedMs);
await ValueChanged.InvokeAsync(ts);
}
}

View File

@@ -0,0 +1,31 @@
@* Generic progress bar. Value: 0.0 to 1.0. Color: Bootstrap color name (primary, success, etc). Size: sm (default), md, lg. *@
<div class="progress" role="progressbar" style="height: @Height">
<div class="progress-bar @BgClass" style="width: @(Value * 100)%">
@if (ShowPercent) { @($"{Value * 100:F0}%") }
</div>
</div>
@code {
[Parameter] public float Value { get; set; }
[Parameter] public string? Color { get; set; }
[Parameter] public bool ShowPercent { get; set; }
[Parameter] public string Size { get; set; } = "sm";
string Height => Size switch {
"sm" => "6px",
"md" => "12px",
"lg" => "20px",
_ => "6px"
};
string BgClass => (Color ?? "primary") switch {
"primary" => "text-bg-primary",
"success" => "text-bg-success",
"danger" => "text-bg-danger",
"warning" => "text-bg-warning",
"info" => "text-bg-info",
"secondary" => "text-bg-secondary",
"dark" => "text-bg-dark",
var c => $"bg-{c}"
};
}

View File

@@ -5,15 +5,17 @@
@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 += async (sender, args) => {
try { await SettingsService.UpdateSettingAsync(GetSetting()); }
var setting = GetSetting();
if (setting == null) return;
try { await SettingsService.UpdateSettingAsync(setting); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
}

View File

@@ -20,17 +20,20 @@
protected override void OnInitialized() {
SettingsService.BeginSave += async (sender, args) => {
try { await SettingsService.UpdateSettingAsync(GetSetting()); }
var setting = GetSetting();
if (setting == null) return;
try { await SettingsService.UpdateSettingAsync(setting); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
if (float.TryParse(Setting.Value, out float value)) {
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;
}

View File

@@ -8,27 +8,42 @@
<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 += async (sender, args) => {
try { await SettingsService.UpdateSettingAsync(GetSetting()); }
var setting = GetSetting();
if (setting == null) return;
try { await SettingsService.UpdateSettingAsync(setting); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
if (int.TryParse(Setting.Value, out int value)) { currentValue = value; } else {
currentValue = 0; // Default value if parsing fails
if (IsPowerOfTwo) {
var converted = int.TryParse(Setting?.Value, out int value);
currentValue = converted ? (int)Math.Log2(value) : 0; // Default value if parsing fails
} else {
currentValue = int.TryParse(Setting?.Value, out int value) ? value : 0; // Default value if parsing fails
}
}
protected override SettingDto GetSetting() {
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;
}

View File

@@ -0,0 +1,35 @@
@using Butter.Dtos.Settings
@using MilkStream.Client.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 += async (sender, args) => {
var setting = GetSetting();
if (setting == null) return;
try { await SettingsService.UpdateSettingAsync(setting); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
currentValue = Setting?.Value ?? string.Empty;
}
protected override SettingDto? GetSetting() {
if (Setting == null) return null;
Setting.Value = currentValue;
return Setting;
}
}

View File

@@ -19,15 +19,18 @@
protected override void OnInitialized() {
SettingsService.BeginSave += async (sender, args) => {
try { await SettingsService.UpdateSettingAsync(GetSetting()); }
var setting = GetSetting();
if (setting == null) return;
try { await SettingsService.UpdateSettingAsync(setting); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
if (bool.TryParse(Setting.Value, out var parsedValue)) {
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;
}

View File

@@ -14,31 +14,41 @@ public class JwtTokenRefresher(LoginService loginService, ILogger<JwtTokenRefres
CancellationToken cancellationToken
) {
var authParam = request.Headers.Authorization?.Parameter;
// If there is no Authorization header the request is anonymous — skip token refresh.
if (string.IsNullOrEmpty(authParam)) {
if (string.IsNullOrEmpty(authParam))
return await base.SendAsync(request, cancellationToken);
}
var jwt = new JsonWebToken(authParam);
// Check if the JWT is valid and not going to expire within 1 minute otherwise send the request as is.
if (jwt.ValidTo >= DateTime.UtcNow.AddMinutes(1)) {
logger.LogDebug("JWT Token valid up to {JwtValidTo}, no need to refresh.", jwt.ValidTo);
if (jwt.ValidTo < DateTime.UtcNow.AddMinutes(1)) {
logger.LogDebug("JWT Token expired or expiring soon at {JwtValidTo}, refreshing token...", jwt.ValidTo);
var auth = await loginService.Reauthenticate();
if (auth == null) {
logger.LogDebug("Failed to reauthenticate, returning unauthorized response.");
return new HttpResponseMessage(HttpStatusCode.Unauthorized) {
Content = new StringContent("Authentication failed. Please log in again.")
};
}
jwt = new JsonWebToken(auth.Token);
logger.LogDebug("JWT Token refreshed, valid up to {JwtValidTo}.", jwt.ValidTo);
request.Headers.Authorization = new("bearer", auth.Token);
}
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized) {
logger.LogDebug("Received 401, refreshing token and retrying...");
response.Dispose();
var auth = await loginService.Reauthenticate();
if (auth == null) {
logger.LogDebug("Failed to reauthenticate after 401, returning unauthorized response.");
return new HttpResponseMessage(HttpStatusCode.Unauthorized) {
Content = new StringContent("Authentication failed. Please log in again.")
};
}
request.Headers.Authorization = new("bearer", auth.Token);
return await base.SendAsync(request, cancellationToken);
}
logger.LogDebug("JWT Token expired or expiring soon at {JwtValidTo}, refreshing token...", jwt.ValidTo);
// If the JWT is expired or going to expire within 1 minute, reauthenticate.
var auth = await loginService.Reauthenticate();
if (auth == null) {
// If reauthentication fails, return an unauthorized response.
logger.LogDebug("Failed to reauthenticate, returning unauthorized response.");
return new HttpResponseMessage(HttpStatusCode.Unauthorized) {
Content = new StringContent("Authentication failed. Please log in again.")
};
}
jwt = new JsonWebToken(auth.Token);
logger.LogDebug("JWT Token refreshed, valid up to {JwtValidTo}.", jwt.ValidTo);
request.Headers.Authorization = new("bearer", auth.Token);
return await base.SendAsync(request, cancellationToken);
return response;
}
}

View File

@@ -28,6 +28,7 @@ builder.Services.AddScoped<MediaService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<SettingsService>();
builder.Services.AddScoped<FoldersService>();
builder.Services.AddScoped<JobsService>();
// Browser local storage (replaces server-side ProtectedLocalStorage).
builder.Services.AddBlazoredLocalStorage();

View File

@@ -0,0 +1,72 @@
using Butter.Dtos.Jobs;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Options;
using System.Net;
namespace MilkStream.Client.Services;
public sealed class JobsService(
IOptions<ServiceOptions> options,
IHttpClientFactory httpClientFactory,
LoginService loginService,
ILogger<JobsService> logger
) : AuthServiceBase(options, httpClientFactory, loginService, logger) {
async Task<HttpResponseMessage> SendWithRefreshAsync(Func<Task<HttpResponseMessage>> send) {
var response = await send();
if (response.StatusCode != HttpStatusCode.Unauthorized)
return response;
response.Dispose();
var auth = await loginService.Reauthenticate();
if (auth == null)
return new HttpResponseMessage(HttpStatusCode.Unauthorized);
Client.DefaultRequestHeaders.Authorization = new("Bearer", auth.Token);
return await send();
}
/// <summary>
/// Returns active root jobs only (lightweight, polled frequently).
/// </summary>
public async Task<List<JobStatusDto>> GetActiveRootJobs() {
var response = await SendWithRefreshAsync(() => Client.GetAsync("/api/jobs"));
response.EnsureSuccessStatusCode();
var jobs = await response.Content.ReadFromJsonAsync<List<JobStatusDto>>();
return jobs ?? new List<JobStatusDto>();
}
/// <summary>
/// Returns a page of past root jobs.
/// </summary>
public async Task<(List<JobStatusDto> Jobs, int Total)> GetPastRootJobs(int page, int pageSize) {
var response = await SendWithRefreshAsync(() => Client.GetAsync($"/api/jobs/past?page={page}&pageSize={pageSize}"));
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<PastJobsResponse>();
return (result?.Jobs ?? [], result?.Total ?? 0);
}
/// <summary>
/// Returns children of a specific parent job.
/// </summary>
public async Task<List<JobStatusDto>> GetChildren(Guid parentId) {
var response = await SendWithRefreshAsync(() => Client.GetAsync($"/api/jobs/{parentId}/children"));
response.EnsureSuccessStatusCode();
var jobs = await response.Content.ReadFromJsonAsync<List<JobStatusDto>>();
return jobs ?? new List<JobStatusDto>();
}
public async Task StartJob(EJobType jobType) {
var response = await Client.PutAsJsonAsync("/api/jobs", new JobRequestDto() {
JobType = jobType
});
response.EnsureSuccessStatusCode();
}
public async Task CancelJob(JobStatusDto job) {
var response = await Client.DeleteAsync($"/api/jobs/{job.Id}");
response.EnsureSuccessStatusCode();
}
}

View File

@@ -101,6 +101,7 @@ public sealed class LoginService : ServiceBase {
var authResult = await result.Content.ReadFromJsonAsync<AuthResultDto>();
authInfo!.Token = authResult?.Token;
authInfo.RefreshToken = authResult?.RefreshToken ?? authInfo.RefreshToken;
NotifyAuthInfoChanged();
return authInfo;
}

View File

@@ -20,10 +20,8 @@ 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 async Task UpdateSettingAsync(SettingDto setting) {

View File

@@ -0,0 +1,3 @@
{
"BaseUrl": "http://localhost:5162/"
}

View File

@@ -1,5 +1,4 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081

View File

@@ -1,5 +1,7 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<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>

View File

@@ -1,6 +1,6 @@
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseStaticWebAssets();
try { builder.WebHost.UseStaticWebAssets(); } catch (DirectoryNotFoundException) { }
#if DEBUG // Enable the Sass compiler in debug mode for hot reload support.
builder.Services.AddSassCompiler();

View File

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

View File

@@ -8,6 +8,9 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lactose", "Lactose\Lactose.csproj", "{054E855F-A730-4FA4-895B-5147AF1877D9}"
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}") = "MilkStream.Client", "MilkStream.Client\MilkStream.Client.csproj", "{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}"
EndProject
@@ -23,10 +26,12 @@ 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
{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -35,5 +40,6 @@ Global
{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

View File

@@ -10,6 +10,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConcurrentDictionary_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc3a41f4911b2871fe46ff44169115d8ffb305313e0fe77907cfeca42acf5e9_003FConcurrentDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_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">&lt;AssemblyExplorer&gt;&#xD;
&lt;Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.16\ref\net8.0\System.Collections.Concurrent.dll" /&gt;&#xD;
&lt;/AssemblyExplorer&gt;</s:String></wpf:ResourceDictionary>

View File

@@ -15,13 +15,17 @@
ports:
- "5162:8080"
depends_on:
- database
database:
condition: service_healthy
volumes:
- "/root/MilkyShots/storageImages:/diary:ro"
#- "./storageImages:/diary:ro"
#- "/root/MilkyShots/storageImages:/diary:ro"
- "./storageImages:/diary:ro"
#- "/root/MilkyShots/thumbs:/app/thumbnails:rw"
#- "/root/MilkyShots/previews:/app/previews:rw"
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
@@ -31,6 +35,12 @@
- "pg_dbdata:/var/lib/postgresql/data"
ports:
- "3306:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U root -d TestDb"]
interval: 3s
timeout: 5s
retries: 10
start_period: 10s
milkstream:
image: milkstream
@@ -46,4 +56,4 @@
- "8080:8080"
volumes:
pg_dbdata:
pg_dbdata: