54 Commits

Author SHA1 Message Date
c8a862b29f Merge branch 'feature/JobsEndpoint' into develop 2026-07-06 18:30:25 +02:00
db8c576c29 Config files adjustment for testing on docker 2026-07-06 18:18:26 +02:00
0a9c3ba207 feat: optimize jobs API with pagination, DB persistence, and cleanup
- Split /api/jobs into three endpoints: active roots, paginated past,
  children fetched on expand — reduces response from ~1MB to ~5KB
- Persist completed jobs to PostgreSQL via JobRecord entity + migration
- Add configurable job retention cleanup (hourly timer, default 30 days)
- Fix PHashJob master not persisting (missing Complete() call)
- Add PastJobsResponse DTO to fix case-sensitive JsonElement deserialization
- Refactor JobTree/JobRow for lazy-loaded children via FetchChildren callback
- Handle 401 responses in JwtTokenRefresher with token refresh + retry
- Remove JobChildrenSummary component (replaced by inline parent summary)
2026-07-06 18:17:49 +02:00
b9a0e2f1b6 fix: handle 401 in JobsService directly with refresh+retry
The JwtTokenRefresher DelegatingHandler was not reliably intercepting
401 responses in Blazor WASM. Add a SendWithRefreshAsync helper to
JobsService that catches 401, calls Reauthenticate() to get a fresh
token, updates DefaultRequestHeaders, and retries once. This ensures
the Jobs page transparently recovers from token expiry without
showing an error.
2026-07-06 17:42:38 +02:00
f9dea23757 chore: remove redundant XML doc comments from ServiceBase 2026-07-06 17:34:18 +02:00
4128ea1b58 fix: redirect to login when token refresh fails in JwtTokenRefresher
- Inject NavigationManager and ILocalStorageService into JwtTokenRefresher
- Extract TryRefresh() helper to avoid repeating refresh logic
- Add ForceLogout() to LoginService that clears both AuthInfo and LoggedUser,
  firing AuthInfoChanged and LoggedUserChanged events
- When an auth refresh fails (both pre-emptive and reactive paths), call
  ForceLogout(), clear persisted auth from localStorage, and redirect to /
  with a full reload so the user sees the login page
2026-07-06 17:34:08 +02:00
4876bb3e74 fix: handle 401 responses in JwtTokenRefresher by refreshing token and retrying
The handler only performed a pre-emptive check (token expiry before
sending), but never reacted to a 401 response from the server. Now
after sending, if the response is 401, the handler disposes it,
refreshes the token via Reauthenticate(), updates the request header,
and retries once. This covers clock skew, server-side invalidation,
and any other case where the server rejects a token the client
considered valid.
2026-07-06 16:55:08 +02:00
3a5729045e chore: remove temporary debug panel from Jobs page 2026-07-06 15:50:08 +02:00
902c4418ae fix: change ParentJobId to get/set for WASM compat, remove @key from JobTree, add debug panel 2026-07-06 15:36:26 +02:00
9863126262 feat: redesign Jobs page with compact hierarchical layout, poll rate selector, and PHash button; remove JobCard 2026-07-06 15:19:03 +02:00
e0508c2a22 feat: add JobRow, JobChildrenSummary, JobSection, JobTree components 2026-07-06 15:17:54 +02:00
0f505575aa feat: add generic reusable components ProgressBar, CollapsibleSection, PollRateSelector 2026-07-06 15:16:18 +02:00
0177a99449 feat: map ParentJobId in JobMapper 2026-07-06 15:15:14 +02:00
8a395da790 feat: set ParentJobId on child jobs for ThumbnailJob, FileSystemCrawlJob, PHashJob 2026-07-06 15:15:08 +02:00
af0545fdd1 feat: add ParentJobId to Job base class 2026-07-06 15:14:52 +02:00
1e13e5694e feat: add ParentJobId to JobStatusDto for job hierarchy tracking 2026-07-06 15:14:43 +02:00
0a48663d35 fix: notify auth info changed after token refresh and handle polling errors
- Fire AuthInfoChanged event after successful Reauthenticate() so all
  AuthServiceBase instances update DefaultRequestHeaders with the new
  token, preventing cascading failure on the next poll cycle
- Wrap RefreshJobs() poll body in try-catch so transient errors don't
  crash the component
