Merge branch 'feature/JobsEndpoint' into develop
This commit is contained in:
6
.idea/.idea.MilkyShots/.idea/copilot.data.migration.agent.xml
generated
Normal file
6
.idea/.idea.MilkyShots/.idea/copilot.data.migration.agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AgentMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/.idea.MilkyShots/.idea/copilot.data.migration.edit.xml
generated
Normal file
6
.idea/.idea.MilkyShots/.idea/copilot.data.migration.edit.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="EditMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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>
|
||||
2
.idea/.idea.MilkyShots/.idea/watcherTasks.xml
generated
2
.idea/.idea.MilkyShots/.idea/watcherTasks.xml
generated
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectTasksOptions">
|
||||
<TaskOptions isEnabled="true">
|
||||
<TaskOptions isEnabled="false">
|
||||
<option name="arguments" value="$FileName$:$FileNameWithoutExtension$.css" />
|
||||
<option name="checkSyntaxErrors" value="true" />
|
||||
<option name="description" />
|
||||
|
||||
25
.run/Compose Deployment (Local).run.xml
Normal file
25
.run/Compose Deployment (Local).run.xml
Normal 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
62
AGENTS.md
Normal 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`.
|
||||
30
Butter/Dtos/Jobs/JobStatusDto.cs
Normal file
30
Butter/Dtos/Jobs/JobStatusDto.cs
Normal 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();
|
||||
}
|
||||
6
Butter/Dtos/Jobs/PastJobsResponse.cs
Normal file
6
Butter/Dtos/Jobs/PastJobsResponse.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Butter.Dtos.Jobs;
|
||||
|
||||
public class PastJobsResponse {
|
||||
public List<JobStatusDto> Jobs { get; init; } = [];
|
||||
public int Total { get; init; }
|
||||
}
|
||||
@@ -3,9 +3,12 @@ using Butter.Settings;
|
||||
namespace Butter.Dtos.Settings;
|
||||
|
||||
public class SettingDto {
|
||||
public required string Name { get; set; } = string.Empty;
|
||||
public string? Value { get; set; } = string.Empty;
|
||||
public required string? Description { get; set; }
|
||||
public required EType Type { get; set; }
|
||||
public required string Name { get; set; } = string.Empty;
|
||||
public string? Value { get; set; } = string.Empty;
|
||||
public required string? Description { get; set; }
|
||||
public required EType Type { get; set; }
|
||||
public required DisplayType DisplayType { get; set; }
|
||||
}
|
||||
// for range type settings min max and step are used.
|
||||
//
|
||||
public string[]? Options { get; set; }
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ public enum DisplayType {
|
||||
Checkbox = 4,
|
||||
Switch = 5,
|
||||
DateTimePicker = 6,
|
||||
TimePicker = 7
|
||||
TimePicker = 7,
|
||||
Po2W = 8, // Power of 2
|
||||
}
|
||||
|
||||
public static class DisplayTypeExtension {
|
||||
|
||||
@@ -6,6 +6,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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Lactose.Jobs;
|
||||
namespace Butter.Types;
|
||||
|
||||
public enum EJobStatus {
|
||||
Queued, // Not yet started
|
||||
@@ -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);
|
||||
|
||||
121
Lactose/Controllers/JobsController.cs
Normal file
121
Lactose/Controllers/JobsController.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
188
Lactose/Jobs/IntegrityCheckJob.cs
Normal file
188
Lactose/Jobs/IntegrityCheckJob.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using Butter.Settings;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Utils;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
public class IntegrityCheckJob (ILogger<IntegrityCheckJob> logger, IServiceProvider serviceProvider): Job{
|
||||
const float totalSteps = 8;
|
||||
|
||||
public override string Name => "Integrity Check Job";
|
||||
|
||||
void IncrementProgress(float f) => JobStatus.UpdateProgress(f/totalSteps, "Database connection successful.");
|
||||
|
||||
protected override async Task TaskJob(CancellationToken token) {
|
||||
|
||||
float progress = 0;
|
||||
logger.LogInformation("Starting integrity check job...");
|
||||
var scope = serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
|
||||
|
||||
//Step 1
|
||||
// Check database connection
|
||||
if (!dbContext.Database.CanConnect()) {
|
||||
logger.LogError("Cannot connect to the database.");
|
||||
JobStatus.Fail("Cannot connect to the database.");
|
||||
return;
|
||||
}
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Step 2
|
||||
// Check for pending migrations
|
||||
var pendingMigrations = dbContext.Database.GetPendingMigrations().ToList();
|
||||
|
||||
if (pendingMigrations.Any()) {
|
||||
logger.LogWarning("There are pending migrations:");
|
||||
|
||||
foreach (var migration in pendingMigrations) logger.LogWarning($"- {migration}");
|
||||
|
||||
// Apply migrations
|
||||
try {
|
||||
dbContext.Database.Migrate();
|
||||
logger.LogInformation("Applied pending migrations successfully.");
|
||||
} catch (Exception ex) {
|
||||
logger.LogError(ex, "Error applying migrations.");
|
||||
JobStatus.Fail("Error applying migrations.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// A bit of context: Load() will load all entities from the table into the DbContext.
|
||||
// Essentially it will ask the DB for all rows in the table and create entities for them in the DbContext.
|
||||
// This will basically warm up the cache of the DbContext so that when we later access all the rows in the table,
|
||||
// it won't lazy load them one by one, one call at the time.
|
||||
// This is important because lazy loading one by one would be a huge performance hit,
|
||||
// especially if there are many rows in the table.
|
||||
// While for folders it might not be a big deal, this is just for consistency with all the other steps,
|
||||
// especially the asset one.
|
||||
|
||||
// Step 3
|
||||
// Check for folders existence
|
||||
dbContext.Folders.Load();
|
||||
foreach (Folder folder in dbContext.Folders.ToList()) {
|
||||
if (!Directory.Exists(folder.BasePath)) {
|
||||
logger.LogWarning($"Folder {folder.Id} does not exist.");
|
||||
folder.Active = false;
|
||||
dbContext.Folders.Update(folder);
|
||||
}
|
||||
|
||||
var affectedAssets = dbContext.Assets.Where(a => a.FolderId == folder.Id);
|
||||
dbContext.Assets.RemoveRange(affectedAssets);
|
||||
dbContext.SaveChanges();
|
||||
logger.LogWarning("Removed {Count} assets associated with folder {FolderId}.", affectedAssets.Count(), folder.Id);
|
||||
}
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Step 4
|
||||
// Check for assets existence
|
||||
dbContext.Assets.Load();
|
||||
foreach (Asset asset in dbContext.Assets) {
|
||||
if (File.Exists(asset.OriginalPath)) continue;
|
||||
dbContext.Assets.Remove(asset);
|
||||
logger.LogWarning($"Asset {asset.Id} does not exist. Removing from database.");
|
||||
}
|
||||
|
||||
int changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Removed {Changes} invalid entries from the database.", changes);
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Step 5
|
||||
// Check for empty albums
|
||||
dbContext.Albums.Load();
|
||||
dbContext.Albums.Include(a => a.Assets)
|
||||
.Where(a => a.Assets!.Count == 0)
|
||||
.ToList().ForEach(a => {
|
||||
logger.LogWarning($"Album {a.Id} is empty. Removing from database.");
|
||||
dbContext.Albums.Remove(a);
|
||||
});
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Removed {Changes} empty albums from the database.", changes);
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Step 6
|
||||
// Check for Persons without faces
|
||||
dbContext.People.Include(p => p.Faces)
|
||||
.Where(p => p.Faces != null && p.Faces.Count == 0)
|
||||
.AsEnumerable()
|
||||
.ForEach(p => {
|
||||
logger.LogWarning($"Person {p.Id} has no faces. Removing from database.");
|
||||
dbContext.People.Remove(p);
|
||||
});
|
||||
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Removed {Changes} people without faces from the database.", changes);
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Step 7
|
||||
// Check for orphaned thumbnails
|
||||
dbContext.Settings.Load();
|
||||
dbContext.Assets.Load();
|
||||
var thumbnailPath = dbContext.Settings.First(s => s.Name == Settings.ThumbnailPath.AsString()).Value;
|
||||
|
||||
if (!Directory.Exists(thumbnailPath)) {
|
||||
logger.LogWarning("Thumbnail path does not exist. Creating...");
|
||||
|
||||
try {
|
||||
if (thumbnailPath != null)
|
||||
Directory.CreateDirectory(thumbnailPath);
|
||||
} catch (Exception e) {
|
||||
logger.LogError(e, "Could not create thumbnail directory.");
|
||||
}
|
||||
|
||||
logger.LogWarning("Marking all assets as missing thumbnails.");
|
||||
dbContext.Assets.ForEach(a => a.ThumbnailPath = "");
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Marked {Changes} assets as missing thumbnails.", changes);
|
||||
} else {
|
||||
dbContext.Assets.Where(a => !String.IsNullOrEmpty(a.ThumbnailPath)).AsEnumerable()
|
||||
.Where(a => !File.Exists(a.ThumbnailPath)).AsEnumerable()
|
||||
.ForEach(a => {
|
||||
logger.LogWarning($"Asset {a.Id} has a missing thumbnail. Marking as missing.");
|
||||
a.ThumbnailPath = "";
|
||||
dbContext.Assets.Update(a);
|
||||
});
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Marked {Changes} assets as missing thumbnails.", changes);
|
||||
}
|
||||
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Step 8
|
||||
// Check for orphaned previews
|
||||
|
||||
var previewPath = dbContext.Settings.First(s => s.Name == Settings.PreviewPath.AsString()).Value;
|
||||
if (!Directory.Exists(previewPath)) {
|
||||
logger.LogWarning("Preview path does not exist. Creating...");
|
||||
|
||||
try {
|
||||
if (previewPath != null)
|
||||
Directory.CreateDirectory(previewPath);
|
||||
} catch (Exception e) {
|
||||
logger.LogError(e, "Could not create preview directory.");
|
||||
}
|
||||
|
||||
logger.LogWarning("Marking all assets as missing previews.");
|
||||
dbContext.Assets.ForEach(a => a.PreviewPath = "");
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Marked {Changes} assets as missing previews.", changes);
|
||||
} else {
|
||||
dbContext.Assets.Where(a => !String.IsNullOrEmpty(a.PreviewPath)).AsEnumerable()
|
||||
.Where(a => !File.Exists(a.PreviewPath)).AsEnumerable()
|
||||
.ForEach(a => {
|
||||
logger.LogWarning($"Asset {a.Id} has a missing preview. Marking as missing.");
|
||||
a.PreviewPath = "";
|
||||
dbContext.Assets.Update(a);
|
||||
});
|
||||
}
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Marked {Changes} assets as missing previews.", changes);
|
||||
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Complete
|
||||
logger.LogInformation("Integrity check completed successfully.");
|
||||
JobStatus.Complete("Integrity check completed successfully.");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
125
Lactose/Jobs/PHashJob.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
201
Lactose/Jobs/ThumbnailJob.cs
Normal file
201
Lactose/Jobs/ThumbnailJob.cs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
26
Lactose/Mapper/JobMapper.cs
Normal file
26
Lactose/Mapper/JobMapper.cs
Normal 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))
|
||||
}
|
||||
};
|
||||
}
|
||||
19
Lactose/Mapper/JobRecordMapper.cs
Normal file
19
Lactose/Mapper/JobRecordMapper.cs
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,8 @@ public static class SettingsMapper {
|
||||
Value = setting.Value,
|
||||
Description = setting.Description,
|
||||
Type = setting.Type,
|
||||
DisplayType = setting.DisplayType
|
||||
DisplayType = setting.DisplayType,
|
||||
Options = setting.Options
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -29,6 +30,7 @@ public static class SettingsMapper {
|
||||
Value = settingDto.Value,
|
||||
Description = settingDto.Description,
|
||||
Type = settingDto.Type,
|
||||
DisplayType = settingDto.DisplayType
|
||||
DisplayType = settingDto.DisplayType,
|
||||
Options = settingDto.Options
|
||||
};
|
||||
}
|
||||
465
Lactose/Migrations/20250903205921_ULongHash.Designer.cs
generated
Normal file
465
Lactose/Migrations/20250903205921_ULongHash.Designer.cs
generated
Normal file
@@ -0,0 +1,465 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
[DbContext(typeof(LactoseDbContext))]
|
||||
[Migration("20250903205921_ULongHash")]
|
||||
partial class ULongHash
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.15")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("AlbumsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AlbumsId", "AssetsId");
|
||||
|
||||
b.HasIndex("AssetsId");
|
||||
|
||||
b.ToTable("AlbumAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AssetsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<Guid?>("UserOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("UserOwnerId");
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<float?>("Duration")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<decimal>("Hash")
|
||||
.HasColumnType("numeric(20,0)");
|
||||
|
||||
b.Property<bool>("IsPubliclyShared")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("OwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OwnerId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BoundingBoxX1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxX2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BasePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Folders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Album", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AlbumsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "UserOwner")
|
||||
.WithMany("OwnedAlbums")
|
||||
.HasForeignKey("UserOwnerId");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
|
||||
b.Navigation("UserOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Owner")
|
||||
.WithMany("OwnedAssets")
|
||||
.HasForeignKey("OwnerId");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("AssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("PersonId");
|
||||
|
||||
b.Navigation("Asset");
|
||||
|
||||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany("SharedWith")
|
||||
.HasForeignKey("AssetId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
|
||||
b.Navigation("SharedWith");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Navigation("Albums");
|
||||
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("OwnedAlbums");
|
||||
|
||||
b.Navigation("OwnedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Lactose/Migrations/20250903205921_ULongHash.cs
Normal file
36
Lactose/Migrations/20250903205921_ULongHash.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ULongHash : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Hash",
|
||||
table: "Assets");
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "Hash",
|
||||
table: "Assets",
|
||||
type: "numeric(20,0)",
|
||||
nullable: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Hash",
|
||||
table: "Assets");
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "Hash",
|
||||
table: "Assets",
|
||||
type: "BYTEA",
|
||||
nullable: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
468
Lactose/Migrations/20250904003848_VectorExtensions.Designer.cs
generated
Normal file
468
Lactose/Migrations/20250904003848_VectorExtensions.Designer.cs
generated
Normal file
@@ -0,0 +1,468 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
[DbContext(typeof(LactoseDbContext))]
|
||||
[Migration("20250904003848_VectorExtensions")]
|
||||
partial class VectorExtensions
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.15")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("AlbumsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AlbumsId", "AssetsId");
|
||||
|
||||
b.HasIndex("AssetsId");
|
||||
|
||||
b.ToTable("AlbumAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AssetsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<Guid?>("UserOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("UserOwnerId");
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<float?>("Duration")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<BitArray>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<bool>("IsPubliclyShared")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("OwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OwnerId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BoundingBoxX1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxX2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BasePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Folders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Album", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AlbumsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "UserOwner")
|
||||
.WithMany("OwnedAlbums")
|
||||
.HasForeignKey("UserOwnerId");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
|
||||
b.Navigation("UserOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Owner")
|
||||
.WithMany("OwnedAssets")
|
||||
.HasForeignKey("OwnerId");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("AssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("PersonId");
|
||||
|
||||
b.Navigation("Asset");
|
||||
|
||||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany("SharedWith")
|
||||
.HasForeignKey("AssetId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
|
||||
b.Navigation("SharedWith");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Navigation("Albums");
|
||||
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("OwnedAlbums");
|
||||
|
||||
b.Navigation("OwnedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
43
Lactose/Migrations/20250904003848_VectorExtensions.cs
Normal file
43
Lactose/Migrations/20250904003848_VectorExtensions.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Collections;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class VectorExtensions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:PostgresExtension:vector", ",,");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Hash",
|
||||
table: "Assets");
|
||||
migrationBuilder.AddColumn<BitArray>(
|
||||
name: "Hash",
|
||||
table: "Assets",
|
||||
type: "bit(64)",
|
||||
nullable: false,
|
||||
defaultValue: new BitArray(64));
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
|
||||
|
||||
migrationBuilder.DropColumn("Hash", "Assets");
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "Hash",
|
||||
table: "Assets",
|
||||
type: "numeric(20,0)",
|
||||
nullable: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
471
Lactose/Migrations/20250905024004_AddedSettingsOptions.Designer.cs
generated
Normal file
471
Lactose/Migrations/20250905024004_AddedSettingsOptions.Designer.cs
generated
Normal file
@@ -0,0 +1,471 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
[DbContext(typeof(LactoseDbContext))]
|
||||
[Migration("20250905024004_AddedSettingsOptions")]
|
||||
partial class AddedSettingsOptions
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.15")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vectors");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("AlbumsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AlbumsId", "AssetsId");
|
||||
|
||||
b.HasIndex("AssetsId");
|
||||
|
||||
b.ToTable("AlbumAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AssetsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<Guid?>("UserOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("UserOwnerId");
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<float?>("Duration")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<BitArray>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<bool>("IsPubliclyShared")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("OwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OwnerId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BoundingBoxX1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxX2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BasePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Folders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string[]>("Options")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Album", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AlbumsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "UserOwner")
|
||||
.WithMany("OwnedAlbums")
|
||||
.HasForeignKey("UserOwnerId");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
|
||||
b.Navigation("UserOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Owner")
|
||||
.WithMany("OwnedAssets")
|
||||
.HasForeignKey("OwnerId");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("AssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("PersonId");
|
||||
|
||||
b.Navigation("Asset");
|
||||
|
||||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany("SharedWith")
|
||||
.HasForeignKey("AssetId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
|
||||
b.Navigation("SharedWith");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Navigation("Albums");
|
||||
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("OwnedAlbums");
|
||||
|
||||
b.Navigation("OwnedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Lactose/Migrations/20250905024004_AddedSettingsOptions.cs
Normal file
29
Lactose/Migrations/20250905024004_AddedSettingsOptions.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddedSettingsOptions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
migrationBuilder.AddColumn<string[]>(
|
||||
name: "Options",
|
||||
table: "Settings",
|
||||
type: "text[]",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Options",
|
||||
table: "Settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
510
Lactose/Migrations/20260706141705_AddJobRecords.Designer.cs
generated
Normal file
510
Lactose/Migrations/20260706141705_AddJobRecords.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
42
Lactose/Migrations/20260706141705_AddJobRecords.cs
Normal file
42
Lactose/Migrations/20260706141705_AddJobRecords.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
33
Lactose/Models/JobRecord.cs
Normal file
33
Lactose/Models/JobRecord.cs
Normal 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; }
|
||||
}
|
||||
@@ -16,4 +16,6 @@ public class Setting {
|
||||
public required EType Type { get; set; }
|
||||
|
||||
public required DisplayType DisplayType { get; set; }
|
||||
|
||||
public string[]? Options { get; set; }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Npgsql;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
using System.Collections;
|
||||
using System.Data;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
@@ -42,6 +44,34 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids) => context.Assets.Where(a => ids.Contains(a.Id));
|
||||
|
||||
public Asset? FindByPath(string path) => context.Assets.FirstOrDefault(a => a.OriginalPath == path);
|
||||
|
||||
public IEnumerable<Asset> GetAssetsMissingPHash(out int count) {
|
||||
var empty = new BitArray(64);
|
||||
count = context.Assets.Count(a => a.Hash == empty);
|
||||
return context.Assets.Where(a => a.Hash == empty);
|
||||
}
|
||||
|
||||
public IEnumerable<List<Asset>> GetDuplicates() => context.Assets
|
||||
.GroupBy(a => new { a.Hash })
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.ToList());
|
||||
|
||||
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, int distance) {
|
||||
if(distance < 0 || distance > 63)
|
||||
throw new ArgumentOutOfRangeException(nameof(distance), "Distance must be between 0 and 64.");
|
||||
|
||||
return context.Assets
|
||||
.OrderBy(a => a.HammingDistance(phash))
|
||||
.Where(a => a.Hash.HammingDistance(phash) <= distance);
|
||||
}
|
||||
|
||||
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, float distance)
|
||||
=> GetWithinHammingDistance(phash, (int)(distance * 64));
|
||||
|
||||
public IEnumerable<Asset> GetAssetsMissingThumbnail(out int count) {
|
||||
count = context.Assets.Count(a => a.ThumbnailPath == null || a.ThumbnailPath == "");
|
||||
return context.Assets.Where(a => a.ThumbnailPath == null || a.ThumbnailPath == "");
|
||||
}
|
||||
|
||||
public void Dispose() => context.Dispose();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Lactose.Models;
|
||||
using System.Collections;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -71,4 +72,13 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <param name="filePath">The file path of the asset.</param>
|
||||
/// <returns>Null if not found, otherwise the requested asset.</returns>
|
||||
Asset? FindByPath(string filePath);
|
||||
|
||||
/// <summary>
|
||||
/// Finds all assets that are missing a perceptual hash (pHash).
|
||||
/// </summary>
|
||||
IEnumerable<Asset> GetAssetsMissingPHash(out int count);
|
||||
|
||||
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, float distance);
|
||||
|
||||
IEnumerable<Asset> GetAssetsMissingThumbnail(out int totalAssets);
|
||||
}
|
||||
|
||||
14
Lactose/Repositories/IJobRecordRepository.cs
Normal file
14
Lactose/Repositories/IJobRecordRepository.cs
Normal 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();
|
||||
}
|
||||
59
Lactose/Repositories/JobRecordRepository.cs
Normal file
59
Lactose/Repositories/JobRecordRepository.cs
Normal 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();
|
||||
}
|
||||
@@ -13,7 +13,7 @@ interface IDbInitializer {
|
||||
void Initialize();
|
||||
}
|
||||
|
||||
class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHasher) : IDbInitializer {
|
||||
internal class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHasher) : IDbInitializer {
|
||||
public void Initialize() {
|
||||
ArgumentNullException.ThrowIfNull(dbContext, nameof(dbContext));
|
||||
|
||||
@@ -52,19 +52,33 @@ class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHa
|
||||
if (settings is not null) {
|
||||
//remove all settings that are not in the default settings file
|
||||
var existingSettings = dbContext.Settings.ToList();
|
||||
foreach (Setting existingSetting in existingSettings
|
||||
.Where(existingSetting => settings.All(s => s.Name != existingSetting.Name)))
|
||||
dbContext.Settings.Remove(existingSetting);
|
||||
// add or update settings from the default settings file leaving value unchanged
|
||||
|
||||
foreach (Setting existingSetting in existingSettings.Where(existingSetting
|
||||
=> settings.All(s => s.Name != existingSetting.Name)
|
||||
)) dbContext.Settings.Remove(existingSetting);
|
||||
|
||||
settings.First(s => s.Name == Settings.ThumbnailPath.AsString()).Value
|
||||
= Path.Combine(Environment.CurrentDirectory, "thumbnails");
|
||||
|
||||
settings.First(s => s.Name == Settings.PreviewPath.AsString()).Value
|
||||
= Path.Combine(Environment.CurrentDirectory, "previews");
|
||||
|
||||
// set max concurrent jobs to number of processors - 2, at least one. (who the fuck is using a single core cpu?)
|
||||
settings.First(s => s.Name == Settings.MaxConcurrentJobs.AsString()).Value
|
||||
= (Math.Max(Environment.ProcessorCount - 2, 1)).ToString();
|
||||
|
||||
// add or update settings from the default settings file leaving value unchanged
|
||||
foreach (var setting in settings) {
|
||||
if (!dbContext.Settings.Any(s => s.Name == setting.Name)) { dbContext.Settings.Add(setting); } else {
|
||||
var dbSetting = dbContext.Settings.First(s => s.Name == setting.Name);
|
||||
dbSetting.Description = setting.Description;
|
||||
dbSetting.Type = setting.Type;
|
||||
dbSetting.DisplayType = setting.DisplayType;
|
||||
dbSetting.Options = setting.Options;
|
||||
dbContext.Settings.Update(dbSetting);
|
||||
}
|
||||
}
|
||||
|
||||
dbContext.SaveChanges();
|
||||
} //else, we are a bit fucked.
|
||||
|
||||
@@ -84,5 +98,6 @@ class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHa
|
||||
dbContext.SaveChanges();
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Jobs;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
using System.Collections;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Lactose.Utils;
|
||||
|
||||
public static class EnumerableExtensions {
|
||||
public static void ForEach<TSource>(this IEnumerable<TSource> source, Action<TSource> action) {
|
||||
|
||||
foreach (var item in source) {
|
||||
action(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class HashExtensions {
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static BitArray ToBitArray(this ulong hash) => new BitArray(BitConverter.GetBytes(hash));
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static ulong ToUlong(this BitArray bits) {
|
||||
if (bits.Length > 64) throw new ArgumentException("BitArray length must be at most 64 bits.");
|
||||
var array = new byte[8];
|
||||
bits.CopyTo(array, 0);
|
||||
return BitConverter.ToUInt64(array, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Lactose.Services" : "Trace"
|
||||
"Lactose.Services" : "Information"
|
||||
}
|
||||
},
|
||||
"SignKey": {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"System": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Lactose.Services" : "Trace"
|
||||
}
|
||||
"LogLevel": {
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Lactose.Services" : "Trace"
|
||||
}
|
||||
},
|
||||
"DatabaseAddress": {
|
||||
"Host": "database",
|
||||
"Port": 3306
|
||||
"Port": 5432
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Lactose": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
37
MilkStream.Client/Components/CollapsibleSection.razor
Normal file
37
MilkStream.Client/Components/CollapsibleSection.razor
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
135
MilkStream.Client/Components/JobRow.razor
Normal file
135
MilkStream.Client/Components/JobRow.razor
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
19
MilkStream.Client/Components/JobSection.razor
Normal file
19
MilkStream.Client/Components/JobSection.razor
Normal 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; }
|
||||
}
|
||||
69
MilkStream.Client/Components/JobTree.razor
Normal file
69
MilkStream.Client/Components/JobTree.razor
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
203
MilkStream.Client/Components/Pages/Jobs.razor
Normal file
203
MilkStream.Client/Components/Pages/Jobs.razor
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
92
MilkStream.Client/Components/Pages/SettingsRework.razor
Normal file
92
MilkStream.Client/Components/Pages/SettingsRework.razor
Normal 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();
|
||||
}
|
||||
}
|
||||
62
MilkStream.Client/Components/Pages/User.razor
Normal file
62
MilkStream.Client/Components/Pages/User.razor
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
32
MilkStream.Client/Components/PollRateSelector.razor
Normal file
32
MilkStream.Client/Components/PollRateSelector.razor
Normal 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);
|
||||
}
|
||||
}
|
||||
31
MilkStream.Client/Components/ProgressBar.razor
Normal file
31
MilkStream.Client/Components/ProgressBar.razor
Normal 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}"
|
||||
};
|
||||
}
|
||||
@@ -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 */ }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
72
MilkStream.Client/Services/JobsService.cs
Normal file
72
MilkStream.Client/Services/JobsService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
3
MilkStream.Client/wwwroot/appsettings.Development.json
Normal file
3
MilkStream.Client/wwwroot/appsettings.Development.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"BaseUrl": "http://localhost:5162/"
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,6 +8,9 @@ EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lactose", "Lactose\Lactose.csproj", "{054E855F-A730-4FA4-895B-5147AF1877D9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MilkStream", "MilkStream\MilkStream.csproj", "{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}"
|
||||
ProjectSection(ProjectDependencies) = postProject
|
||||
{054E855F-A730-4FA4-895B-5147AF1877D9} = {054E855F-A730-4FA4-895B-5147AF1877D9}
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "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
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConcurrentDictionary_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc3a41f4911b2871fe46ff44169115d8ffb305313e0fe77907cfeca42acf5e9_003FConcurrentDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConfigurationExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fb8c1c57742337e59591581af3eb2c647e9ed52d2951655fd74b1facf3ff520_003FConfigurationExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConfigurationManager_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbbcfe7ab04c9c5e854e8084459eb29d1ba45cca1ecfec1ad876fafa1267_003FConfigurationManager_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F9e99606fc18442e3a934d0cf2081796f1de928_003F8f_003F76fd927b_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003AC_0021_003FUsers_003Fnivek_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F96a4cc66aa48dda8a9d8be8febec4a769fc143f7a86dd53ecdebfb9c3177d_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADefaultInterpolatedStringHandler_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fc1c946eaa6d8ddaaea1b63e936ca8ed2791d9316c5b025a41d445891e8a59ecd_003FDefaultInterpolatedStringHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADefaultJsonTypeInfoResolver_002EConverters_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F16171b2c80ba9499f621a6bdb243caf2a6f19af216cfead9653432cd5aae_003FDefaultJsonTypeInfoResolver_002EConverters_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
@@ -24,6 +25,7 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExecutionContext_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9eda537f15ea23cdfae523c19e87eb303a3ded88937ae7e55919387a43f70_003FExecutionContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFSharpCoreReflectionProxy_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F74e6dceae40f3123c8fc6d1fb41fb1f5bd4eb9214623f4ab4943efe5fc52ad9_003FFSharpCoreReflectionProxy_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFSharpTypeConverterFactory_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff5b0593df95acacf3652937716a4f2a2135151dd5a2ce4d133d4a43608c32af_003FFSharpTypeConverterFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFuture_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6f90c6c13479b8c2a5be98f3d75dfc3bd885a055652d8a32904ca2448132949e_003FFuture_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpResponseMessage_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F21d9c87abd9d4814ab095cd6be92e1911a6928_003F86_003Fcd4a7567_003FHttpResponseMessage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIFileInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fnivek_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F781590a6c8e24e6b9f85d75a1ac30c819918_003F47_003Fda5e789a_003FIFileInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fnivek_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9fcad18e31a1983467a9fd4bfef344ee97d72fa4ab6ba20353b8f78db8437b1_003FFileInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
@@ -44,6 +46,7 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2832e8c2b81f4641b3863f406ce3a519c90938_003F1c_003F3b0d22ec_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd882146b4f265f10bcbec2663fce248db9ffec5fa1aeaf76e32a11ba5eafcd6_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F4363be3555734c96804bc429c863e56ab2e200_003F2f_003F2c688ff0_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F36f346b6c0454bc8a6afa7aed38119fe5bbffcc983298d9bfa4dbd5f49461f_003FMonitor_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F36f346b6c0454bc8a6afa7aed38119fe5bbffcc983298d9bfa4dbd5f49461f_003FMonitor_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMonitor_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2b90705926ae4f10aa01bbe3f1025828c90908_003F1a_003F5b817523_003FMonitor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANullable_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5acc345db3c207bc9d886a36ff14867ef8d65557432172c2a42f19aeac04d1b_003FNullable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
@@ -61,9 +64,11 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARuntimeCustomAttributeData_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F167c292414d4cdbd966d4cb3bfda345c71ff4f9f149871eb4f4c65b6371ec63_003FRuntimeCustomAttributeData_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARuntimeType_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3791ade316feeb2be344648da3c7d31719ff492f7733b643bf8c24d7629883_003FRuntimeType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASerializationExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F1fa9d27d36b95177aad139996d2e72727f1a5e60fcc839c2f1ac67d031ca5_003FSerializationExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F7d81b2d4f22bee75e5438c707251ae43cb0974c207db91ffc159118c84b4eb9_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASessionMiddlewareExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F7c7ebbdc5d4270fb17dee7c3eb17d64c7845f617f3c679ce4e22ad4bd11192d_003FSessionMiddlewareExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASessionStorageService_002Eg_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc5abb9d0ed5bcffd706887e8cabcd2f1947e67c_003FSessionStorageService_002Eg_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AString_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2832e8c2b81f4641b3863f406ce3a519c90938_003F06_003F3b8d7821_003FString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATask_002Ecs_002Fl_003AC_0021_003FUsers_003Fairon_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fa77b231cea33ff338c41dc7869d6c493d4dae137ff51e342173efa61d933_003FTask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATask_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2832e8c2b81f4641b3863f406ce3a519c90938_003F27_003F7cad2316_003FTask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThreadPoolWorkQueue_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fac24a7c2e0f296e231c484b3fd930268f76c06dc75f4aad1df7aaf09f2c1227_003FThreadPoolWorkQueue_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2c8e7ca976f350cba9836d5565dac56b11e0b56656fa786460eb1395857a6fa_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
@@ -72,5 +77,10 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUtf8JsonWriterCache_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F96d87fca98b4167c66ffae61c9ee88dc182d6e7dbc7eb8dbf41297e660eb_003FUtf8JsonWriterCache_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AValueTask_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9ec7c6b688aa4b89a1477dc1b679f62bf856b50196f4b8d19cd77f86df0abc_003FValueTask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWriteStackFrame_002Ecs_002Fl_003AC_0021_003FUsers_003FREDCODE_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcfb06811acbbb25f4c1f3ae4eee74f65da5b2e9bd1bdc01ad979ac9b6745e9_003FWriteStackFrame_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/Highlighting/SweaWarningsMode/@EntryValue">ShowAndRun</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/ACCESSOR_OWNER_BODY/@EntryValue">ExpressionBody</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/ThisQualifier/INSTANCE_MEMBERS_QUALIFY_DECLARED_IN/@EntryValue">0</s:String></wpf:ResourceDictionary>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/ThisQualifier/INSTANCE_MEMBERS_QUALIFY_DECLARED_IN/@EntryValue">0</s:String>
|
||||
<s:Boolean x:Key="/Default/Dpa/IsEnabledInDebug/@EntryValue">True</s:Boolean>
|
||||
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue"><AssemblyExplorer>
|
||||
<Assembly Path="C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.16\ref\net8.0\System.Collections.Concurrent.dll" />
|
||||
</AssemblyExplorer></s:String></wpf:ResourceDictionary>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user