2026-07-06 15:04:54 +02:00
Samuele Lorefice
0b47a8d2b2 Merge pull request #7 from SamueleLorefice/copilot/fix-hanging-jobs-and-folderid-issue 2026-07-06 15:03:18 +02:00
copilot-swe-agent[bot]
836a5472b8 fix: catch OperationCanceledException from Task.Delay to prevent incorrect Completed status on cancellation 2026-07-06 13:01:04 +00:00
491003aeac Makes the configuration work during debug on rider 2026-07-06 14:41:39 +02:00
8c5b4895ac fix: add DB healthcheck and service_healthy condition to prevent lactose startup race condition 2026-07-06 01:51:49 +02:00
e2041dd2c9 WIP 2026-07-02 13:29:33 +02:00
b16ad2d096 Merge branch 'develop' into copilot/fix-hanging-jobs-and-folderid-issue 2026-07-02 12:27:01 +02:00
copilot-swe-agent[bot]
ab5e9e7de4 Fix hanging jobs and missing FolderId in FileSystemCrawlJob
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/92e2d123-7cee-43cd-ae8f-fe89aa3954ec

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-05-06 03:38:39 +00:00
copilot-swe-agent[bot]
7b81887729 Initial plan 2026-05-06 03:33:58 +00:00
REDCODE
230d0a7d1b Merge remote-tracking branch 'GitHub/develop' into feature/JobsEndpoint 2026-04-23 04:07:04 +02:00
Samuele Lorefice
3ff8b3de8f Merge pull request #3 from SamueleLorefice/copilot/update-branch-to-be-compatible
Merge WASM frontend migration (PR #2) into feature branch
2026-04-23 04:06:07 +02:00
copilot-swe-agent[bot]
3e9f5c1164 Fix CORS errors on unimplemented job types: catch NotImplementedException in controller, add error feedback in Jobs.razor
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/3ff2b38e-490d-474f-adf2-e980d2154099

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-23 01:54:21 +00:00
copilot-swe-agent[bot]
76e6a08923 Fix build errors after merge: lowercase injection in Home.razor, null-guard in SettingNumber.razor
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/51f22dbb-c20e-4f84-8edc-e2f45d2dea1f

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-22 21:19:15 +00:00
copilot-swe-agent[bot]
a1d5e23817 Merge PR#2 (convert-frontend-to-wasm) into update-branch-to-be-compatible
Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-22 21:17:46 +00:00
Samuele Lorefice
84c1548221 Settings WIP 2025-09-09 22:01:55 +02:00
Samuele Lorefice
b183521af8 General Cleanup (and async/awaiting rules enforcing) 2025-09-09 19:32:19 +02:00
Samuele Lorefice
16f53cb931 Added User Page 2025-09-09 18:49:20 +02:00
Samuele Lorefice
b052a025a7 Small Refactor 2025-09-09 17:30:33 +02:00
Samuele Lorefice
f31369bea9 Added missing settings and power slider. Broken state 2025-09-05 04:56:53 +02:00
REDCODE
b2cfdc5af4 Fixed Race condition on Thumbnail job signaling.
Moved compose to use proper docker configs instead of normal development configs.
2025-09-05 00:48:18 +02:00
Samuele Lorefice
3dacf84a85 Some more failsafes 2025-09-04 20:40:22 +02:00
Samuele Lorefice
bd18d8f636 Tasks now fail when they fail. 2025-09-04 20:34:03 +02:00
Samuele Lorefice
19b57901f1 I am slowly losing sanity 2025-09-04 20:20:48 +02:00
Samuele Lorefice
3bee6c7939 Strictly enforces calling the correct methods to change the job status, ensuring events are fired correctly 2025-09-04 20:10:05 +02:00
Samuele Lorefice
dde98a9740 Refactoring for readability in thumb job 2025-09-04 19:53:21 +02:00
Samuele Lorefice
2b84d66e8b WIP debug for Thumbnailjob 2025-09-04 19:50:52 +02:00
Samuele Lorefice
7c2029c7e4 Wasn't done with the time yet. 2025-09-04 19:25:23 +02:00
Samuele Lorefice
ccbc75a31c fix(frontend): Fixes all of the special HttpClients to not have been specialized due to a small typo in the program init.
fix(frontend): Fixes time being interpreted as local time instead of UTC time for job cards.
feat(frontend): Adds more job types buttons on the job page
2025-09-04 19:13:59 +02:00
Samuele Lorefice
a170025520 Moved to a compatible pgvector db variant, implemented Thumbnails job 2025-09-04 04:59:15 +02:00
Samuele Lorefice
627239f0a3 Enabled vector extensions 2025-09-04 02:40:29 +02:00
REDCODE
28e53eb31b PHash Job implementation 2025-09-03 23:37:56 +02:00
Samuele Lorefice
e070031ec0 Added libraries for image processing and hashing 2025-09-03 20:56:29 +02:00
Samuele Lorefice
a4747ca417 Front end work on Jobs page 2025-09-03 20:55:57 +02:00
REDCODE
74f3584dd0 FrontEnd work (partial) 2025-09-03 03:45:03 +02:00
REDCODE
1e3a0eae53 Added IntegrityCheck job 2025-09-03 01:02:20 +02:00
REDCODE
9d6da2af06 Leveled logging settings 2025-09-02 22:35:27 +02:00
Samuele Lorefice
4beddeaa2e Added scheduler as singleton to allow JobsController to use it 2025-09-02 21:09:56 +02:00
Samuele Lorefice
b18f294db4 Scaffolded Jobs controller basics 2025-09-02 20:40:16 +02:00
80 changed files with 4498 additions and 204 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

62
AGENTS.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,11 +10,13 @@ public class LactoseDbContext : DbContext {
public DbSet<User> Users { get; set; }
public DbSet<Tag> Tags { get; set; }
public DbSet<Folder> Folders { get; set; }
public DbSet<Setting> Settings { get; set; }
public DbSet<Setting> Settings { get; set; }
public DbSet<JobRecord> JobRecords { get; set; }
public LactoseDbContext(DbContextOptions options) : base(options) {}
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.HasPostgresExtension("vector");
//Album Relationships
modelBuilder.Entity<Album>().HasOne(e => e.UserOwner).WithMany(e => e.OwnedAlbums);
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);

View File

@@ -0,0 +1,121 @@
using Butter.Dtos.Jobs;
using Butter.Types;
using Lactose.Jobs;
using Lactose.Mapper;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Lactose.Controllers;
[ApiController]
[Authorize(Roles = "Admin")]
[Route("api/[controller]")]
public class JobsController(JobManager jobManager, JobScheduler jobScheduler, LactoseAuthService authService) : ControllerBase {
/// <summary>
/// Returns active root jobs only — no children, no past jobs. Lightweight, polled frequently.
/// </summary>
[HttpGet]
[Authorize(Roles = "Admin")]
public ActionResult<List<JobStatusDto>> GetActiveRoots() {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
return Ok(jobManager.GetActiveRootJobs());
}
/// <summary>
/// Returns a page of past root jobs with total count. Polled less frequently or on demand.
/// </summary>
[HttpGet("past")]
[Authorize(Roles = "Admin")]
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 1, [FromQuery] int pageSize = 5) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var (jobs, total) = jobManager.GetPastRootJobs(page, pageSize);
return Ok(new PastJobsResponse { Jobs = jobs, Total = total });
}
/// <summary>
/// Returns children of a specific parent job (active + past).
/// </summary>
[HttpGet("{parentId}/children")]
[Authorize(Roles = "Admin")]
public ActionResult<List<JobStatusDto>> GetChildren(Guid parentId) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
return Ok(jobManager.GetChildren(parentId));
}
[HttpGet("{jobId}")]
[Authorize(Roles = "Admin")]
public ActionResult<JobStatusDto> Get(Guid jobId) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var job = jobManager.GetJobStatus(jobId);
if (job == null) { return NotFound(); }
return Ok(job);
}
[HttpDelete("{jobId}")]
[Authorize(Roles = "Admin")]
public ActionResult Cancel(Guid jobId) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var job = jobManager.GetJob(jobId);
if (job == null) { return NotFound(); }
job.Cancel();
return Ok();
}
[HttpPut]
[Authorize(Roles = "Admin")]
public ActionResult Enqueue([FromBody] JobRequestDto jobRequest) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
switch (jobRequest.JobType) {
case EJobType.FileSystemScan:
if (jobRequest.Parameters.Count != 0)
return BadRequest("FullFileSystemScan job does not require any parameters.");
jobScheduler.QueueFileSystemCrawl(null);
return Ok();
case EJobType.ThumbnailGeneration:
if (jobRequest.Parameters.Count != 0)
return BadRequest("ThumbnailGeneration job does not require any parameters.");
jobScheduler.QueueThumbnailGeneration(null);
return Ok();
case EJobType.PreviewGeneration:
if (jobRequest.Parameters.Count != 0)
return BadRequest("PreviewGeneration job does not require any parameters.");
try { jobScheduler.QueuePreviewGeneration(null); }
catch (NotImplementedException) { return StatusCode(501, "PreviewGeneration is not yet implemented."); }
return Ok();
case EJobType.MetadataExtraction:
if (jobRequest.Parameters.Count != 0)
return BadRequest("MetadataExtraction job does not require any parameters.");
try { jobScheduler.QueueMetadataExtraction(null); }
catch (NotImplementedException) { return StatusCode(501, "MetadataExtraction is not yet implemented."); }
return Ok();
case EJobType.IntegrityCheck:
if (jobRequest.Parameters.Count != 0)
return BadRequest("IntegrityCheck job does not require any parameters.");
jobScheduler.QueueIntegrityCheck(null);
return Ok();
case EJobType.PHashGeneration:
if (jobRequest.Parameters.Count > 1)
return BadRequest("PHashGeneration job takes zero or one parameter (the asset ID).");
jobScheduler.QueuePHashGeneration(jobRequest.Parameters.FirstOrDefault());
return Ok();
default: return BadRequest("Invalid job type.");
}
}
}

View File

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

View File

@@ -2,35 +2,40 @@ using Butter;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using System.Collections;
using System.Data;
using static System.String;
namespace Lactose.Jobs;
public class FileSystemCrawlJob : Job {
public override string Name { get; } = "Filesystem Crawl Job";
public override JobStatus Status { get; }
public sealed class FileSystemCrawlJob : Job {
public override string Name { get; } = "Directory Scan: ";
public override JobStatus JobStatus { get; }
IAssetRepository assetRepository;
string workingPath;
Guid folderId;
ILogger<FileSystemCrawlJob> logger;
JobManager jobManager;
List<Job> childJobs = [];
public FileSystemCrawlJob(
Guid folderId,
string workingPath,
ILogger<FileSystemCrawlJob> logger,
IAssetRepository assetRepository,
JobManager jobManager
) {
Status = new(this);
JobStatus = new(this);
this.logger = logger;
this.assetRepository = assetRepository;
this.workingPath = workingPath;
this.folderId = folderId;
this.jobManager = jobManager;
Name += $"{workingPath}";
}
///<inheritdoc />
protected override void TaskJob(CancellationToken token) {
protected override async Task TaskJob(CancellationToken token) {
logger.LogInformation($"Started crawling directory {workingPath}");
DirectoryInfo dir = new(workingPath);
@@ -54,28 +59,35 @@ public class FileSystemCrawlJob : Job {
foreach (var folder in folders) {
if (token.IsCancellationRequested) {
Status.Cancel();
// Cancel all child jobs
lock (childJobs) { childJobs.ForEach(j => j.Cancel()); }
// Report this job as canceled
JobStatus.Cancel();
return;
}
steps++;
var job = jobManager.CreateJob<FileSystemCrawlJob>(folder.FullName);
var job = jobManager.CreateJob<FileSystemCrawlJob>(folderId, folder.FullName);
job.ParentJobId = Id;
// When the job is done, remove it from the child jobs list
job.Done += (o, _) => childJobs.Remove((o as Job)!);
childJobs.Add(job);
job.Done += (o, _) => { lock (childJobs) { childJobs.Remove((o as Job)!); } };
lock (childJobs) { childJobs.Add(job); }
jobManager.EnqueueJob(job);
logger.LogDebug($"Enqueued crawl job for folder {folder.FullName}");
Status.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing folder {folder.FullName}");
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing folder {folder.FullName}");
}
foreach (var file in files) {
if (token.IsCancellationRequested) {
Status.Cancel();
// Cancel all child jobs
lock (childJobs) { childJobs.ForEach(j => j.Cancel()); }
// Report this job as canceled
JobStatus.Cancel();
return;
}
steps++;
Status.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing file {file.FullName}");
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing file {file.FullName}");
if (assetRepository.FindByPath(file.FullName) != null) continue;
@@ -94,19 +106,29 @@ public class FileSystemCrawlJob : Job {
}
// Wait for all child jobs to complete
Status.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Waiting for {childJobs.Count} child jobs to complete.");
Status.Status = EJobStatus.Waiting;
int pendingCount;
lock (childJobs) { pendingCount = childJobs.Count; }
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Waiting for {pendingCount} child jobs to complete.");
JobStatus.Wait();
while (childJobs.Count > 0) {
while (true) {
lock (childJobs) { if (childJobs.Count == 0) break; }
if (token.IsCancellationRequested) {
// Cancel all child jobs
childJobs.ForEach(j => j.Cancel());
lock (childJobs) { childJobs.ForEach(j => j.Cancel()); }
// Cancel this job as well
Status.Cancel();
JobStatus.Cancel();
return;
}
try {
await Task.Delay(50, token).ConfigureAwait(false);
} catch (OperationCanceledException) {
lock (childJobs) { childJobs.ForEach(j => j.Cancel()); }
JobStatus.Cancel();
return;
}
}
Status.Complete("Crawl completed successfully.");
JobStatus.Complete("Crawl completed successfully.");
}
Asset? AssetFromPath(string filePath) {
@@ -128,7 +150,7 @@ public class FileSystemCrawlJob : Job {
// Create a new asset
var asset = new Asset() {
//TODO: folder ID is missing here
FolderId = folderId,
OriginalPath = finfo.FullName,
OriginalFilename = finfo.Name,
Type = type.Value,
@@ -141,7 +163,7 @@ public class FileSystemCrawlJob : Job {
_ => throw new ArgumentOutOfRangeException()
},
FileSize = finfo.Length,
Hash = []
Hash = new BitArray(64)
};
logger.LogDebug(

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -20,6 +21,7 @@ namespace Lactose.Migrations
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
@@ -108,9 +110,9 @@ namespace Lactose.Migrations
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<byte[]>("Hash")
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("BYTEA");
.HasColumnType("bit(64)");
b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean");
@@ -219,6 +221,45 @@ namespace Lactose.Migrations
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp without time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(256)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp without time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
@@ -256,6 +297,9 @@ namespace Lactose.Migrations
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.Property<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"Lactose.Services" : "Trace"
"Lactose.Services" : "Information"
}
},
"SignKey": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,9 +53,11 @@
</button>
<button class="btn btn-lg btn-outline-light dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
<li><a class="dropdown-item" @onclick="OnProfileClick"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
@if(Admin) {
<li><a class="dropdown-item" @onclick="OnSettingsClick"><i class="bi bi-gear-wide-connected"></i> Settings</a></li>
<li><a class="dropdown-item" @onclick="OnSettings1Click"><i class="bi bi-gear-wide-connected"></i> Settings 1</a></li>
<li><a class="dropdown-item" @onclick="OnJobsClick"><i class="bi bi-kanban-fill"></i> Jobs</a></li>
}
<li><a class="dropdown-item" @onclick="OnLogout"><i class="bi bi-door-closed"></i> Logout</a></li>
</ul>
@@ -96,6 +98,12 @@
void OnSettingsClick() => navigation.NavigateTo("/settings");
void OnSettings1Click() => navigation.NavigateTo("/Settings1");
void OnJobsClick() => navigation.NavigateTo("/jobs");
void OnProfileClick() => navigation.NavigateTo($"/User/{loginService.AuthInfo?.UserId}", true);
async Task OnLogout() {
_ = loginService.Logout();
await localStorage.RemoveItemAsync("auth");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,15 +5,17 @@
@code {
[Parameter]
public required SettingDto Setting { get; set; }
public required SettingDto? Setting { get; set; }
protected virtual SettingDto GetSetting() {
protected virtual SettingDto? GetSetting() {
return Setting;
}
protected override void OnInitialized() {
SettingsService.BeginSave += async (sender, args) => {
try { await SettingsService.UpdateSettingAsync(GetSetting()); }
var setting = GetSetting();
if (setting == null) return;
try { await SettingsService.UpdateSettingAsync(setting); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
}

View File

@@ -20,17 +20,20 @@
protected override void OnInitialized() {
SettingsService.BeginSave += async (sender, args) => {
try { await SettingsService.UpdateSettingAsync(GetSetting()); }
var setting = GetSetting();
if (setting == null) return;
try { await SettingsService.UpdateSettingAsync(setting); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
if (float.TryParse(Setting.Value, out float value)) {
if (float.TryParse(Setting?.Value, out float value)) {
currentValue = value;
} else {
currentValue = 0; // Default value if parsing fails
}
}
protected override SettingDto GetSetting() {
protected override SettingDto? GetSetting() {
if (Setting == null) return null;
Setting.Value = currentValue.ToString(CultureInfo.InvariantCulture);
return Setting;
}

View File

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

View File

@@ -0,0 +1,35 @@
@using Butter.Dtos.Settings
@using MilkStream.Client.Services
@inherits SettingBox
@inject SettingsService SettingsService
<div class="card mb-3">
<div class="card-header">@(Setting?.Name ?? "NO NAME")</div>
<div class="card-body d-flex justify-content-between align-items-center">
<p class="card-text">@(Setting?.Description ?? "No description provided")</p>
<div class="d-flex">
<input type="text" id="@Setting?.Name" class="form-control" @bind="@currentValue" />
</div>
</div>
</div>
@code {
private string currentValue = string.Empty;
protected override void OnInitialized() {
base.OnInitialized();
SettingsService.BeginSave += async (sender, args) => {
var setting = GetSetting();
if (setting == null) return;
try { await SettingsService.UpdateSettingAsync(setting); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
currentValue = Setting?.Value ?? string.Empty;
}
protected override SettingDto? GetSetting() {
if (Setting == null) return null;
Setting.Value = currentValue;
return Setting;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,10 +20,8 @@ public sealed class SettingsService : AuthServiceBase {
public async Task<List<SettingDto>?> GetAllSettings() {
var response = await Client.GetAsync("api/settings");
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<List<SettingDto>>();
return null;
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<SettingDto>>();
}
public async Task UpdateSettingAsync(SettingDto setting) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,9 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lactose", "Lactose\Lactose.csproj", "{054E855F-A730-4FA4-895B-5147AF1877D9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MilkStream", "MilkStream\MilkStream.csproj", "{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}"
ProjectSection(ProjectDependencies) = postProject
{054E855F-A730-4FA4-895B-5147AF1877D9} = {054E855F-A730-4FA4-895B-5147AF1877D9}
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MilkStream.Client", "MilkStream.Client\MilkStream.Client.csproj", "{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}"
EndProject
@@ -23,10 +26,12 @@ Global
{054E855F-A730-4FA4-895B-5147AF1877D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{054E855F-A730-4FA4-895B-5147AF1877D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{054E855F-A730-4FA4-895B-5147AF1877D9}.Release|Any CPU.Build.0 = Release|Any CPU
{054E855F-A730-4FA4-895B-5147AF1877D9}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Release|Any CPU.Build.0 = Release|Any CPU
{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -35,5 +40,6 @@ Global
{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Release|Any CPU.Build.0 = Release|Any CPU
{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
EndGlobalSection
EndGlobal

View File

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

View File

@@ -15,13 +15,17 @@
ports:
- "5162:8080"
depends_on:
- database
database:
condition: service_healthy
volumes:
- "/root/MilkyShots/storageImages:/diary:ro"
#- "./storageImages:/diary:ro"
#- "/root/MilkyShots/storageImages:/diary:ro"
- "./storageImages:/diary:ro"
#- "/root/MilkyShots/thumbs:/app/thumbnails:rw"
#- "/root/MilkyShots/previews:/app/previews:rw"
database:
image: tensorchord/pgvecto-rs:pg17-v0.4.0
#image: tensorchord/pgvecto-rs:pg17-v0.4.0
image: pgvector/pgvector:pg17-trixie
container_name: database
environment:
POSTGRES_USER: root
@@ -31,6 +35,12 @@
- "pg_dbdata:/var/lib/postgresql/data"
ports:
- "3306:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U root -d TestDb"]
interval: 3s
timeout: 5s
retries: 10
start_period: 10s
milkstream:
image: milkstream
@@ -46,4 +56,4 @@
- "8080:8080"
volumes:
pg_dbdata:
pg_dbdata: