Compare commits
245
Commits
v1.0.2
...
451c131711
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
451c131711 | ||
|
|
559e7f1a46 | ||
|
|
9c5bc6ccf7 | ||
|
|
4228ab6ca5 | ||
|
|
c644717808 | ||
|
|
453507a3ed | ||
|
|
c95728b097 | ||
|
|
a8122ebc39 | ||
|
|
594fe7a977 | ||
|
|
62dc693842 | ||
|
|
e00fc5e98c | ||
|
|
c773228832 | ||
|
|
225e6c4b92 | ||
|
|
dffc909353 | ||
|
|
379f640f00 | ||
|
|
8c6b18382a | ||
|
|
917a144ed5 | ||
|
|
9613e7fe13 | ||
|
|
059866eb4a | ||
|
|
65ee5cfd20 | ||
|
|
c0164a75a0 | ||
|
|
2d1d1e0d26 | ||
|
|
6f2ad6d24c | ||
|
|
5727da5798 | ||
|
|
2038bd5e20 | ||
|
|
8a4783367a | ||
|
|
4bf8c5e69f | ||
|
|
df8b5a2cf2 | ||
|
|
2d82dd7c7c | ||
|
|
c3b473ff86 | ||
|
|
e279e91f0d | ||
|
|
ccbf8ebaa4 | ||
|
|
ae62213b76 | ||
|
|
a2de4b8ac3 | ||
|
|
1127364b15 | ||
|
|
64ec5d46a8 | ||
|
|
b1bcab67e0 | ||
|
|
b43f85e7c9 | ||
|
|
b3f2e73eef | ||
|
|
3f8b36eb37 | ||
|
|
152e0c2bc0 | ||
|
|
9d79c15201 | ||
|
|
ba8f7b68be | ||
|
|
a7d2e88993 | ||
|
|
79fc81fabd | ||
|
|
a5d32d7173 | ||
|
|
90026a61cc | ||
|
|
98c3fd5371 | ||
|
|
9ccdfe5512 | ||
|
|
abe446db6c | ||
|
|
fe8c1d5531 | ||
|
|
104193a09e | ||
|
|
291eb611c0 | ||
|
|
e24e6cc2f7 | ||
|
|
03ee6c555e | ||
|
|
70c432a668 | ||
|
|
d72448b34b | ||
|
|
87b7d61f1a | ||
|
|
2ea4bb7d8b | ||
|
|
a893836f42 | ||
|
|
0a51ce676a | ||
|
|
de8589b066 | ||
|
|
c2fe80cddc | ||
|
|
1a340d23ac | ||
|
|
819be813dd | ||
|
|
d5ee952bb0 | ||
|
|
b675457e3e | ||
|
|
2dd4f8621f | ||
|
|
8209da44e7 | ||
|
|
2177cb1a0b | ||
|
|
79b15ed0ff | ||
|
|
4124daeec9 | ||
|
|
8007a2d8ce | ||
|
|
a38f5eb59e | ||
|
|
cd2fa3a355 | ||
|
|
59cb11e687 | ||
|
|
a3372881c4 | ||
|
|
f89c8f3804 | ||
|
|
b6386aad98 | ||
|
|
e63b93f645 | ||
|
|
a23231fd5f | ||
|
|
bfd99c578d | ||
|
|
0d5089c01e | ||
|
|
2c8dcb6255 | ||
|
|
85357752e6 | ||
|
|
5b4e2b9afc | ||
|
|
7fd294043e | ||
|
|
025f9f6629 | ||
|
|
f6b42f850c | ||
|
|
7596308075 | ||
|
|
70247200f1 | ||
|
|
ba66000748 | ||
|
|
ffe37fec87 | ||
|
|
7563450c35 | ||
|
|
4bc35c0625 | ||
|
|
6fbb98a3fd | ||
|
|
796f485a84 | ||
|
|
95c624aaab | ||
|
|
371e73a999 | ||
|
|
ee249adaf1 | ||
|
|
b5b8fb247c | ||
|
|
f9556cd5de | ||
|
|
05f3c40f60 | ||
|
|
22b6e0e702 | ||
|
|
b2786b487f | ||
|
|
317bf12e07 | ||
|
|
7fd7a82d54 | ||
|
|
f9706d26d5 | ||
|
|
9c3d7effc9 | ||
|
|
4d9d94b648 | ||
|
|
9eb333093a | ||
|
|
30ad404efd | ||
|
|
7823d9f4d4 | ||
|
|
82ab09adcb | ||
|
|
4d068216df | ||
|
|
24d6109d33 | ||
|
|
68a583d584 | ||
|
|
1c7ede30af | ||
|
|
a5dda805fd | ||
|
|
87259a02a5 | ||
|
|
e2bdff9277 | ||
|
|
9beeb92ff0 | ||
|
|
a798a2cf30 | ||
|
|
887e5e0b31 | ||
|
|
4e93029965 | ||
|
|
b26163296f | ||
|
|
ec6254ea73 | ||
|
|
9d08f3de3e | ||
|
|
3872fb05ed | ||
|
|
16850bc6fd | ||
|
|
4d9b595a1d | ||
|
|
a02b2f4843 | ||
|
|
68fdad13d5 | ||
|
|
a149b01690 | ||
|
|
3fce3599fb | ||
|
|
2fd03c4d14 | ||
|
|
c55ce7ac4f | ||
|
|
6026c9ec43 | ||
|
|
6add667ef6 | ||
|
|
0c09e01f3c | ||
|
|
f940d5603a | ||
|
|
83d30feadf | ||
|
|
03e4c3e86d | ||
|
|
61b7c10967 | ||
|
|
f8db265ffd | ||
|
|
164008bef1 | ||
|
|
4d5694dca6 | ||
|
|
a680f6363d | ||
|
|
0098ec3a65 | ||
|
|
fbd7f5ff00 | ||
|
|
c05082ad13 | ||
|
|
d96473eca5 | ||
|
|
34080648d6 | ||
|
|
f9385e2d9d | ||
|
|
e995388af0 | ||
|
|
3fbdd56c10 | ||
|
|
026b5d8fe8 | ||
|
|
7ded2b068b | ||
|
|
99117347d7 | ||
|
|
42154e9e5c | ||
|
|
230202337a | ||
|
|
2ded40d2a0 | ||
|
|
965caadc93 | ||
|
|
8a1b39347d | ||
|
|
06e59e164f | ||
|
|
c2f3b05903 | ||
|
|
d3124c55bd | ||
|
|
c97b16ada9 | ||
|
|
5e7c053218 | ||
|
|
e1bb7d3330 | ||
|
|
0c8e653a5f | ||
|
|
725a020078 | ||
|
|
2782a38819 | ||
|
|
84e4492aff | ||
|
|
f75940cd20 | ||
|
|
84d3f63a97 | ||
|
|
9e9d091f60 | ||
|
|
556c4a15e0 | ||
|
|
3374fe6ad4 | ||
|
|
fe4e19a7de | ||
|
|
a5a783e8d6 | ||
|
|
3afa7f2834 | ||
|
|
66782cacfc | ||
|
|
aacd4c8049 | ||
|
|
fa62e75a72 | ||
|
|
1599715d06 | ||
|
|
4189e975a1 | ||
|
|
5fbb0e75e8 | ||
|
|
2371011720 | ||
|
|
2d4e78f014 | ||
|
|
e1839d4e8f | ||
|
|
ee73c8ad90 | ||
|
|
a9d58606a7 | ||
|
|
588095f370 | ||
|
|
9f5d9c0c11 | ||
|
|
25144ccd3c | ||
|
|
a9b52a890a | ||
|
|
b344c1d625 | ||
|
|
cff5a7969d | ||
|
|
30dca5e216 | ||
|
|
e6a8799965 | ||
|
|
ff2e7ff6dc | ||
|
|
cd1e5841cf | ||
|
|
d318400504 | ||
|
|
9d70516da2 | ||
|
|
52e026d7e1 | ||
|
|
a811221228 | ||
|
|
3ffee8df78 | ||
|
|
63e3413141 | ||
|
|
b83b97934c | ||
|
|
6bf627e429 | ||
|
|
7ef8d8155f | ||
|
|
1aeb644e30 | ||
|
|
8913fa7c43 | ||
|
|
f42c2e4d04 | ||
|
|
0480b00766 | ||
|
|
26674e05e5 | ||
|
|
29eb39e545 | ||
|
|
fbcc7a317d | ||
|
|
ee07306a4d | ||
|
|
294f7a6cae | ||
|
|
595399385b | ||
|
|
77ee80cd63 | ||
|
|
9e67c597ef | ||
|
|
05ff6e8f2d | ||
|
|
07e43b5023 | ||
|
|
00e0dd42d1 | ||
|
|
6f60ade6df | ||
|
|
eaa067134e | ||
|
|
701c4360b4 | ||
|
|
e132d269eb | ||
|
|
79252d363d | ||
|
|
8ff21fa56b | ||
|
|
2305e44e68 | ||
|
|
439df56f2b | ||
|
|
f70f256c07 | ||
|
|
3b1f26cdf3 | ||
|
|
9965a13f40 | ||
|
|
fbafa54322 | ||
|
|
9607746912 | ||
|
|
785d33e289 | ||
|
|
a3eeb8b6d1 | ||
|
|
cae61116c8 | ||
|
|
ced0f61b87 | ||
|
|
9751c81574 |
@@ -48,7 +48,7 @@ jobs:
|
||||
if [ "${{ matrix.service }}" = "Lactose" ]; then
|
||||
echo "BUILD_ARGS=SixLaborsLicenseKey=${{ secrets.SIXLABORS_KEY }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "BUILD_ARGS=" >> $GITHUB_ENV
|
||||
echo "BUILD_ARGS=APP_VERSION=$VERSION" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Build and push ${{ matrix.service }}
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
# MilkyShots – Agent Guidelines
|
||||
|
||||
This file captures the coding conventions, architecture rules, and standards extracted from the codebase. Follow them in every contribution to keep the project consistent.
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
MilkyShots is a .NET 8 media-library application composed of four projects:
|
||||
|
||||
| Project | Role |
|
||||
|---|---|
|
||||
| **Butter** | Shared class library – DTOs, enums, MIME-type tables, settings definitions |
|
||||
| **Lactose** | ASP.NET Core Web API backend – controllers, EF Core models, repositories, services, background jobs |
|
||||
| **MilkStream** | Blazor WebAssembly host – serves static WASM files and provides dynamic configuration |
|
||||
| **MilkStream.Client** | Blazor WASM client – Razor components, frontend services, SCSS styles (runs in browser) |
|
||||
|
||||
The backend stores data in **PostgreSQL** (with the `pgvecto-rs` extension) accessed via **Entity Framework Core**.
|
||||
The WASM client runs entirely in the browser and communicates with the Lactose backend over HTTP/JSON using named `HttpClient` instances.
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **.NET 8 / C# 12** — target framework and language version for all projects.
|
||||
- **ASP.NET Core** (Lactose) and **Blazor WebAssembly** (MilkStream.Client hosted by MilkStream).
|
||||
- **Entity Framework Core** with `Npgsql.EntityFrameworkCore.PostgreSQL`.
|
||||
- **JWT Bearer** authentication + refresh-token rotation.
|
||||
- **Bootstrap 5** and **SCSS** (compiled by `AspNetCore.SassCompiler` in debug) for styling.
|
||||
- **Docker / docker-compose** for containerised deployment.
|
||||
|
||||
---
|
||||
|
||||
## Solution Structure
|
||||
|
||||
```
|
||||
MilkyShots/
|
||||
├── Butter/ # Shared library
|
||||
│ ├── Dtos/ # DTOs grouped by entity (Album/, Asset/, …)
|
||||
│ ├── Settings/ # Settings enum + extensions
|
||||
│ └── Types/ # Shared enums (EAccessLevel, EAssetType)
|
||||
├── Lactose/ # Web API
|
||||
│ ├── Authorization/ # Claims transformation + authorization handlers
|
||||
│ ├── Configuration/ # Options classes (SignKeyConfiguration, SignKeyProvider)
|
||||
│ ├── Context/ # EF Core DbContext (LactoseDbContext)
|
||||
│ ├── Controllers/ # API controllers
|
||||
│ ├── Jobs/ # Background job infrastructure + concrete jobs
|
||||
│ ├── Mapper/ # Model → DTO mapping (static extension methods)
|
||||
│ ├── Migrations/ # EF Core migrations (generated – do NOT hand-edit)
|
||||
│ ├── Models/ # EF Core entity classes
|
||||
│ ├── Repositories/ # Interfaces + implementations for data access
|
||||
│ ├── Services/ # Application services (auth, DB initialiser, scheduler)
|
||||
│ └── Utils/ # Small helpers / extension methods
|
||||
├── MilkStream/ # Blazor WASM static host
|
||||
│ ├── Program.cs # Minimal API – serves WASM files + dynamic /appsettings.json endpoint
|
||||
│ └── appsettings.json # Server-side config (LactoseBaseUrl for dynamic injection)
|
||||
└── MilkStream.Client/ # Blazor WASM client (runs in browser)
|
||||
├── Components/ # Razor components (Pages/, Layout/, SettingBoxes/, …)
|
||||
├── Services/ # Frontend services (LoginService, MediaService, …)
|
||||
├── Styles/ # SCSS source files
|
||||
└── wwwroot/ # Static assets bundled into WASM
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### C# identifiers
|
||||
|
||||
| Category | Pattern | Example |
|
||||
|---|---|---|
|
||||
| Enums | `E` prefix | `EAccessLevel`, `EAssetType`, `EJobStatus` |
|
||||
| Interfaces | `I` prefix | `IAssetRepository`, `IAlbumRepository` |
|
||||
| DTOs | `…Dto` suffix (PascalCase) | `AssetDto`, `AlbumCreateDto` |
|
||||
| DTO sets: create / update / preview / full | `…CreateDto`, `…UpdateDto`, `…PreviewDto`, `…FullDto` | `AlbumCreateDto` |
|
||||
| Repository interfaces | `I…Repository` | `IAssetRepository` |
|
||||
| Repository implementations | `…Repository` | `AssetRepository` |
|
||||
| Mapper classes | `…Mapper` | `AssetsMapper`, `UsersMapper` |
|
||||
| Controller classes | `…Controller` | `AssetController` |
|
||||
| Background jobs | extend `Job` | `FileSystemCrawlJob` |
|
||||
| Services | `…Service` | `LactoseAuthService`, `LoginService` |
|
||||
|
||||
> **Note:** The suffix `DTO` (all-caps) appears in one legacy file (`PagedParametersDTO.cs`).
|
||||
> New files must use `Dto` (mixed-case) to match the rest of the codebase.
|
||||
|
||||
### Files and directories
|
||||
|
||||
- One public type per file; file name equals the type name.
|
||||
- Folders mirror the namespace suffix (e.g. `Lactose.Repositories` → `Lactose/Repositories/`).
|
||||
|
||||
---
|
||||
|
||||
## C# Coding Style
|
||||
|
||||
- Enable **nullable reference types** (`<Nullable>enable</Nullable>`) and **implicit usings** in all projects.
|
||||
- Prefer **primary constructors** (C# 12) for constructor injection:
|
||||
```csharp
|
||||
public class AssetController(
|
||||
ILogger<AssetController> logger,
|
||||
LactoseAuthService authService,
|
||||
IAssetRepository assetRepository
|
||||
) : ControllerBase { … }
|
||||
```
|
||||
- Use **`var`** for local variables when the type is obvious from the right-hand side.
|
||||
- Use **object initialisers** when creating entity or DTO instances.
|
||||
- Use the **`required`** keyword on properties that must be set during object construction.
|
||||
- Default string properties to `string.Empty`, not `null`.
|
||||
- Default collection navigation properties to `null` (lazy) — the EF Core model builder wires up relationships.
|
||||
- Mark navigation property regions with `#region Navigation Properties … #endregion`.
|
||||
|
||||
### Documentation comments
|
||||
|
||||
- Every **model class** and every property on it must have a `/// <summary>` XML comment.
|
||||
- Every **interface method** must have `<summary>`, `<param>`, and `<returns>` XML comments.
|
||||
- **Mapper classes** must have a `<summary>` on the class and on each method.
|
||||
- Controllers may omit XML comments on action methods (use inline comments instead).
|
||||
|
||||
### Logging
|
||||
|
||||
- Inject `ILogger<T>` into every controller and service.
|
||||
- Use structured logging with message templates (avoid string concatenation in log calls where possible).
|
||||
- Use verbatim interpolated string literals (`$"""…"""`) for multi-line log entries.
|
||||
- Log-level conventions:
|
||||
|
||||
| Level | When to use |
|
||||
|---|---|
|
||||
| `LogTrace` | Detailed per-request data (IDs, field values) |
|
||||
| `LogDebug` | Medium-detail operational info |
|
||||
| `LogInformation` | Key lifecycle events (job started, user registered, …) |
|
||||
| `LogWarning` | Non-fatal problems (missing resource, permission violation) |
|
||||
| `LogError` | Exceptions and failures |
|
||||
|
||||
---
|
||||
|
||||
## Entity / Model Rules
|
||||
|
||||
- Primary keys are **`Guid`** annotated with `[Key]`.
|
||||
- String columns must specify `[Column(TypeName = "VARCHAR(n)")]` with an appropriate max length.
|
||||
- Foreign-key scalar properties use `[ForeignKey(nameof(NavigationProperty))]`.
|
||||
- Timestamps follow the pattern: `CreatedAt`, `UpdatedAt`, `DeletedAt` (nullable `DateTime?` for soft delete).
|
||||
- **Soft delete** — entities are never hard-deleted from the database via normal application flows; set `DeletedAt = DateTime.Now` instead.
|
||||
- `[Index(nameof(Field), IsUnique = true)]` is used on fields that must be unique at the DB level.
|
||||
- Model classes live in `Lactose.Models`; shared enums/types live in `Butter.Types`.
|
||||
|
||||
---
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
- Every repository exposes its contract through an `I…Repository` interface in the same folder.
|
||||
- All repository interfaces implement `IDisposable` (or `IAsyncDisposable` where async teardown is needed).
|
||||
- Standard method set:
|
||||
|
||||
| Method | Signature |
|
||||
|---|---|
|
||||
| Find by PK | `T? Find(Guid id)` |
|
||||
| Find multiple | `IEnumerable<T> FindBulk(IEnumerable<Guid> ids)` |
|
||||
| Insert | `void Insert(T entity)` |
|
||||
| Update | `void Update(T entity)` (sets `UpdatedAt = DateTime.Now`) |
|
||||
| Bulk update | `void UpdateBulk(IEnumerable<T> entities)` |
|
||||
| Save | `void Save()` → `context.SaveChanges()` |
|
||||
| Delete | `void Delete(T entity)` or `void Delete(Guid id)` |
|
||||
|
||||
- **`Save()` must be called explicitly** after insert/update/delete operations; repositories do not auto-save except `FolderRepository.Create()` (which needs to fire an event after commit).
|
||||
- Repositories may expose **static events** for cross-service notification (e.g. `FolderRepository.FolderAdded`, `SettingsRepository.SettingChanged`). Keep these static and `EventHandler<T>` typed.
|
||||
- Register all repositories as **`Transient`** in `Program.cs`.
|
||||
|
||||
---
|
||||
|
||||
## Mapper / DTO Rules
|
||||
|
||||
- Mapper classes are **`public static`** and live in `Lactose.Mapper`.
|
||||
- Mapping methods are **extension methods** on the model type:
|
||||
```csharp
|
||||
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) { … }
|
||||
```
|
||||
- Naming convention: `To<TargetType>()` or `To<TargetType>(extraParam)`.
|
||||
- Never put business logic in mappers — only field assignments.
|
||||
|
||||
---
|
||||
|
||||
## Controller Rules
|
||||
|
||||
- Decorate every controller with `[ApiController]` and `[Route("api/[controller]")]`.
|
||||
- Route URLs are lowercase (`builder.Services.Configure<RouteOptions>(o => o.LowercaseUrls = true)`).
|
||||
- HTTP verb → CRUD mapping:
|
||||
|
||||
| Verb | Operation |
|
||||
|---|---|
|
||||
| `[HttpGet]` / `[HttpGet("{id}")]` | Read single / search/list |
|
||||
| `[HttpPut]` | Create a new resource |
|
||||
| `[HttpPost("{id}")]` | Update a specific resource |
|
||||
| `[HttpPost]` | Bulk update |
|
||||
| `[HttpDelete("{id}")]` | Delete a specific resource |
|
||||
| `[HttpDelete]` | Bulk delete |
|
||||
|
||||
- Return `ActionResult<T>` for endpoints that return a body, and `IStatusCodeActionResult` or `ActionResult` for status-only responses.
|
||||
- Standard error returns: `NotFound()`, `BadRequest()`, `Unauthorized()`, `Forbid()`, `Conflict(…)`.
|
||||
- Always validate `null` after a repository lookup and return `NotFound()` before proceeding.
|
||||
- Access-level checks must be performed inside the action method as well as via `[Authorize]` attributes (defence-in-depth).
|
||||
|
||||
---
|
||||
|
||||
## Authentication & Authorisation
|
||||
|
||||
- Auth uses **JWT Bearer** tokens (`JwtBearerDefaults.AuthenticationScheme`).
|
||||
- Access is controlled by three roles (in ascending privilege order):
|
||||
|
||||
| Role | Description |
|
||||
|---|---|
|
||||
| `User` | Read-only access to publicly shared / explicitly shared content |
|
||||
| `Curator` | Can create/update/delete their own content |
|
||||
| `Admin` | Unrestricted access |
|
||||
|
||||
- `[Authorize]` with no roles = any authenticated user.
|
||||
- `[Authorize(Roles = "Admin")]` or `[Authorize(Roles = "Admin, Curator")]` for restricted endpoints.
|
||||
Note the space after the comma in the role list — this is the project's established style.
|
||||
- Use `LactoseAuthService.GetUserData(User)` inside action methods to retrieve the typed `LactoseAuthenticatedUser` from claims.
|
||||
- Refresh tokens are stored (hashed by ASP.NET Identity) in the `User` table.
|
||||
`RefreshTokenTransformation` (a `IClaimsTransformation`) validates the refresh token on every request and adds a `"LoginValid"` claim.
|
||||
- Access tokens expire after **10 minutes**; refresh tokens expire after **60 minutes**.
|
||||
|
||||
---
|
||||
|
||||
## Background Jobs System
|
||||
|
||||
- All background jobs **extend `Job`** (abstract base in `Lactose.Jobs`).
|
||||
- Override `TaskJob(CancellationToken token)` to implement job logic.
|
||||
- Check `token.IsCancellationRequested` inside loops and call `Status.Cancel()` + `return` when set.
|
||||
- Use `Status.UpdateProgress(float, string)` to report progress (value between 0 and 1).
|
||||
- Use `Status.Complete(string)` / `Status.Fail(string)` for terminal states.
|
||||
- New job types must be instantiatable via `ActivatorUtilities.CreateInstance<T>(serviceProvider, extraArgs)`.
|
||||
- Enqueue jobs with `JobManager.CreateJob<T>(args)` then `JobManager.EnqueueJob(job)`.
|
||||
- Maximum concurrent jobs defaults to **8** (`JobManager.MaxConcurrentJobs`).
|
||||
- Timer-based scheduling is handled by `JobScheduler` which reacts to `SettingsRepository.SettingChanged`.
|
||||
|
||||
---
|
||||
|
||||
## Settings System
|
||||
|
||||
- Runtime settings are persisted in the `Settings` database table.
|
||||
- Canonical names are defined in `Butter.Settings.Settings` enum with `AsString()` extension.
|
||||
- Default values and metadata are loaded from **`Lactose/DefaultSettings.json`** on startup by `DbInitializer`.
|
||||
- When adding a new setting:
|
||||
1. Add an entry to `Settings` enum and `AsString()`.
|
||||
2. Add the default entry to `DefaultSettings.json`.
|
||||
3. React to changes via `SettingsRepository.SettingChanged` event.
|
||||
|
||||
---
|
||||
|
||||
## Frontend (MilkStream.Client / Blazor WASM)
|
||||
|
||||
- Use **Blazor WebAssembly** – the client runs entirely in the browser.
|
||||
- Pages live in `MilkStream.Client/Components/Pages/`, reusable components in `MilkStream.Client/Components/` (or a subfolder by concern).
|
||||
- Services that call the backend extend either `ServiceBase` (no auth) or `AuthServiceBase` (requires auth).
|
||||
- `AuthServiceBase` subscribes to `LoginService.AuthInfoChanged` to keep the `Authorization` header up to date.
|
||||
- Use the **`"MilkStreamClient"`** named `HttpClient` (configured with `JwtTokenRefresher`) for any authenticated API call.
|
||||
- `JwtTokenRefresher` automatically refreshes the access token when it is expired or expires within 1 minute.
|
||||
- Use `ProtectedLocalStorage` / `ProtectedSessionStorage` (Blazor's encrypted storage) for auth-related persistence.
|
||||
|
||||
### Dynamic Configuration
|
||||
- MilkStream's `Program.cs` registers a `GET /appsettings.json` endpoint **before** `UseStaticFiles()`, shadowing the static file.
|
||||
- This endpoint returns `{ "BaseUrl": "<LactoseBaseUrl>" }` from server-side configuration, ensuring the WASM client always receives the externally-reachable Lactose URL.
|
||||
- The `LactoseBaseUrl` setting is injected via environment variable in Docker deployments and configured in `appsettings.json` / `appsettings.Development.json` for local development.
|
||||
|
||||
- SCSS source files go in `MilkStream.Client/Styles/`; compiled output is written to `MilkStream.Client/wwwroot/css/` (do not edit the compiled files by hand).
|
||||
|
||||
---
|
||||
|
||||
## Docker / Deployment
|
||||
|
||||
- Each deployable project has its own **multi-stage `Dockerfile`** (build → publish → base).
|
||||
- `docker-compose.yml` at repo root wires up `lactose`, `milkstream`, and `database` services.
|
||||
- Media is mounted as a **read-only volume** at `/diary` inside the `lactose` container.
|
||||
- Data-protection keys for MilkStream are persisted via a named Docker volume.
|
||||
- Environment-specific config is loaded from `appsettings.{EnvironmentName}.json`; secrets are injected via environment variables or Docker secrets — **never commit real credentials**.
|
||||
- When `CorsAllowedOrigins` is set to `["*"]` in Lactose configuration, the API accepts requests from any origin (useful for browser-based WASM clients in unpredictable deployment environments). Lock this down to specific origins in production if needed.
|
||||
|
||||
---
|
||||
|
||||
## Configuration & Secrets
|
||||
|
||||
- `appsettings.json` contains placeholder/default values only.
|
||||
- Sensitive values (DB password, JWT signing key) must be provided via environment variables or .NET User Secrets (development only, keyed by `UserSecretsId`).
|
||||
- The JWT signing key (`SignKey:Key`) must be **at least 32 characters**.
|
||||
- The DB port mapping in `docker-compose.yml` exposes PostgreSQL on host port `3306` (maps to container port `5432`).
|
||||
|
||||
---
|
||||
|
||||
## Git & Commit Conventions
|
||||
|
||||
This project uses **Conventional Commits** and generates a changelog with **git-cliff**.
|
||||
|
||||
| Prefix | Purpose |
|
||||
|---|---|
|
||||
| `feat:` | New feature |
|
||||
| `fix:` | Bug fix |
|
||||
| `refactor:` | Code restructuring without behaviour change |
|
||||
| `perf:` | Performance improvement |
|
||||
| `doc:` | Documentation changes |
|
||||
| `style:` | Formatting / style only |
|
||||
| `test:` | Tests |
|
||||
| `chore:` / `ci:` | Build system, tooling, CI |
|
||||
| `revert:` | Revert a previous commit |
|
||||
|
||||
- Commits with `chore(release):`, `chore(deps)`, `chore(pr)`, `chore(pull)` are excluded from the changelog automatically.
|
||||
- Breaking changes must be marked with `[**breaking**]` in the commit body or footer.
|
||||
|
||||
---
|
||||
|
||||
## TODOs & Known Gaps (as of initial analysis)
|
||||
|
||||
The following items appear as `TODO` comments in the codebase and should be tracked:
|
||||
|
||||
- `AssetController.Create` — endpoint marked `[NonAction]`; purpose unclear, may need removal or redesign.
|
||||
- `AlbumController`, `FolderController`, `SettingsController`, `TagController` — logging not yet wired in.
|
||||
- `AuthController.Register` — should return a full `AuthResultDto` instead of a bare Guid.
|
||||
- `UserController.Get` / `GetAll` — lower access levels should receive reduced user information.
|
||||
- `FileSystemCrawlJob.AssetFromPath` — folder ID is not populated when crawling.
|
||||
- `TagController.Get` (ancestors traversal) — not protected against circular references.
|
||||
- Self-registration flow in `AuthController` is gated by the `UserRegistrationEnabled` setting but that check is not yet implemented in the controller.
|
||||
+3
-1
@@ -9,4 +9,6 @@ dotnet-tools.json
|
||||
.opencode/
|
||||
*.css.map
|
||||
# SixLabors license file
|
||||
/Lactose/sixlabors.lic
|
||||
/Lactose/sixlabors.lic
|
||||
.desloppify/
|
||||
.claude/
|
||||
+1
-15
@@ -1,20 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="MariaDb" uuid="eab007eb-4f50-4961-9bce-5c4940a7cdff">
|
||||
<driver-ref>mariadb</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mariadb://localhost:3306/</jdbc-url>
|
||||
<jdbc-additional-properties>
|
||||
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.resource.type" value="Deployment" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||
</jdbc-additional-properties>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
<time-zone>Europe/Rome</time-zone>
|
||||
</data-source>
|
||||
<data-source source="LOCAL" name="TestDb@localhost" uuid="80498c22-67da-4c4a-8106-c4789470ef83">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
@@ -31,7 +17,7 @@
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://192.168.51.101:3306/TestDb</jdbc-url>
|
||||
<jdbc-url>jdbc:postgresql://192.168.51.101:5432/TestDb</jdbc-url>
|
||||
<jdbc-additional-properties>
|
||||
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||
|
||||
+2
-3
@@ -3,9 +3,8 @@
|
||||
<component name="db-forest-configuration">
|
||||
<data version="2">.
|
||||
----------------------------------------
|
||||
1:0:eab007eb-4f50-4961-9bce-5c4940a7cdff
|
||||
2:0:80498c22-67da-4c4a-8106-c4789470ef83
|
||||
3:0:a0f3e480-c856-41e0-8fb4-5cac87b9faf6
|
||||
1:0:80498c22-67da-4c4a-8106-c4789470ef83
|
||||
2:0:a0f3e480-c856-41e0-8fb4-5cac87b9faf6
|
||||
.</data>
|
||||
</component>
|
||||
</project>
|
||||
@@ -20,6 +20,8 @@
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
<method v="2">
|
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,34 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Compose Deployment (PM) Win" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker-PM">
|
||||
<deployment type="docker-compose.yml">
|
||||
<settings>
|
||||
<option name="containerName" value="" />
|
||||
<option name="envFilePath" value="" />
|
||||
<option name="envFilePaths">
|
||||
<list>
|
||||
<option value="" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="envVars">
|
||||
<list>
|
||||
<DockerEnvVarImpl>
|
||||
<option name="name" value="ASPNETCORE_ENVIRONMENT" />
|
||||
<option name="value" value="Docker" />
|
||||
</DockerEnvVarImpl>
|
||||
</list>
|
||||
</option>
|
||||
<option name="removeOrphansOnComposeDown" value="false" />
|
||||
<option name="commandLineOptions" value="--build" />
|
||||
<option name="secondarySourceFiles">
|
||||
<list>
|
||||
<option value="docker-compose.pm.yml" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="sourceFilePath" value="docker-compose.yml" />
|
||||
<option name="upRemoveOrphans" value="true" />
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -29,6 +29,8 @@
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
<method v="2">
|
||||
<option name="RunConfigurationTask" enabled="false" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,17 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="VersionEnvGen" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/generate-version.sh" />
|
||||
<option name="SCRIPT_OPTIONS" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||
<option name="INTERPRETER_PATH" value="/bin/zsh" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="true" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,17 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="VersionEnvGenWin" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="false" />
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/generate-version.ps1" />
|
||||
<option name="SCRIPT_OPTIONS" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="false" />
|
||||
<option name="INTERPRETER_PATH" value=""C:/Program Files/PowerShell/7/pwsh.exe"" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="EXECUTE_IN_TERMINAL" value="false" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="true" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -44,8 +44,6 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
|
||||
|
||||
**Database:** Default credentials in `appsettings.json`. Docker compose spins up all services.
|
||||
|
||||
**Port gotcha:** `appsettings.json` defaults `DatabaseAddress:Port` to **3306** (legacy MySQL port). PostgreSQL runs on 5432. The raw connection string must be overridden, or use `docker-compose.yml` which maps host 3306 → container 5432 automatically.
|
||||
|
||||
## Infrastructure gotchas
|
||||
|
||||
- **MilkStream `/appsettings.json`:** Dynamically generated from server config — maps **before** `UseStaticFiles`, so it shadows the static file in `MilkStream.Client/wwwroot/`. This endpoint provides `LactoseBaseUrl` to the WASM client.
|
||||
@@ -65,6 +63,7 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
|
||||
- **Soft delete only** — set `DeletedAt`, never hard-delete (models: Asset, User)
|
||||
- **Repository `Save()` must be called explicitly** after insert/update/delete
|
||||
- **Enum prefix `E`** (e.g. `EAccessLevel`, `EAssetType`, `EJobStatus`)
|
||||
- **Frontend fields: no `_` prefix** — private fields in `MilkStream.Client` use a plain camelCase name (e.g. `isLoading`, `selectedIndex`), never a leading `_`. Do not introduce `_`-prefixed fields; rename them out when touching such code.
|
||||
- **JWT access token**: 10 min, refresh token: 60 min (constants in `LactoseAuthService.cs`)
|
||||
- **Pagination is zero-based** everywhere — `Page = 0` is the first page. All repositories use `Skip(page * pageSize)`. All controllers validate `Page < 0` (reject negative) and `PageSize < 1 || PageSize > 250` (clamp 1–250).
|
||||
- **Conventional Commits** enforced via `cliff.toml`; changelog generated with `git-cliff`
|
||||
@@ -129,6 +128,8 @@ See issue #52 for the tracking list of candidate components.
|
||||
|
||||
Migrations in `Lactose/Migrations/` — generated, do not hand-edit.
|
||||
|
||||
**All migrations MUST be created with EF Core tools** (`dotnet ef migrations add`) and **never edited manually** (including the generated `.Designer.cs` and snapshot), unless the user explicitly asks for a manual edit. Hand-written data backfills, index tweaks, or idempotency guards belong in a separate migration added via the tools — not as edits to a generated file. If a migration is already partially applied (e.g. a backfill timed out), do not make the migration re-runnable; instead reset the partial DB state and re-apply, or add a new tool-generated migration.
|
||||
|
||||
```bash
|
||||
dotnet ef migrations add <Name> --project Lactose
|
||||
dotnet ef database update --project Lactose
|
||||
|
||||
@@ -27,6 +27,11 @@ public class AlbumAssetPreviewDto {
|
||||
/// </summary>
|
||||
public bool HasPreview { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset's display media. For animated assets this is the
|
||||
/// converted video MIME type (e.g. "video/mp4"); otherwise the original MIME type.
|
||||
/// </summary>
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the original filename of the asset.
|
||||
/// </summary>
|
||||
public string? FileName { get; set; }
|
||||
|
||||
@@ -27,6 +27,14 @@ public class AlbumPreviewDto {
|
||||
/// </summary>
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover image width, if a cover exists.
|
||||
/// </summary>
|
||||
public int? CoverWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover image height, if a cover exists.
|
||||
/// </summary>
|
||||
public int? CoverHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets in the album.
|
||||
/// </summary>
|
||||
public int AssetCount { get; set; }
|
||||
|
||||
@@ -14,4 +14,9 @@ public class AlbumSearchParametersDto : PagedSearchParametersDto {
|
||||
/// Only returns albums that contain at least one asset uploaded by the specified user.
|
||||
/// </summary>
|
||||
public Guid? AssetUploadedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID to filter albums by their owner.
|
||||
/// </summary>
|
||||
public Guid? PersonOwnerId { get; set; }
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ public class AssetBrowseOptionsDto : PagedSearchParametersDto {
|
||||
/// </summary>
|
||||
public Guid FolderId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the current path relative to the folder root (empty for root).
|
||||
/// Gets or sets the current path relative to the folder root (empty or null for root).
|
||||
/// </summary>
|
||||
public string Path { get; set; } = "";
|
||||
public string? Path { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether to only include assets not assigned to any album. Defaults to true.
|
||||
/// </summary>
|
||||
|
||||
@@ -13,11 +13,12 @@ public class AssetBrowseResultDto {
|
||||
/// </summary>
|
||||
public List<AssetPreviewDto> Assets { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Gets or sets the current browsing path relative to the folder root.
|
||||
/// </summary>
|
||||
public string CurrentPath { get; set; } = "";
|
||||
/// <summary>
|
||||
/// Gets or sets the total count of assets at the current path level (for pagination).
|
||||
/// </summary>
|
||||
public int TotalAssetCount { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the current path level contains at least one non-deleted asset
|
||||
/// (registered in the database) that is not assigned to any album.
|
||||
/// </summary>
|
||||
public bool HasUnassignedFilesHere { get; set; }
|
||||
}
|
||||
|
||||
@@ -58,4 +58,16 @@ public class AssetPreviewDto {
|
||||
/// Gets or sets the soft-delete timestamp. Only populated for admins and the asset uploader.
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when processing of the asset last failed. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public DateTime? ProcessFailedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the error message recorded when the asset was last marked broken. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public string? ProcessErrorMessage { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the original file path of the asset. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public string? OriginalPath { get; set; }
|
||||
}
|
||||
|
||||
@@ -38,4 +38,22 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
|
||||
/// Gets or sets the uploader user ID to filter assets by their uploader.
|
||||
/// </summary>
|
||||
public Guid? UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether to filter exclusively for soft-deleted assets.
|
||||
/// Results remain scoped by the requester's access level.
|
||||
/// </summary>
|
||||
public bool DeletedOnly { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the album ID to filter assets belonging to that album.
|
||||
/// </summary>
|
||||
public Guid? AlbumId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID to filter assets in albums owned by that person.
|
||||
/// </summary>
|
||||
public Guid? PersonId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the total matching count should be computed and returned in the <c>X-Total-Count</c> header.
|
||||
/// Skipping it avoids a full count query over the filtered result set.
|
||||
/// </summary>
|
||||
public bool IncludeCount { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ public class AssetUpdateDto {
|
||||
/// </summary>
|
||||
public Guid? UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the deletion date to set on the asset.
|
||||
/// Gets or sets the soft-delete state to apply to the asset.
|
||||
/// <see langword="true"/> soft-deletes the asset, <see langword="false"/> restores it,
|
||||
/// and <see langword="null"/> leaves the current state unchanged.
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
public bool? IsDeleted { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
/// Reports the outcome of a bulk restore operation on soft-deleted assets.
|
||||
/// </summary>
|
||||
public class BulkRestoreResultDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the IDs of assets that were successfully restored.
|
||||
/// </summary>
|
||||
public List<Guid> Restored { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Gets or sets the IDs of assets that were skipped because their source file no longer exists on disk.
|
||||
/// </summary>
|
||||
public List<Guid> SkippedMissingFile { get; set; } = [];
|
||||
}
|
||||
@@ -8,4 +8,13 @@ public class DirectoryEntryDto {
|
||||
/// Gets or sets the directory name (a single path segment, not a full path).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the directory path relative to the folder root (forward slashes).
|
||||
/// </summary>
|
||||
public string Path { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets whether this directory contains at least one non-deleted asset
|
||||
/// that is not assigned to any album (directly or in a subdirectory).
|
||||
/// </summary>
|
||||
public bool HasUnassignedAssets { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,4 +13,9 @@ public class BulkDto<TDto> {
|
||||
/// Gets or sets the update data to apply to each entity.
|
||||
/// </summary>
|
||||
public required TDto Data { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets optional cascade options for the update (e.g. cascade a visibility change to albums and assets).
|
||||
/// </summary>
|
||||
public CascadeOptions? Cascade { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Butter.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Represents cascade options for a bulk visibility update.
|
||||
/// </summary>
|
||||
public class CascadeOptions {
|
||||
/// <summary>
|
||||
/// Gets or sets whether to cascade the visibility update to albums.
|
||||
/// </summary>
|
||||
public bool ToAlbums { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to cascade the visibility update to assets.
|
||||
/// </summary>
|
||||
public bool ToAssets { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Butter.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a bulk visibility update with cascading.
|
||||
/// </summary>
|
||||
public class CascadeResultDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the number of people updated.
|
||||
/// </summary>
|
||||
public int PeopleUpdated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of albums updated.
|
||||
/// </summary>
|
||||
public int AlbumsUpdated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets updated.
|
||||
/// </summary>
|
||||
public int AssetsUpdated { get; set; }
|
||||
}
|
||||
@@ -67,7 +67,9 @@ public enum EJobType {
|
||||
/// <summary>Creates missing Person entities extracted from asset paths via folder regex patterns.</summary>
|
||||
CreatePersons,
|
||||
/// <summary>Creates missing Person and Album entities extracted from asset paths via folder regex patterns.</summary>
|
||||
CreateAlbums
|
||||
CreateAlbums,
|
||||
/// <summary>Converts animated assets (GIF) to video (mp4) and extracts a first-frame thumbnail.</summary>
|
||||
AnimatedConversion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,7 +9,7 @@ public class PersonUpdateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the person.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
public string? Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID to use as the profile picture.
|
||||
/// </summary>
|
||||
|
||||
@@ -108,6 +108,10 @@ public class StatsDto {
|
||||
/// </summary>
|
||||
public int AssetsMissingPhash { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets whose processing failed (marked broken).
|
||||
/// </summary>
|
||||
public int TotalBrokenAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets with no person assigned (no album with a PersonOwner).
|
||||
/// </summary>
|
||||
public int AssetsWithNoPerson { get; set; }
|
||||
|
||||
+2
-1
@@ -26,7 +26,6 @@ public static class MimeTypes{
|
||||
new("image/bmp}", [".bmp"]),
|
||||
new("image/cgm", [".cgm"]),
|
||||
new("image/g3fax", [".g3"]),
|
||||
new("image/gif", [".gif"]),
|
||||
new("image/heic", [".heif", ".heic"]),
|
||||
new("image/ief", [".ief"]),
|
||||
new("image/jpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfif", ".jfif-tbnl", ".jif"]),
|
||||
@@ -89,6 +88,8 @@ public static class MimeTypes{
|
||||
public static readonly MimeTypeMap[] Video = [
|
||||
new("video/3gpp", [".3gp"]),
|
||||
new("video/3gpp2", [".3g2"]),
|
||||
// Animated GIFs are repurposed as video so they flow through the video rendering path.
|
||||
new("image/gif", [".gif"]),
|
||||
new("video/h261", [".h261"]),
|
||||
new("video/h263", [".h263"]),
|
||||
new("video/h264", [".h264"]),
|
||||
|
||||
@@ -32,6 +32,12 @@ public enum Settings {
|
||||
ThumbnailQuality,
|
||||
/// <summary>Quality setting (1–100) for WebP preview encoding.</summary>
|
||||
PreviewQuality,
|
||||
/// <summary>Path to store converted videos for animated assets (e.g. GIF → mp4).</summary>
|
||||
ConvertedPath,
|
||||
/// <summary>Container format for converted videos (e.g. "mp4").</summary>
|
||||
ConvertedFormat,
|
||||
/// <summary>Path to the ffmpeg executable (defaults to "ffmpeg" on PATH).</summary>
|
||||
FfmpegPath,
|
||||
/// <summary>User ID used for scanner-created asset attribution.</summary>
|
||||
SystemUploaderId
|
||||
}
|
||||
@@ -39,7 +45,7 @@ public enum Settings {
|
||||
/// <summary>
|
||||
/// Provides extension methods for the <see cref="Settings"/> enum.
|
||||
/// </summary>
|
||||
public static class SettingsExtensions {
|
||||
public static class SettingsExtension {
|
||||
/// <summary>
|
||||
/// Converts a <see cref="Settings"/> value to its human-readable display string.
|
||||
/// </summary>
|
||||
@@ -61,6 +67,9 @@ public static class SettingsExtensions {
|
||||
Settings.JobBatchSize => "Job Batch Size",
|
||||
Settings.ThumbnailQuality => "Thumbnail Quality",
|
||||
Settings.PreviewQuality => "Preview Quality",
|
||||
Settings.ConvertedPath => "Converted Path",
|
||||
Settings.ConvertedFormat => "Converted Format",
|
||||
Settings.FfmpegPath => "FFmpeg Path",
|
||||
Settings.SystemUploaderId => "System Uploader Id",
|
||||
_ => setting.ToString() // Fallback to the enum name
|
||||
};
|
||||
|
||||
+859
@@ -0,0 +1,859 @@
|
||||
# Changelog
|
||||
|
||||
## [1.0.3.2] - 2026-08-17
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add personOwnerId album filter and opt-in asset IncludeCount
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- Page cosplayer albums via the album endpoint and skip count queries
|
||||
- Cache thumbnails and previews in the browser for 24h
|
||||
- Add filtered IX_Assets_VisibleCreatedAt index for the default listing
|
||||
- Server-side seeded shuffle, DTO projection, and opt-in count
|
||||
- Project album assets to DTO in SQL and slim the Find paths
|
||||
- Push album filter/search/sort/pagination to SQL in person FindVisible
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Add auto-save middleware as a safety net for the Save() pattern
|
||||
- Desloppify review fixes: PathFromGuid extraction, RegexHighlighter dedup, port default fix
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- Quality improvements from the desloppify scan
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Update desloppify scorecard and add it to the README
|
||||
|
||||
## [1.0.3.1] - 2026-07-22
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add sliding-window page buffer to AlbumGrid and CosplayerGrid with scroll-based observer reattach
|
||||
- Embed git version in WASM footer via AssemblyInformationalVersion
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Use status CSS variables for progress bar segment colors
|
||||
- Eliminate masonry layout race on infinite scroll and banner double-fetch (#146)
|
||||
- Masonry items stacking on infinite scroll — missing @ on ViewMode binding (#146)
|
||||
- Prevent infinite scroll cascade by debouncing observer reattach and disabling scroll anchoring
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- Replace IntersectionObserver with input-event-based infinite scroll
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Scripts and run configs to automate version injection
|
||||
- Removed outdated agent.md
|
||||
|
||||
## [1.0.3] - 2026-07-21
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Cropper handle size constant mismatch (14→18 px)
|
||||
- Jobs past tab zero-based pagination
|
||||
- Show registration disabled message on 403
|
||||
- Hide register form when registration is disabled
|
||||
- Allow manual folder scan when scheduled scan is off
|
||||
- Prevent masonry layout shift on infinite scroll
|
||||
- Order album assets by CreatedAt in album detail (#144)
|
||||
- Replace overflow-y with JS masonry for stable album layout (#146)
|
||||
- Add aspect-ratio to album cards for stable masonry height (#146)
|
||||
- Use per-card aspect-ratio from cover dimensions for stable masonry layout (#146)
|
||||
- Restore masonryLayout.dispose that was lost in reset edit
|
||||
- Use pre-pagination album count (#143)
|
||||
- Sort album assets by OriginalFilename instead of CreatedAt (#144)
|
||||
|
||||
## [1.0.2] - 2026-07-17
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Remove redundant AddJsonFile calls that overrode env vars
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Auto-run on tags only, manual dispatch on branches
|
||||
|
||||
## [1.0.1] - 2026-07-17
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Use explicit domain git.r3d.codes instead of gitea.server_url
|
||||
- Sanitize tag names for Docker compatibility (replace / with -)
|
||||
- Lowercase repository name for Docker compatibility
|
||||
- Match matrix service names to actual directory case (Lactose/MilkStream)
|
||||
- Remove gha cache config (cache server unreachable from builder)
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- Add BuildKit cache mounts for NuGet packages in Dockerfiles
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Add workflow_dispatch trigger with version input
|
||||
- Also run on push to master
|
||||
- Also run on develop and hotfix/* branches
|
||||
- Pass SixLaborsLicenseKey as build arg for lactose
|
||||
- Use REGISTRY_TOKEN (PAT) instead of GITEA_TOKEN for registry push
|
||||
|
||||
### CI
|
||||
|
||||
- Added sixlabors license key to the docker file for lactose
|
||||
|
||||
## [1.0] - 2026-07-16
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(API)* Adds Authentication System (Needs Testing)
|
||||
- *(API)* Adds UserRepository.cs
|
||||
- *(API)* Adds Password hashing
|
||||
- *(API)* Adds Password hashing
|
||||
- *(API)* Implements user delete endpoint
|
||||
- *(API)* Adds stubs for AssetController.cs
|
||||
- *(API)* Adds Album endpoint Stubs
|
||||
- *(API)* Adds TagController class
|
||||
- *(API)* Adds Tag endpoint implementation
|
||||
- *(API)* Adds Repository implementation (not tested yet)
|
||||
- *(API)* Implements TagController With Bulk Requests
|
||||
- *(API)* Adds person stub endpoint
|
||||
- *(API)* Added Resolution, mimetype and extra media data on asset table
|
||||
- *(API)* Adds computed file hash field in Asset
|
||||
- *(API)* Adds Folder and Asset entities in the DB
|
||||
- *(API)* Implemented FolderController and their respective Mappers/DTO/Repository interface and it's own implementation
|
||||
- [**breaking**] Upgraded project to .Net 9.0
|
||||
- *(API)* Implemented Settings Endpoint
|
||||
- *(API)* Implemented Media endpoint and relative Repository and DTO
|
||||
- *(DB)* Switched to PostgreSQL connector
|
||||
- *(DB)* [**breaking**] Change to use database pgvecto-rs
|
||||
- *(DB)* [**breaking**] Regenerated migrations for PostgreSQL switch
|
||||
- Switched to on the fly connection string generation
|
||||
- *(DbContext)* Adds Initialization for Development environment
|
||||
- *(API)* Adds Authentication Result as DTO for authentication
|
||||
- *(Lactose)* Adds RefreshToken
|
||||
- *(Lactose)* Adds DB Context Snapshot
|
||||
- *(Lactose)* Adds RefreshToken Endpoint
|
||||
- *(DB)* Changed Asset table to have OriginalPath as Unique Index
|
||||
- Added a settings enum + it's own extension class to retrieve interpretation type and string name in DB
|
||||
- *(db)* Adds Migration for Settings in LactoseDbContext
|
||||
- Added disposability to some repositories, changed the FileSystemScanner service to be a singleton.
|
||||
- *(API)* Added Access level to the data returned from authentication actions
|
||||
- *(frontend)* Upgraded login to new API changes
|
||||
- *(API)* Added get-all endpoint for settings controller.
|
||||
- *(frontend)* Added settings service
|
||||
- *(API)* Added Access level to the data returned from authentication actions
|
||||
- *(frontend)* Added login and JWT token auto-refresh
|
||||
- *(frontend)* Adds ServiceBase and AuthServiceBase abstract classes
|
||||
- *(frontend)* Login and logout now properly work
|
||||
- *(frontend)* Added folders service
|
||||
- *(frontend)* Nav menu restyle
|
||||
- *(API)* Adds method to modify the FileWatcherService: folders, time
|
||||
- *(API)* Edits SettingsRepository to manage FileWatcher Service
|
||||
- *(frontend)* Editing of settings and addition/editing of folder paths
|
||||
- *(frontend)* Added folder removal in settings
|
||||
- *(API)* Added get-all endpoint for settings controller.
|
||||
- *(frontend)* Added settings service
|
||||
- *(API)* Added Access level to the data returned from authentication actions
|
||||
- *(frontend)* Added login and JWT token auto-refresh
|
||||
- *(frontend)* Adds ServiceBase and AuthServiceBase abstract classes
|
||||
- *(frontend)* Login and logout now properly work
|
||||
- *(frontend)* Added folders service
|
||||
- *(frontend)* Nav menu restyle
|
||||
- *(API)* Adds method to modify the FileWatcherService: folders, time
|
||||
- *(API)* Edits SettingsRepository to manage FileWatcher Service
|
||||
- *(frontend)* Editing of settings and addition/editing of folder paths
|
||||
- *(frontend)* Added folder removal in settings
|
||||
- Migrate MilkStream frontend from Blazor SSR to Blazor WASM
|
||||
- *(cors)* Support semicolon-separated CorsAllowedOrigins env var
|
||||
- Add ParentJobId to JobStatusDto for job hierarchy tracking
|
||||
- Add ParentJobId to Job base class
|
||||
- Set ParentJobId on child jobs for ThumbnailJob, FileSystemCrawlJob, PHashJob
|
||||
- Map ParentJobId in JobMapper
|
||||
- Add generic reusable components ProgressBar, CollapsibleSection, PollRateSelector
|
||||
- Add JobRow, JobChildrenSummary, JobSection, JobTree components
|
||||
- Redesign Jobs page with compact hierarchical layout, poll rate selector, and PHash button; remove JobCard
|
||||
- Optimize jobs API with pagination, DB persistence, and cleanup
|
||||
- Add GetAssetsMissingMetadata repository method for metadata job
|
||||
- Implement MetadataJob for image resolution extraction
|
||||
- *(docker)* Add named volumes for thumbnails and previews
|
||||
- Unify settings pages into sidebar + detail panel with auto-save
|
||||
- *(assets)* Add ThumbnailSize column to track generated thumbnail dimensions
|
||||
- *(repository)* Add GetAssetsMissingOrWrongThumbnail to interface
|
||||
- *(repository)* Implement GetAssetsMissingOrWrongThumbnail query
|
||||
- *(thumbs)* Read ThumbnailSize setting, invalidate wrong-sized thumbnails, set size after gen
|
||||
- *(db)* Add migration for ThumbnailSize column on Assets
|
||||
- *(jobs)* Add CompletedWithErrors status for parents with failed sub-jobs
|
||||
- *(ui)* Show CompletedWithErrors as orange and group sub-jobs by status
|
||||
- *(jobs)* Apply batching to PHashJob and MetadataJob
|
||||
- *(settings)* Add JobBatchSize setting for batch job sizing
|
||||
- *(settings)* Add JobBatchSize setting for batch job sizing
|
||||
- *(settings)* Add runtime-configurable Po2W slider limits + split thumbnail/preview ranges
|
||||
- *(folders)* Add regex pattern field with named groups, syntax highlighting, and sample path preview
|
||||
- Masonry gallery on homepage with infinite scroll
|
||||
- Pass JWT token in media URLs for authenticated thumbnail/preview requests
|
||||
- Make navbar sticky during infinite scroll
|
||||
- Add PreviewJob and upgrade thumbnails to WebP
|
||||
- Add CreatePersons and CreateAlbums jobs
|
||||
- Add stacked progress bar for parent jobs with sub-job breakdown
|
||||
- Improve logging and progress reporting for album/person jobs
|
||||
- Add statistics page with aggregate asset/user/system stats via fast DB queries
|
||||
- Add thousands separators to job status numbers
|
||||
- Extend AssetPreviewDto with album and cosplayer names
|
||||
- Add cosplayer and album name overlays on home page
|
||||
- Add full-size view and download buttons in preview overlay
|
||||
- Add bidirectional infinite scroll (up and down)
|
||||
- Implement seed-based deterministic random ordering for home page
|
||||
- Add data completeness indicators to statistics page
|
||||
- Add CoverAssetId to albums with resolution data in full DTO
|
||||
- Add AlbumService and PersonService to WASM client
|
||||
- Add albums list page and album detail page with inline preview
|
||||
- Auto-assign album cover from first alphabetically-sorted asset
|
||||
- Make album names clickable links in home tile hover and preview
|
||||
- Justified gallery layout for album detail page
|
||||
- View mode selector for albums page (masonry/grid/list)
|
||||
- *(admin)* Add user management page for admins
|
||||
- *(admin)* Add inline editing for username, email, and password
|
||||
- *(stats)* Add percentage and stale counters to completeness cards
|
||||
- *(backend)* Add ProfileAssetId to Person model + migration
|
||||
- *(dtos)* Update Person DTOs with ProfileAssetId and album previews
|
||||
- *(backend)* Eager-load ProfileAsset and Albums in PersonRepository
|
||||
- *(backend)* Add PersonMapper with ToPersonPreviewDto and ToPersonDetailedDto
|
||||
- *(backend)* Implement person detail, update, and delete endpoints
|
||||
- *(client)* Extract AlbumCard reusable component, update Albums.razor
|
||||
- *(client)* Add PersonService client methods and PersonForm component
|
||||
- *(client)* Add Cosplayers and CosplayerDetail pages, fix NavMenu, add modularity docs
|
||||
- *(css)* Add Cosplayers and CosplayerDetail scoped CSS with banner, grid, and card styles
|
||||
- *(backend)* Add ProfileCropX/Y to Person model for profile picture cropping
|
||||
- *(ui)* Add circular profile picture cropper with drag-to-pan
|
||||
- *(ui)* Redesigned profile cropper with movable circle, dimmed exterior, square boundary, zoom controls; added ProfileCropZoom to model; switched avatars to background-image for zoom support
|
||||
- *(ui)* Add New Cosplayer button on list page with create endpoint and form
|
||||
- *(ui)* Unassigned album selector in PersonForm, Add Albums button in CosplayerDetail
|
||||
- *(dto)* Add RemovePerson flag to AlbumUpdateDto for explicit person unlinking
|
||||
- Person hard delete with album cascade, AlbumCard select mode, cosplayer list multi-select
|
||||
- Infinite scroll for albums on cosplayer detail page
|
||||
- Migrate to UTC timestamps — remove legacy Npgsql timestamp behavior
|
||||
- Make album name in image previewer an anchor element for native navigation
|
||||
- Allow users to update own albums and curators to update any album
|
||||
- Add OldPassword field to UserUpdateDto for self-service password change
|
||||
- Enforce old-password verification on own password change, allow admin override
|
||||
- Add password change UI with strength bar and validation on user profile page
|
||||
- Add server-side password policy validation (admin bypass)
|
||||
- *(ui)* Redesign user profile page with avatar initials and edit profile modal
|
||||
- *(ui)* Rewrite admin users page with sortable table, search, and modal-based editing
|
||||
- *(ui)* Show avatar initial in navbar instead of generic icon
|
||||
- *(ui)* Album detail multi-select edit mode, cosplayer link, move edit button (#63)
|
||||
- *(api)* Add BulkDeleteAlbumsAsync for bulk album deletion
|
||||
- *(ui)* Add album select/edit mode with bulk deletion
|
||||
- *(ui)* Redesign jobs page with tabs, search, filters, and mobile cards
|
||||
- Add LastChange tracking to JobStatus
|
||||
- Add ModifiedAt to JobRecord with delta query support
|
||||
- Implement delta-based children endpoint with ?since parameter
|
||||
- Add delta merge logic to job tree components
|
||||
- Poll children for all active roots and fix segment progress bar colors
|
||||
- Return-url login redirects + keepalive + cosplayers fix
|
||||
- Add password strength validation to registration page
|
||||
- Hide cosplayers with no visible content from regular users
|
||||
- *(css)* SCSS foundation with Bootstrap 5.3.7 source, new dark navy theme, Inter font
|
||||
- *(ui)* CSS variables for .razor.css, job type colors, interactive states, gradients
|
||||
- *(jobs)* Extract all job-type colors to configurable SCSS variables
|
||||
- *(ui)* CosplayerDetail banner empty gradient uses accent theme
|
||||
- *(ui)* Aggressive gradients on navbar, buttons, modal headers
|
||||
- *(ui)* Page background gradient, status/strength CSS vars, nav/image polish
|
||||
- *(ui)* Replace default favicon with MilkyShot logo SVG
|
||||
- *(cropper)* Circle handles with accent color, black border, drop shadow
|
||||
- *(gradients)* Strengthen all gradients for more aggressive effect
|
||||
- *(ui)* Apply metallic gradient to all buttons and surfaces
|
||||
- *(ui)* Add eye toggle button to show/hide password on all password fields
|
||||
- *(api)* Add sort parameters to pagination DTOs
|
||||
- *(api)* Add sorting and unassigned filter to AlbumRepository.SearchQuery
|
||||
- *(api)* Pass sort and filter params through AlbumController.Search
|
||||
- *(ui)* Add SortFilterBar component and update Albums page with search/sort
|
||||
- *(ui)* Extract AlbumGrid to isolate grid re-renders from SortFilterBar
|
||||
- *(api)* Add SearchQuery to PersonRepository with sort/search/pagination
|
||||
- *(api)* Paginate PersonController.GetAll with sort/search params
|
||||
- *(ui)* Convert Cosplayers page to infinite scroll with sort/search toolbar
|
||||
- *(ui)* Extract CosplayerGrid to isolate grid re-renders from SortFilterBar
|
||||
- Add SearchDropdown typeahead component
|
||||
- Wire SearchDropdown into NavMenu
|
||||
- Make search dropdown headers selectable with keyboard and mouse
|
||||
- Parse ?search= query param on Cosplayers and Albums pages
|
||||
- *(cosplayer-detail)* Add SortFilterBar for album search and sort
|
||||
- *(butter)* Add EVisibility enum, Maintainer access level, and SystemUploaderId setting
|
||||
- *(butter)* Update DTOs for visibility, renamed Owner→UploadedBy, removed IsPublic/Owner fields
|
||||
- *(models)* Update models for auth redesign — rename Owner→UploadedBy, drop UserOwnerId/IsPubliclyShared, add EVisibility, PersonMaintainer
|
||||
- *(infra)* Update DbContext and mappers for auth redesign — new relationships, PersonMaintainer, conditional Visibility
|
||||
- *(repos,controllers)* Update repositories, interfaces, controllers for auth redesign — visibility filtering, UploadedBy/Uploader rename, Maintainer support
|
||||
- *(db)* Add migration for auth redesign — drop IsPubliclyShared/UserOwnerId/SharedWith, add Visibility, PersonMaintainer, rename OwnerId→UploadedBy
|
||||
- *(auth)* Complete Maintainer role — add PersonMaintainer checks, update controller authz, pass uploadedBy search param
|
||||
- Add MaintainedPersonIds to UserInfoDto with backend plumbing
|
||||
- Add Maintainer to admin user dropdowns and role badges
|
||||
- Add Visibility dropdown to album and person create/edit forms
|
||||
- Add Protected Assets card to stats page
|
||||
- Expand UI gating to include Maintainer with scope-aware CanEdit
|
||||
- Maintainer cosplayer assignment in admin user edit modal
|
||||
- Maintainer assignment UI for cosplayers, maintainer display on user page
|
||||
- Expand user page edit modal for admins with access level, maintainer assignment, and danger zone
|
||||
- Make usernames clickable on admin users page, linking to /User/{id}
|
||||
- Add docker-compose.debug.yml and UseWebAssemblyDebugging for WASM debugging
|
||||
- Add role-visible colored borders on albums and assets based on item state (#78)
|
||||
- Add bulk delete and visibility change to AlbumDetail select mode
|
||||
- Show visibility/deleted breakdown in album header in select mode
|
||||
- Add Select All / Deselect All button in album select mode
|
||||
- Add album-level bulk actions to CosplayerDetail page
|
||||
- Cascade visibility to albums and assets with lower-only rule
|
||||
- Add multiselect editing controls to Cosplayers page
|
||||
- Wire up SystemUploaderId setting — seed, UI, and crawl job backfill
|
||||
- Bulk cosplayer assignment for albums from multiselect mode
|
||||
- Add reusable ConfirmDialog component
|
||||
- Add visibility-colored borders to cosplayer cards in selection mode
|
||||
- Add rate limiting with configurable buckets
|
||||
- Apply rate-limit buckets to controllers
|
||||
- Add AlbumMergeDto and PersonMergeDto
|
||||
- Add MergeAlbums to album repository
|
||||
- Add MergePeople to person repository
|
||||
- Add merge endpoints to AlbumController and PersonController
|
||||
- Add frontend service methods for merge, bulk update, and bulk delete people
|
||||
- Add MergeModal shared component for merge destination selection
|
||||
- Add Merge button to Albums.razor with AlbumGrid preview access
|
||||
- Add Merge button to Cosplayers.razor with CosplayerGrid preview access
|
||||
- Add Merge Albums button to CosplayerDetail.razor
|
||||
- Add Unlink cosplayer button to AlbumDetail.razor
|
||||
- Add UnlinkedAssetGroupDto and extend AssetSearchOptionsDto with unlinked/folder/uploader filters
|
||||
- Add trigram GIN index on Asset.OriginalFilename for efficient ILike search
|
||||
- Extend AssetRepository with unassigned, folderId, uploadedBy, search filters and GetUnlinkedGroups
|
||||
- Extend AssetController with unlinked-groups endpoint and pass new search/unassigned/folder/uploader params
|
||||
- Extend AssetService with unassigned/folder/uploader/search params and GetUnlinkedGroupsAsync
|
||||
- Add AlbumAssetPicker modal component with drillable folder sidebar, search, and multi-select
|
||||
- Integrate AlbumAssetPicker into AlbumDetail with Add Assets button and merge handler
|
||||
- Add file-browser BrowseUnlinkedAssets endpoint with directory tree from OriginalPath
|
||||
- Redesign AlbumAssetPicker with breadcrumb navigation, directory drill-down, and file browser UX
|
||||
- Add collapsible sidebar toggle to AlbumAssetPicker folder browser
|
||||
- Add list/grid view toggle, bulk delete in maintenance mode, and /maintenance page
|
||||
- Add visibility and create-album actions, fix embedded viewport sizing
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(API)* Split authentication from user endpoint
|
||||
- *(API)* Fix retrieve of user authentication data
|
||||
- *(API)* Split authentication from user endpoint
|
||||
- *(API)* Fixes null exception against anonymous users requests
|
||||
- *(API)* Fix exception for UserRepository.cs
|
||||
- *(API)* Fix Password hasher dependency resolver
|
||||
- *(API)* Adds last login update on login
|
||||
- *(API)* Fixes null exception against anonymous users requests
|
||||
- *(API)* Fix exception for UserRepository.cs
|
||||
- *(API)* Fix Password hasher dependency resolver
|
||||
- *(API)* Adds last login update on login
|
||||
- *(API)* Remove login permission to Banned or Disabled Users
|
||||
- *(API)* User endpoints to be in line with new structure
|
||||
- *(API)* Switches tagSearch method from uint to int
|
||||
- *(docker)* Fixed Dockerfile stuck on old project name and build configs
|
||||
- *(API)* Added support for pagination on Asset GetAll Query
|
||||
- *(API)* Added deleted at field in UserInfo mappers
|
||||
- *(API)* Implements GetAll tags in tag Repository
|
||||
- *(API)* Wrong logger in album controller
|
||||
- *(API)* Standardized DTO namespaces
|
||||
- *(API)* Added required instead of null initialized strings where it didn't make sense to have a null string in Users DTOs
|
||||
- *(API)* Added missing Email field in UserInfoDto
|
||||
- *(API)* Marked as required both fields of BulkDto
|
||||
- *(API)* Marked as required both fields of credentials DTOs
|
||||
- *(API)* Marked as required the tag name in TagCreateDto
|
||||
- *(API)* Marked as required all the various required fields in person related DTOs
|
||||
- *(API)* Added null checking to folderRepository get operations in FolderController
|
||||
- *(API)* Added GetAll method to Folder Repository and controller
|
||||
- *(API)* [**breaking**] Marked required properties in Asset as required
|
||||
- *(API)* Added FindByPath in AssetRepository
|
||||
- *(API)* Allow Anonymous to use MediaEndpoint
|
||||
- *(docker)* Dockerfiles updated to use Net 8 as base images
|
||||
- *(docker)* Solved mismatch with base folders in both dockerfile and compose file
|
||||
- *(docker)* Applied "Docker" as environment when building the application in a container thus avoiding not getting the correct configuration overloads at run time.
|
||||
- *(Docker)* Removes Env var from where is not needed and add it to teh compose
|
||||
- *(Docker)* Add internal db port in appsettings.Development.json
|
||||
- *(docker)* [**breaking**] Postgres volume data was not named properly
|
||||
- *(db)* Added postgres switch for Legacy Timestamp behaviour
|
||||
- *(API)* Correct reference to the return type of result
|
||||
- *(API)* Fixes the Db Initializer on Production Environment
|
||||
- *(DB)* Added check for already existing assets on DB entry.
|
||||
- *(Services)* Added FileSystem scan service to the startup of the webserver.
|
||||
- *(frontend)* Adds the new services to the app startup configuration
|
||||
- *(frontend)* Added correct headers and response format to LoginService
|
||||
- *(frontend)* Adjusted SettingsService to work with List<SettingDto> instead of SettingsDto object.
|
||||
- *(API)* Added SaveChanges to Settings repository, reflecting changes to he database
|
||||
- *(API)* Solved filesystem scanner not scanning
|
||||
- *(frontend)* Adds the new services to the app startup configuration
|
||||
- *(frontend)* Added correct headers and response format to LoginService
|
||||
- *(frontend)* Adjusted SettingsService to work with List<SettingDto> instead of SettingsDto object.
|
||||
- *(API)* Added SaveChanges to Settings repository, reflecting changes to he database
|
||||
- *(API)* Solved filesystem scanner not scanning
|
||||
- Address code review findings (blocking .Result, async void handlers, email logging)
|
||||
- Handle stale ProtectedLocalStorage value crashing NavMenu deserialization
|
||||
- Serve appsettings.json dynamically from MilkStream to fix WASM BaseUrl in Docker
|
||||
- Support wildcard CORS origin for self-hosted Docker deployments
|
||||
- Replace CORS wildcard with explicit safe origins per environment
|
||||
- Add DB healthcheck and service_healthy condition to prevent lactose startup race condition
|
||||
- Catch OperationCanceledException from Task.Delay to prevent incorrect Completed status on cancellation
|
||||
- Notify auth info changed after token refresh and handle polling errors
|
||||
- Change ParentJobId to get/set for WASM compat, remove @key from JobTree, add debug panel
|
||||
- Handle 401 responses in JwtTokenRefresher by refreshing token and retrying
|
||||
- Redirect to login when token refresh fails in JwtTokenRefresher
|
||||
- Handle 401 in JobsService directly with refresh+retry
|
||||
- Add missing Butter.Types import in AssetRepository for EAssetType
|
||||
- Remove completed sub-jobs from list so master job can finish
|
||||
- Prevent master PHashJob from spinning indefinitely on sub-job completion
|
||||
- Use oninput for live slider value update and widen range layout
|
||||
- Add minutes unit to Folder Scan Interval description in default settings
|
||||
- Handle 401 transparently at AuthServiceBase level for all services
|
||||
- Redirect unauthenticated users from settings page to /login
|
||||
- Redirect unauthenticated users from jobs page to /login
|
||||
- *(ui)* Render child jobs in JobTree by skipping root filter at deeper levels
|
||||
- *(db)* Always override ThumbnailPath/PreviewPath values with local paths on init
|
||||
- *(docker)* Create thumbnails/previews dirs as app user so subdirs can be created at runtime
|
||||
- *(ui)* Restore auth in App.razor before rendering to prevent race redirect to /login
|
||||
- *(phash)* Display child jobs grouped by status
|
||||
- *(jobs)* Prevent silent crash on large thumbnail jobs and auto-refresh child jobs
|
||||
- *(ui)* Apply poll interval to child jobs, add @key for DOM reuse, guard admin pages
|
||||
- *(ui)* Submit login form on Enter key press
|
||||
- *(jobs)* Fix IntegrityCheckJob conditional + use soft-delete
|
||||
- *(jobs)* Add delay to JobManager main loop to prevent CPU spin
|
||||
- Add DeletedAt filter to asset batch queries to exclude soft-deleted assets
|
||||
- Add AssetUser many-to-many join table for SharedWith
|
||||
- Remove AspNetCore.SassCompiler to fix Docker startup crash
|
||||
- Proper bidirectional SharedWith many-to-many migration
|
||||
- *(Lactose)* Admin and Curators media visibility
|
||||
- Wire MaxConcurrentJobs to database setting on startup and runtime changes
|
||||
- Propagate cancellation to sub-jobs in batch processing jobs
|
||||
- Cancel past refresh loop when poll rate is set to Off
|
||||
- Make job cancellation work for all job states and types
|
||||
- Per-batch progress messages and counting bug in album/person jobs
|
||||
- Skip existing AlbumAsset relationships in CreateAlbumsJob to prevent duplicate key violations on re-run
|
||||
- Add JobBatchSize to DefaultSettings.json to prevent reset on restart
|
||||
- Populate LastLogin/BannedAt/UpdatedAt in UserInfoDto mapping
|
||||
- Rewrite TopTags query to avoid EF Core collection navigation translation error
|
||||
- Remove destructive People soft-delete in IntegrityCheckJob and drop duplicate hash stats
|
||||
- Move preview info text below image instead of overlaying on bottom border
|
||||
- Download button now fetches blob and triggers actual file download
|
||||
- Remove scroll position correction from LoadPreviousPage for smoother scroll-up
|
||||
- Propagate cancellation tokens through linked child job CTS
|
||||
- Album controller Save calls, person list endpoint, media empty path guard
|
||||
- Scoped CSS deep selectors for preview info text and filename binding
|
||||
- Switch album detail page to row-based flexbox masonry
|
||||
- Remove preventDefault from album links so they actually navigate
|
||||
- Remove content-visibility and contain-intrinsic-size from album detail tiles
|
||||
- Switch album detail grid from flexbox to CSS Grid
|
||||
- Use flexbox with align-items:flex-start for album detail grid
|
||||
- Unlock body scroll on navigation, reorder masonry for left-to-right flow
|
||||
- JS-based masonry for left-to-right item flow with varying heights
|
||||
- Faulty migration that could cause lactose crash on migration apply
|
||||
- Use CSS columns for albums page, keep JS masonry only for album detail
|
||||
- Override masonry inline styles with !important for grid mode
|
||||
- Force square aspect-ratio in grid mode over inline style
|
||||
- *(controllers)* Tighten asset visibility and media access checks
|
||||
- *(AssetController)* Correct search visibility filter for #11
|
||||
- *(AlbumController)* Exclude deleted assets from search visibility for #11
|
||||
- *(admin)* Add confirm/cancel for role change and prevent admin self-demotion
|
||||
- *(user)* Hash password in Create endpoint instead of storing raw
|
||||
- *(ui)* Remove broken onfocusout handler from role edit dropdown
|
||||
- *(admin)* Show inline errors instead of replacing entire page
|
||||
- *(auth)* Show ban/disabled message on login and force-logout on refresh
|
||||
- *(auth)* Detect banned/deleted user on page load via InitializeAsync
|
||||
- *(frontend)* Sorts setting by alphabetical
|
||||
- *(Lactose)* Change to use UtcNow, even if alredy working without timeline
|
||||
- *(backend)* Add [FromQuery] to person list endpoint to fix 415 error
|
||||
- *(css)* Move card styles to AlbumCard.razor.css, use ::deep for grid-to-card selectors
|
||||
- *(ui)* Larger cosplayer cards with profile pics, move edit buttons top-right, add masonry/grid toggle
|
||||
- *(auth)* Force logout on any refresh failure, redirect to login
|
||||
- *(ui)* Add scoped modal CSS to PersonForm for proper overlay rendering
|
||||
- *(ui)* Cosplayer cards now match album card style — image fills square, text overlay at bottom
|
||||
- *(js)* Set onload before src to handle cached images
|
||||
- *(ui)* Remove empty filler cells from banner mosaic, let overlay cover gaps naturally
|
||||
- *(ui)* Always render ProfileCropper in tree, use OnParametersSet to trigger JS init
|
||||
- *(ui)* Random 3 album covers max in banner, redesigned cropper with drag-to-move and drag-handle resize, image stays static
|
||||
- *(ui)* 4 corner handles on square boundary for resizing instead of single circular handle
|
||||
- *(js)* Handle positions relative to square, not viewport
|
||||
- *(js)* Corner-drag resize with fixed opposite corner, proper visual rate matching
|
||||
- *(css)* Show full image in cropper viewport (contain instead of cover)
|
||||
- *(ui)* Correct geometry — square outer, circle inscribed inside; image-relative crop coords with background-image viewport; correct display formula for background-position
|
||||
- *(js)* Adjust stored cropZoom by image aspect ratio factor so background-size formula matches circle
|
||||
- *(js)* Store cropZoom as image-relative fraction (square_side / image_displayed_width), no adjustment needed on save
|
||||
- *(ui)* Use pixel-exact transform positioning for avatar display instead of background-size formula
|
||||
- *(js)* Use correct opposite corners for TL/TR/BL resize handles
|
||||
- *(js)* Skip resize on first mousemove if mouse hasn't moved from mousedown position
|
||||
- *(js)* Resize calculations now in viewport pixels to avoid image-relative X/Y scale mismatch on non-square images
|
||||
- *(api)* Allow unlinking person from album by directly assigning null PersonOwnerId
|
||||
- *(build)* Remove stray semicolon in onclick lambda
|
||||
- Prevent album title from being overwritten when only setting Person in update DTO
|
||||
- *(pr)* Implement BulkUpdate, BulkDelete, revert docker-compose, explain [FromQuery]
|
||||
- *(tests)* Fix pre-existing auth route tests and handle empty unassigned albums gracefully
|
||||
- *(test)* Use response.body directly instead of JSON.parse (already parsed object)
|
||||
- *(test)* Update name assertion to match renamed person
|
||||
- Create AlbumSearchParametersDto, add album pagination to person detail, fix duplicate XML param
|
||||
- Cache random banner covers in field, not computed on every render
|
||||
- *(ui)* Responsive layout fixes for mobile portrait mode (closes #48)
|
||||
- *(ui)* Collapse AdminUsers table columns and actions on mobile (#48)
|
||||
- *(frontend)* Removed faulty d-flex tag on main object
|
||||
- *(milkystream)* Fix sorted masonry for albums
|
||||
- *(ui)* Prevent JobRow horizontal overflow on mobile portrait
|
||||
- *(ui)* Change AdminUsers mobile actions dropdown from dropup to dropdown
|
||||
- *(ui)* Reduce password input width on AdminUsers to prevent cell overflow
|
||||
- *(ui)* Stack setting card description and control vertically on mobile
|
||||
- *(ui)* Prevent layout shift from save indicator width change
|
||||
- *(ui)* Prevent save indicator from shifting adjacent controls
|
||||
- *(ui)* Stack setting card body vertically — description above, control + indicator below
|
||||
- *(ui)* Right-align number inputs, let them grow to fill card width
|
||||
- *(ui)* Replace inline save indicator with Bootstrap toast notification
|
||||
- *(ui)* Restore progress bar visibility on job rows
|
||||
- Resolve UI issues #61, #62, #66
|
||||
- *(#62)* Move mobile modal fix to global MilkyShot.css — scoped ::deep cannot pierce PersonForm root element
|
||||
- *(#62)* Use explicit top/left/right/bottom instead of inset, add !important to ensure position:fixed takes effect on mobile
|
||||
- *(#62)* Lock body scroll when modal forms open to prevent position:fixed breakage on mobile
|
||||
- Propagate server-side error messages from UpdateUserAsync to UI
|
||||
- Auto-close modal on password change success, add page-level success message
|
||||
- Center profile card on page
|
||||
- Wire profile icon in navbar to user profile page
|
||||
- Default sort users table by role descending (admin on top)
|
||||
- Use bold (700) instead of semi-bold for reliable font rendering
|
||||
- *(mobile)* Keep back button inline with title, collapse admin actions into dropdown
|
||||
- Batch-processing subjobs report correct status on asset failures
|
||||
- Remove double-count of failed assets in master Done handler
|
||||
- Metadata icon in dropdown uses text-light for visibility
|
||||
- Metadata icon visible in job list rows
|
||||
- Pass null since on first child fetch instead of DateTime.MinValue
|
||||
- Eagerly fetch children on load and hide sub-jobs placeholder
|
||||
- Re-render after eager child fetch so icons appear immediately
|
||||
- Eager-fetch children for all roots including past jobs
|
||||
- Update home logged-out message to match cosplayers wording
|
||||
- Update albums logged-out message to match home/cosplayers
|
||||
- Translate GetAllVisible to client-side eval for SharedWith navigation
|
||||
- Suppress nullable warnings on Include chain in GetAllVisible
|
||||
- Remove soft-delete from Person model
|
||||
- *(jobs)* Add hover/active variants for all job types via darken()
|
||||
- *(ui)* Modal icon order, fill all outline buttons
|
||||
- *(favicon)* Render MilkyShot logo as PNG favicon
|
||||
- *(ui)* Page bg radial gradient, navbar radial glow, favicon cache
|
||||
- *(cropper)* Revert ProfileCropper to inline modal
|
||||
- *(cropper)* Restore missing cropper CSS styles
|
||||
- *(cropper)* Add pointer-events:auto on handles, fix cursors
|
||||
- *(ui)* Fix float values in the cosplayer thumbnail and cosplayer card
|
||||
- *(ui)* Fix remaining float culture issues in CSS style attributes
|
||||
- *(ui)* Prevent infinite loading loop on cosplayers page with no data
|
||||
- *(ui)* Keep header and toolbar visible when albums search returns empty
|
||||
- *(ui)* Debounce search input and prevent backspace navigation
|
||||
- *(ui)* Refocus search input after debounce triggers reload
|
||||
- *(ui)* Move album grid CSS to AlbumGrid.razor.css for scoped isolation
|
||||
- *(ui)* Add height:100% to album-card-fallback so icon centers vertically in grid mode
|
||||
- *(ui)* Add margin-bottom to SortFilterBar for spacing from grid
|
||||
- *(ui)* Add ActionButtons slot to SortFilterBar, move album actions into bar
|
||||
- *(ui)* Push ActionButtons to right with ms-auto wrapper
|
||||
- *(ui)* Keep header and toolbar visible when cosplayers search returns empty
|
||||
- *(ui)* Move cosplayer grid CSS to CosplayerGrid.razor.css for scoped isolation
|
||||
- *(ui)* Move cosplayer actions into SortFilterBar, fix sortAsc always sending
|
||||
- Close outer div and remove invalid @{} blocks in SearchDropdown
|
||||
- Pressing enter with nothing selected does nothing
|
||||
- Move query param parsing to OnParametersSet
|
||||
- Parse search query before await in Albums OnInitializedAsync
|
||||
- Use case-insensitive ILike search for persons and albums
|
||||
- Override Bootstrap dropdown-menu display:none
|
||||
- Return 401 Unauthorized when user data is null in PersonController
|
||||
- Allow anonymous search for persons and albums
|
||||
- Materialize person search query before client-side visibility filter
|
||||
- Restrict user list to admin only
|
||||
- Add [FromQuery] to TagController.GetAll parameter
|
||||
- Use zero-based page numbers in AssetController to match API convention
|
||||
- Guard ILIKE filter in AlbumRepository.SearchQuery against null/empty query
|
||||
- Restore Include(p => p.Albums) in PersonRepository queries
|
||||
- Paginate PersonRepository.SearchQuery by ID before loading albums
|
||||
- Prevent duplicate @key errors across all list renderings
|
||||
- Filter album count by user visibility in person search
|
||||
- Restore ToAlbumPreviewDto mapper method per review feedback #91
|
||||
- Standardize pagination to zero-based across all layers
|
||||
- Move SharedWith visibility check to client side for translatable queries
|
||||
- Only reject Page < 0, keep original PageSize validation
|
||||
- Restore PageSize < 1 validation with upper limit of 250
|
||||
- *(cosplayer-detail)* Prevent banner image shuffle on album search/sort
|
||||
- *(cosplayer-detail)* Debounce search, keep page intact on filter changes
|
||||
- *(cosplayer-detail)* Remove search debounce, instant inline reload
|
||||
- Replace untranslatable Album navigation in stats queries with direct join table queries
|
||||
- Replace untranslatable Tags navigation in TopTags stats query with direct join table query
|
||||
- *(albums)* Use MaxPageSize constant for unassigned album fetch
|
||||
- Implement CosplayersMissingProfile stat
|
||||
- Make card components middle-clickable
|
||||
- Remove underline from album card person link
|
||||
- *(album-form)* Searchable person combobox with modal-safe positioning
|
||||
- *(jobs)* Use grey for queued segments in layered progress bar
|
||||
- *(repo)* Scope Maintainer to only maintained persons in GetAllVisible and SearchQuery
|
||||
- *(controller)* Scope Maintainer album visibility to only maintained persons
|
||||
- *(controller)* Scope Maintainer asset access to only maintained cosplayers in Get
|
||||
- *(controller)* Add Maintainer scope to Album BulkUpdate, Delete, BulkDelete
|
||||
- *(mapper)* Allow Maintainer to see Visibility field in DTO responses
|
||||
- *(media)* Replace bool parameter with EAccessLevel, add deleted asset check
|
||||
- *(stats)* Add ProtectedAssets count, fix PrivateAssets to count only Private
|
||||
- *(dto)* Default AssetCreateDto.Visibility to Private instead of Protected
|
||||
- *(repo)* Use PagedParametersDto.MaxPageSize const instead of hardcoded 150
|
||||
- *(migration)* Remap existing EAccessLevel values to prevent data corruption
|
||||
- *(migration)* Default Visibility to Protected instead of Public, preserve old IsPubliclyShared mapping
|
||||
- *(dto)* Make AssetCreateDto.UploadedBy nullable for scanner-created assets
|
||||
- *(model)* Add [PrimaryKey] annotation to PersonMaintainer for clarity
|
||||
- *(repo)* Scope AlbumRepository.SearchQuery maintainer visibility to maintained persons
|
||||
- *(controller)* Scope AssetController maintainer permissions via album→person chain
|
||||
- *(mapper)* Replace null-forgiving operator with null-conditional in AlbumMapper
|
||||
- *(controller)* Split Maintainer from User in AssetController.GetAll visibility filter
|
||||
- *(media)* Allow Maintainer to access media files for cosplayers they maintain
|
||||
- *(mapper)* Scope asset counts in album/ person previews to visible assets
|
||||
- Add missing app.css link to index.html
|
||||
- Null reference in User page maintainer display
|
||||
- Remove underline from user links, hide email from non-admin viewers
|
||||
- Remove underline from maintainer links, load assigned person names in user edit modal
|
||||
- NRE on user page when user data not yet loaded
|
||||
- Show email to self-viewing users on user page
|
||||
- Enforce data visibility per access level with test coverage
|
||||
- Replace jsonPath with response.body for null-value checks in visibility tests
|
||||
- Replace remaining jsonPath calls in asset visibility tests
|
||||
- Allow maintainer assignment for all user roles in UI
|
||||
- Address PR #127 review issues — fallback, eager load, indentation, Maintainer tests
|
||||
- Show maintained cosplayers section for all user types in admin edit modal
|
||||
- Show maintained cosplayers section for all user types in user profile edit modal
|
||||
- Add entity-level visibility gating for albums and persons
|
||||
- Update border colors and browse-mode visibility in AlbumDetail
|
||||
- Show borders for maintainers in select mode
|
||||
- Prevent jagged wrapping of toolbar and action buttons
|
||||
- Album-card-select and cosplayer-card-select only on selected cards
|
||||
- Wrap CosplayerDetail action buttons into mobile dropdown
|
||||
- Add cascade checkbox to CosplayerDetail album visibility modal
|
||||
- Cosplayers page bugs
|
||||
- Prevent private assets with null UploadedBy leaking to anonymous users
|
||||
- Homepage preview navigation, grid observer crash, and cascade labels
|
||||
- Label Delete button and cropper modal overlay
|
||||
- Album detail back button uses history.back() with fallback to /albums
|
||||
- Soft-delete users in UserController.Delete instead of hard-delete
|
||||
- Add ConfirmDialog to AdminUsers delete user flow
|
||||
- Add ConfirmDialog to User delete user flow
|
||||
- Add ConfirmDialog to AlbumDetail delete flows
|
||||
- Add ConfirmDialog to CosplayerDetail delete flows
|
||||
- Add ConfirmDialog to Albums bulk delete flow
|
||||
- Add ConfirmDialog to Cosplayers bulk delete flow
|
||||
- Add ConfirmDialog to Settings folder deletion flow
|
||||
- *(UI)* Make the delete cosplayer button in cosplayer detail page be just an icon to conform with albums detail page
|
||||
- Apply Visibility and MaintainerUserIds in PersonController.BulkUpdate
|
||||
- Split multi-request test blocks into single-request blocks
|
||||
- Guard against null PersonOwnerId in MergePeople albums query
|
||||
- Gate registration behind UserRegistrationEnabled setting
|
||||
- Handle 204/empty responses in AssetService and always reset isLoading in AlbumAssetPicker
|
||||
- Use Albums.Count==0 instead of !Any() for unlinked filter, add trace logging
|
||||
- Use OnParametersSetAsync instead of fire-and-forget to ensure Blazor re-renders after init
|
||||
- Use parameterized SqlQueryRaw instead of unquoted string.Format to prevent SQL error
|
||||
- Filter directory names to only paths containing a slash after prefix, excluding filenames
|
||||
- Restrict maintenance page and nav link to admin only
|
||||
- Add scoped CSS for maintenance page to make embedded picker fill viewport
|
||||
- Inline all markup in .razor template to restore CSS isolation
|
||||
- Change LoginService from Singleton to Scoped to match Blazored.LocalStorage lifetime
|
||||
- Simplify maintenance page CSS to flex-fill container without vh calc
|
||||
- Adjust embedded picker viewport height offset
|
||||
- Address PR #142 review issues
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- *(API)* Switch to repository pattern
|
||||
- *(API)* Switch to use UserRepository AuthController.cs
|
||||
- *(API)* Switch to repository pattern
|
||||
- *(API)* Switch to use UserRepository AuthController.cs
|
||||
- *(API)* Cleans the code
|
||||
- *(API)* Cleans the code
|
||||
- *(API)* Extract BulkDtoinseparate file
|
||||
- *(API)* Switch to use a class for paged search
|
||||
- *(API)* Adds Bulk capabilities
|
||||
- *(API)* Move classes and Standardlize
|
||||
- *(API)* Rename Asset Dtos
|
||||
- *(API)* AlbumController explicitly marked all of the x in lambdas as they were accidentally hiding previous stage, also improved null checking
|
||||
- Reformatted AssetController and AssetCreateDto files
|
||||
- *(API)* Asset controller reformatted params
|
||||
- Improve readability of CorsAllowedOrigins parsing block
|
||||
- *(jobs)* Flatten ThumbnailJob to batch-based sub-jobs
|
||||
- *(jobs)* Flatten PHashJob to batch-based sub-jobs
|
||||
- *(jobs)* Flatten MetadataJob to batch-based sub-jobs
|
||||
- Make LoginService singleton, centralize auth state and persistence
|
||||
- Extract ImagePreview shared component from Home and AlbumDetail
|
||||
- Remove list view, add grid/masonry toggle to album detail
|
||||
- *(css)* Move shared modal styles to global MilkyShot.css/SCSS, remove duplicated scoped CSS
|
||||
- *(css)* Remove SCSS, switch to plain CSS with Bootstrap custom properties
|
||||
- *(api)* Fold unassigned album filter into search endpoint via query param
|
||||
- *(dto)* Move Unassigned filter into PagedSearchParametersDto
|
||||
- Replace individual albumPage/albumSize params with PagedParametersDto in person detail endpoint
|
||||
- Replace custom masonry JS with Bootstrap grid + Masonry.js approach
|
||||
- *(auth)* Extract AuthServiceBase, replace retry with re-auth on 401
|
||||
- *(auth)* Remove SendWithRefreshAsync, rely solely on JwtTokenRefresher
|
||||
- *(ui)* Replace inline password form with modal
|
||||
- *(ui)* Complete user profile page redesign with card layout
|
||||
- Move cosplayer link to same line as album name
|
||||
- Center-align header, album name on top, cosplayer smaller below
|
||||
- Extract password strength UI into shared PasswordField component
|
||||
- Replace tuple + custom header with ChildrenResponse DTO
|
||||
- *(ui)* ModalFrame + EmptyState components, remove duplicate CSS, remove main border
|
||||
- *(jobs)* Use darken() for phash and create-albums hover/active too
|
||||
- Project PersonPreviewDto directly in repository instead of [NotMapped] entity property
|
||||
- Project AlbumPreviewDto directly in repository with visibility-aware asset count
|
||||
- Clean up imports, simplify checks, and add SearchDropdown component
|
||||
- *(albums)* Extract visibility-aware AssetCount into AlbumMapper overload
|
||||
- Replace hardcoded 250 page size limit with PagedParametersDto.MaxPageSize
|
||||
- Use MaxPageSize constant as default pageSize across all frontend services
|
||||
- Reuse AlbumGrid in CosplayerDetail via FetchAlbums delegate
|
||||
- Move all visibility filtering from mappers and controllers to repositories
|
||||
- Rename AlbumSearchParametersDto.UploadedBy to AssetUploadedBy
|
||||
- Extract role badge colors to SCSS theme layer
|
||||
- Generate access level dropdown from enum values
|
||||
- Extract role checks to LoginService, update all consumers
|
||||
- Move CanEdit logic to LoginService, remove local page properties
|
||||
- Extract person search/assign UI into shared SearchAssignBadges component
|
||||
- Extract VisibilityModal shared component, add editing controls to Albums page
|
||||
- Centralize cascade visibility logic in AlbumService.CascadeVisibilityToAssetsAsync
|
||||
- Replace inline visibility modal with shared VisibilityModal in Cosplayers.razor
|
||||
- Move Unlink cosplayer button from album header to album edit form
|
||||
- Add Embedded mode, UnlinkedOnly param, rename methods from Unlinked to generic names
|
||||
- Rename UnlinkedAssetGroupDto to AssetGroupDto
|
||||
- Replace individual [FromQuery] params with AssetBrowseOptionsDto
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Update agents.md for Blazor WASM architecture
|
||||
- Add CORS configuration section to README
|
||||
- *(readme)* Add wildcard CORS warning and MilkStream CORS setup guide
|
||||
- Add XML documentation to all public APIs across all projects
|
||||
- Update AGENTS.md with XML doc enforcement and CORS guidance
|
||||
- Update AGENTS.md with verified infrastructure gotchas
|
||||
- Add API reuse convention to AGENTS.md — prefer optional query params over new endpoints
|
||||
- Add Gitea repo owner note to AGENTS.md
|
||||
- Add granular commit convention to AGENTS.md
|
||||
- Document REST Client tests in AGENTS.md
|
||||
- Update AGENTS.md with missing conventions and project info
|
||||
- Add use case catalog for new auth/permissions model
|
||||
- Add Gitea label levels note to AGENTS.md
|
||||
- Add PR workflow section to AGENTS.md
|
||||
- Reconcile AGENTS.md with current codebase
|
||||
- *(repo)* Fix stale pageSize default in IAlbumRepository XML doc
|
||||
- Add data size estimates to AGENTS.md
|
||||
- Clarify Curators and Admins can be maintainers for credited attribution
|
||||
- Fix stale column references and tooling versions in AGENTS.md
|
||||
- Add visibility rules (R1–R7) to AGENTS.md
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- Push visibility filter into SQL for PersonRepository queries
|
||||
- Add partial index on Assets for visibility-aware search queries
|
||||
- Simplify ORDER BY to total count and add trigram GIN indexes
|
||||
- Add AsSplitQuery to repository queries with multiple Include calls
|
||||
- Replace in-memory path parsing with SQL SPLIT_PART aggregation and double-LIKE filter
|
||||
- Replace in-memory path parsing with SELECT DISTINCT SQL, remove dir count badges
|
||||
|
||||
### 🎨 Styling
|
||||
|
||||
- Style cosplayer link with inherited color and person icon
|
||||
- Match cosplayer link font size to h3, remove icon, keep regular weight
|
||||
- Consistent inline typography for album title and cosplayer name with dash separator
|
||||
- Borderless edit pencil with hover reveal on header
|
||||
- Cosplayer link dims on hover instead of blue/underline
|
||||
- Replace badge backgrounds with colored icons + matching launch buttons
|
||||
- Custom job color palette based on purpose, avoid red
|
||||
- Mobile launch button cyan for better contrast
|
||||
- Soften accent gradient stops (5%/10% instead of 15%/30%)
|
||||
- Use rounded-square thumbnails for album results
|
||||
- Add chevron-right indicator to search dropdown headers
|
||||
- Use Bootstrap dropdown-menu and dropdown-item classes
|
||||
- *(repo)* Convert visibility filters to switch expressions for readability
|
||||
- *(repo)* Expand switch expression arms for readability
|
||||
- *(repo)* Use explicit named enum cases in PersonRepository switch expressions
|
||||
- Standardize access level comparison to switch expressions
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
- More unit testing on HTTP client
|
||||
- *(api)* Add person endpoint tests to WepApiTest.http
|
||||
- *(api)* Add comprehensive http tests for all person and album endpoints
|
||||
- Add album update test without removePerson field
|
||||
- Use real album from search for update test, not zero-GUID fallback
|
||||
- All tests create their own data — create album returns ID, all mutations use self-created resources
|
||||
- Add endpoint tests for refresh token, albums, curator auth levels
|
||||
- Add anonymous search tests for persons and albums
|
||||
- Handle empty person search results gracefully
|
||||
- Set personId from admin search to ensure it's always populated
|
||||
- Add user id to profile update request body
|
||||
- Fix curator user access level from Admin (2) to Curator (1)
|
||||
- Restructure WepApiTest.http with three clearly separated users
|
||||
- Add cleanup of regular test user in REST Client suite
|
||||
- Add page=0&pageSize=5 to paginated list endpoints
|
||||
- Add merge and bulk visibility tests to WepApiTest.http
|
||||
- Flatten test numbering to sequential 1–137
|
||||
- Added rate limiting stress test
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Removes auto generated docker-compose file for deployment
|
||||
- *(docker)* Removes auto generated docker-compose file for deployment
|
||||
- *(API)* Changes the category name of the logger
|
||||
- Added datagrip files
|
||||
- *(config)* Separates connection string defaults for docker and local webserver run
|
||||
- Removed unused using in Program.cs
|
||||
- Swapped empty array initializer with empty collection initializer in Asset hash
|
||||
- Commented out unused logger in Album controller and added a TODO: about adding logging capabilities
|
||||
- Marked navigation property in folder as nullable
|
||||
- Commented out logger in FolderController and added a TODO regarding logging
|
||||
- Removed redundant type cast in tag search
|
||||
- Removed useless using declarations in TagMapper
|
||||
- Commented out the logger in tagController and added a TODO regarding logging
|
||||
- Removed more unused using directives
|
||||
- *(docker)* Added NoCache build option for lactose
|
||||
- *(docker)* Updated dockerfile to build using dotnet 9.0
|
||||
- *(NuGet)* [**breaking**] Added dependency to Magick.NET-Q16-HDRI-OpenMP-x64
|
||||
- Updated nuget packages to latest releases supporting Net 8.0, removed ImageMagick package.
|
||||
- Removed MySql package from required nuget packages
|
||||
- Refined db connector params
|
||||
- *(API)* Removes Redundant Check
|
||||
- Split TokenGeneratorProvider file in their respective classes
|
||||
- Moved milkstream services under a services namespace
|
||||
- *(frontend)* Switched to a more recent session storage implementation which doesn't have a security vulnerability.
|
||||
- Updated run profiles
|
||||
- Moved milkstream services under a services namespace
|
||||
- *(frontend)* Switched to a more recent session storage implementation which doesn't have a security vulnerability.
|
||||
- Updated run profiles
|
||||
- Remove temporary debug panel from Jobs page
|
||||
- Remove redundant XML doc comments from ServiceBase
|
||||
- Upgrade target framework to net10.0
|
||||
- Add dotnet-tools.json for ef tool
|
||||
- *(dev)* Fix ovveride PM Compose deploy
|
||||
- Remove debug auth logging middleware from Program.cs
|
||||
- Remove unused banner-cell-empty CSS class
|
||||
- *(test)* Remove Swagger test — disabled in container environments
|
||||
- Added the test images folder to the dockerignore avoiding them being sent every time a docker build starts
|
||||
- Revert docker-compose.yml to match develop
|
||||
- Updated packages, removed unused packages. ImageSharp now requires a license
|
||||
- Commit compiled CSS for role badge classes
|
||||
- Fix Dockerfiles, add debug compose to solution
|
||||
- Use record for config classes
|
||||
- Adjusted rate-limits of thumbs, preview and jobs to avoid triggering them during normal usage
|
||||
- Add Gitea Actions workflow to build and push containers on version tags
|
||||
|
||||
### 🛡️ Security
|
||||
|
||||
- Upgrade to .NET 10 — packages, Docker images, and Swagger fix
|
||||
|
||||
### ◀️ Revert
|
||||
|
||||
- Switch album detail back to masonry layout
|
||||
- Restore docker-compose.yml — testing-only CORS/baseUrl changes committed by accident
|
||||
- Restore docker-compose.yml LactoseBaseUrl to 192.168.50.100
|
||||
- Remove AllowAnonymous from AlbumController.Search and PersonController.GetAll
|
||||
- Change LoginService back to Singleton (DI error was from docker fast mode)
|
||||
|
||||
### CI
|
||||
|
||||
- Added build + push to internal registry action
|
||||
|
||||
### Fix
|
||||
|
||||
- *(API)* Disables Asset Create
|
||||
- *(API)* Refresh token being changed the last 10 minutes instead of every moment
|
||||
- *(API)* Refresh token being changed the last 10 minutes instead of every moment
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ public class RefreshTokenTransformation(
|
||||
/// </summary>
|
||||
public const string ClaimType = "LoginValid";
|
||||
/// <inheritdoc />
|
||||
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
|
||||
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
|
||||
{
|
||||
ClaimsIdentity claimsIdentity = new ClaimsIdentity();
|
||||
if (principal.HasClaim(claim => claim.Type == ClaimType)) {
|
||||
@@ -68,12 +68,12 @@ public class RefreshTokenTransformation(
|
||||
}
|
||||
}
|
||||
var userData = authService.GetUserData(principal);
|
||||
if (userData == null) return Task.FromResult(principal);
|
||||
var user = userRepository.Find(userData.Id);
|
||||
if (user == null) return Task.FromResult(principal);
|
||||
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.UtcNow) return Task.FromResult(principal);
|
||||
if (userData == null) return principal;
|
||||
var user = await userRepository.FindAsync(userData.Id, CancellationToken.None);
|
||||
if (user == null) return principal;
|
||||
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.UtcNow) return principal;
|
||||
claimsIdentity.AddClaim(new Claim(ClaimType, "true", ClaimValueTypes.Boolean));
|
||||
principal.AddIdentity(claimsIdentity);
|
||||
return Task.FromResult(principal);
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,14 @@ public class LactoseDbContext : DbContext {
|
||||
/// Gets or sets the person-maintainer join table.
|
||||
/// </summary>
|
||||
public DbSet<PersonMaintainer> PersonMaintainers { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the per-directory scan-state table used by incremental file-system scans.
|
||||
/// </summary>
|
||||
public DbSet<DirectoryScanState> DirectoryScanStates { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the temporary staging table used by the person/album discovery job.
|
||||
/// </summary>
|
||||
public DbSet<AssetAlbumStaging> AssetAlbumStaging { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initialises a new instance of the <see cref="LactoseDbContext"/> class.
|
||||
@@ -65,6 +73,7 @@ public class LactoseDbContext : DbContext {
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||
modelBuilder.HasPostgresExtension("vector");
|
||||
modelBuilder.HasPostgresExtension("pg_trgm");
|
||||
modelBuilder.HasDbFunction(() => PgFunctions.Md5(default!));
|
||||
//Album Relationships
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.CoverAsset);
|
||||
@@ -78,10 +87,41 @@ public class LactoseDbContext : DbContext {
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.CreatedAt })
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(a => a.OriginalFilename)
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm")
|
||||
.HasMethod("gin")
|
||||
.HasOperators("gin_trgm_ops");
|
||||
// Stats-supporting partial indexes
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.CreatedAt)
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.Type)
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.ThumbnailPath)
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.PreviewPath)
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.Hash)
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.ResolutionWidth, e.ResolutionHeight, e.Type })
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
// Broken assets
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.ProcessFailedAt)
|
||||
.HasDatabaseName("IX_Assets_ProcessFailedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
// Keyset pagination for scan jobs:
|
||||
// WHERE FolderId = X AND DeletedAt IS NULL AND IngestedAt >= @cursor AND Id > @last ORDER BY Id
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.FolderId, e.IngestedAt, e.Id })
|
||||
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
// People
|
||||
modelBuilder.Entity<Person>().HasIndex(p => p.Name)
|
||||
.HasDatabaseName("IX_People_Name_Trgm")
|
||||
@@ -111,5 +151,17 @@ public class LactoseDbContext : DbContext {
|
||||
modelBuilder.Entity<User>().HasMany(e => e.UploadedAssets).WithOne(e => e.Uploader).HasForeignKey(e => e.UploadedBy);
|
||||
//Folder Relationships
|
||||
modelBuilder.Entity<Folder>().HasMany(e => e.Assets).WithOne(e => e.Folder);
|
||||
//Scan state
|
||||
modelBuilder.Entity<DirectoryScanState>().HasOne(e => e.Folder).WithMany().HasForeignKey(e => e.FolderId);
|
||||
modelBuilder.Entity<DirectoryScanState>()
|
||||
.HasIndex(e => new { e.FolderId, e.Path })
|
||||
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path")
|
||||
.IsUnique();
|
||||
//Discovery staging
|
||||
modelBuilder.Entity<AssetAlbumStaging>().HasOne(e => e.Asset).WithMany().HasForeignKey(e => e.AssetId);
|
||||
modelBuilder.Entity<AssetAlbumStaging>().HasIndex(e => e.AssetId)
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
|
||||
modelBuilder.Entity<AssetAlbumStaging>().HasIndex(e => e.PersonName)
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Maps PostgreSQL functions not covered by the Npgsql provider for use in LINQ-to-SQL queries.
|
||||
/// </summary>
|
||||
public static class PgFunctions {
|
||||
/// <summary>
|
||||
/// Maps to the PostgreSQL <c>md5</c> function. Only callable inside LINQ-to-SQL queries.
|
||||
/// </summary>
|
||||
/// <param name="value">The string to hash.</param>
|
||||
/// <returns>The md5 hex digest of <paramref name="value"/>.</returns>
|
||||
[DbFunction("md5", "pg_catalog")]
|
||||
public static string Md5(string value) => throw new NotSupportedException("PgFunctions.Md5 is only usable inside LINQ-to-SQL queries.");
|
||||
}
|
||||
@@ -32,13 +32,14 @@ public class AlbumController(
|
||||
/// Gets an album by its ID with filtered assets based on access level.
|
||||
/// </summary>
|
||||
/// <param name="id">The album ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The full album details with accessible assets, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<AlbumFullDto> Get([FromRoute] Guid id) {
|
||||
public async Task<ActionResult<AlbumFullDto>> Get([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
Guid? uid = authService.GetUserData(User)?.Id;
|
||||
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var album = albumRepository.FindVisible(id, uid, accessLevel);
|
||||
var album = await albumRepository.FindVisibleAsync(id, uid, accessLevel, cancellationToken);
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
@@ -50,15 +51,16 @@ public class AlbumController(
|
||||
/// Searches albums with pagination, optional search term, sort options, and optional unassigned filter.
|
||||
/// </summary>
|
||||
/// <param name="pagingOptions">Pagination, search, sort, and filter parameters.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of album previews accessible to the user.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<List<AlbumPreviewDto>> Search([FromQuery] AlbumSearchParametersDto pagingOptions) {
|
||||
public async Task<ActionResult<List<AlbumPreviewDto>>> Search([FromQuery] AlbumSearchParametersDto pagingOptions, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if(pagingOptions.Page < 0 || pagingOptions.PageSize < 1 || pagingOptions.PageSize > PagedParametersDto.MaxPageSize) return BadRequest();
|
||||
|
||||
var albums = albumRepository.SearchQuery(
|
||||
var albums = await albumRepository.SearchQueryAsync(
|
||||
pagingOptions.Search ?? string.Empty,
|
||||
pagingOptions.Page,
|
||||
pagingOptions.PageSize,
|
||||
@@ -67,8 +69,10 @@ public class AlbumController(
|
||||
pagingOptions.Unassigned,
|
||||
uid ?? default,
|
||||
accessLevel,
|
||||
pagingOptions.AssetUploadedBy
|
||||
).ToList();
|
||||
pagingOptions.AssetUploadedBy,
|
||||
pagingOptions.PersonOwnerId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Ok(albums);
|
||||
}
|
||||
@@ -78,37 +82,36 @@ public class AlbumController(
|
||||
/// </summary>
|
||||
/// <param name="id">The album ID.</param>
|
||||
/// <param name="albumDto">The album update data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 403 if forbidden, 404 if not found.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto) {
|
||||
public async Task<ActionResult> Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto, CancellationToken cancellationToken) {
|
||||
Guid? uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var album = albumRepository.Find(id);
|
||||
var album = await albumRepository.FindWithAssetsAsync(id, cancellationToken);
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
|
||||
// Maintainer scope check — must maintain the album's person
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Maintainer when album.PersonOwnerId == null || !personRepository.IsMaintainerOf(uid!.Value, album.PersonOwnerId.Value):
|
||||
return Forbid();
|
||||
case EAccessLevel.User:
|
||||
return Forbid();
|
||||
}
|
||||
if (accessLevel == EAccessLevel.Maintainer && (album.PersonOwnerId == null || !await personRepository.IsMaintainerOfAsync(uid!.Value, album.PersonOwnerId.Value, cancellationToken)))
|
||||
return Forbid();
|
||||
if (accessLevel == EAccessLevel.User)
|
||||
return Forbid();
|
||||
|
||||
// Update album properties
|
||||
album.Title = string.IsNullOrEmpty(albumDto.Name) ? album.Title : albumDto.Name;
|
||||
album.UpdatedAt = DateTime.UtcNow;
|
||||
album.PersonOwnerId = albumDto.RemovePerson ? null : albumDto.Person ?? album.PersonOwnerId;
|
||||
album.Visibility = albumDto.Visibility ?? album.Visibility;
|
||||
album.Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : album.Assets;
|
||||
album.Assets = albumDto.Assets != null ? await assetRepository.FindBulkAsync(albumDto.Assets, cancellationToken) : album.Assets;
|
||||
album.CoverAssetId = albumDto.CoverAssetId ?? album.CoverAssetId;
|
||||
|
||||
// Save changes
|
||||
albumRepository.Update(album);
|
||||
albumRepository.Save();
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -117,24 +120,31 @@ public class AlbumController(
|
||||
/// Updates multiple albums in a bulk operation.
|
||||
/// </summary>
|
||||
/// <param name="bulkDto">The bulk update data with album IDs and shared values.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 403 if forbidden.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto) {
|
||||
public async Task<ActionResult> BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var albums = albumRepository.FindBulk(bulkDto.Ids).ToList();
|
||||
var albums = await albumRepository.FindBulkAsync(bulkDto.Ids, cancellationToken);
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User:
|
||||
// Users can't update albums
|
||||
return Forbid();
|
||||
case EAccessLevel.Maintainer:
|
||||
case EAccessLevel.Maintainer: {
|
||||
// Maintainers can only update albums of persons they maintain
|
||||
albums = albums.Where(a => a.PersonOwnerId != null && personRepository.IsMaintainerOf(uid!.Value, a.PersonOwnerId.Value)).ToList();
|
||||
List<Album> maintained = [];
|
||||
foreach (var a in albums) {
|
||||
if (a.PersonOwnerId != null && await personRepository.IsMaintainerOfAsync(uid!.Value, a.PersonOwnerId.Value, cancellationToken))
|
||||
maintained.Add(a);
|
||||
}
|
||||
albums = maintained;
|
||||
if (albums.Count == 0) return Forbid();
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.Curator:
|
||||
// Curators can update any album
|
||||
break;
|
||||
@@ -143,32 +153,44 @@ public class AlbumController(
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var album in albums) {
|
||||
album.Title = bulkDto.Data.Name ?? album.Title;
|
||||
album.UpdatedAt = DateTime.UtcNow;
|
||||
album.PersonOwnerId = bulkDto.Data.Person ?? album.PersonOwnerId;
|
||||
album.Visibility = bulkDto.Data.Visibility ?? album.Visibility;
|
||||
album.Assets = bulkDto.Data.Assets != null ? assetRepository.FindBulk(bulkDto.Data.Assets).ToList() : album.Assets;
|
||||
album.CoverAssetId = bulkDto.Data.CoverAssetId ?? album.CoverAssetId;
|
||||
}
|
||||
var result = await albumRepository.ExecuteInTransactionAsync(async () => {
|
||||
foreach (var album in albums) {
|
||||
album.Title = bulkDto.Data.Name ?? album.Title;
|
||||
album.UpdatedAt = DateTime.UtcNow;
|
||||
album.PersonOwnerId = bulkDto.Data.Person ?? album.PersonOwnerId;
|
||||
album.Visibility = bulkDto.Data.Visibility ?? album.Visibility;
|
||||
album.Assets = bulkDto.Data.Assets != null ? await assetRepository.FindBulkAsync(bulkDto.Data.Assets, CancellationToken.None) : album.Assets;
|
||||
album.CoverAssetId = bulkDto.Data.CoverAssetId ?? album.CoverAssetId;
|
||||
}
|
||||
|
||||
albumRepository.UpdateBulk(albums);
|
||||
albumRepository.Save();
|
||||
var result = new CascadeResultDto { AlbumsUpdated = albums.Count };
|
||||
var cascade = bulkDto.Cascade;
|
||||
var visibility = bulkDto.Data.Visibility;
|
||||
|
||||
return Ok();
|
||||
if (cascade?.ToAssets == true && visibility.HasValue) {
|
||||
result.AssetsUpdated = await assetRepository.BulkSetVisibilityByAlbumIdsAsync(
|
||||
albums.Select(a => a.Id).ToList(), visibility.Value, uid, accessLevel, CancellationToken.None);
|
||||
}
|
||||
|
||||
await albumRepository.SaveAsync(CancellationToken.None);
|
||||
return result;
|
||||
});
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new album.
|
||||
/// </summary>
|
||||
/// <param name="albumDto">The album creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public ActionResult Create([FromBody] AlbumCreateDto albumDto) {
|
||||
public async Task<ActionResult> Create([FromBody] AlbumCreateDto albumDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : new List<Asset>();
|
||||
var assets = albumDto.Assets != null ? await assetRepository.FindBulkAsync(albumDto.Assets, cancellationToken) : [];
|
||||
|
||||
var album = new Album {
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -181,7 +203,7 @@ public class AlbumController(
|
||||
};
|
||||
|
||||
albumRepository.Insert(album);
|
||||
albumRepository.Save();
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok(album.Id);
|
||||
}
|
||||
@@ -191,18 +213,19 @@ public class AlbumController(
|
||||
/// All assets from source albums are moved to the destination, then sources are hard-deleted.
|
||||
/// </summary>
|
||||
/// <param name="dto">The merge request with destination and source IDs.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
|
||||
[HttpPost("merge")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public ActionResult Merge([FromBody] AlbumMergeDto dto) {
|
||||
public async Task<ActionResult> Merge([FromBody] AlbumMergeDto dto, CancellationToken cancellationToken) {
|
||||
if (dto.SourceIds.Contains(dto.DestinationId))
|
||||
return BadRequest("Destination album ID must not appear in source album IDs.");
|
||||
|
||||
if (albumRepository.Find(dto.DestinationId) == null)
|
||||
if (await albumRepository.FindAsync(dto.DestinationId, cancellationToken) == null)
|
||||
return NotFound("Destination album not found.");
|
||||
|
||||
albumRepository.MergeAlbums(dto.DestinationId, dto.SourceIds);
|
||||
albumRepository.Save();
|
||||
await albumRepository.MergeAlbumsAsync(dto.DestinationId, dto.SourceIds, cancellationToken);
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -210,20 +233,27 @@ public class AlbumController(
|
||||
/// Removes multiple albums (soft delete).
|
||||
/// </summary>
|
||||
/// <param name="albumIds">The list of album IDs to remove.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 403 if forbidden.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult BulkDelete([FromBody] List<Guid> albumIds) {
|
||||
public async Task<ActionResult> BulkDelete([FromBody] List<Guid> albumIds, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var albums = albumRepository.FindBulk(albumIds).ToList();
|
||||
var albums = await albumRepository.FindBulkAsync(albumIds, cancellationToken);
|
||||
|
||||
// Filter by access level
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Maintainer:
|
||||
albums = albums.Where(a => a.PersonOwnerId != null && personRepository.IsMaintainerOf(uid!.Value, a.PersonOwnerId.Value)).ToList();
|
||||
case EAccessLevel.Maintainer: {
|
||||
List<Album> maintained = [];
|
||||
foreach (var a in albums) {
|
||||
if (a.PersonOwnerId != null && await personRepository.IsMaintainerOfAsync(uid!.Value, a.PersonOwnerId.Value, cancellationToken))
|
||||
maintained.Add(a);
|
||||
}
|
||||
albums = maintained;
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.User:
|
||||
return Forbid();
|
||||
}
|
||||
@@ -231,7 +261,7 @@ public class AlbumController(
|
||||
if (albums.Count == 0) return Forbid();
|
||||
|
||||
albums.ForEach(albumRepository.Remove);
|
||||
albumRepository.Save();
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -240,27 +270,26 @@ public class AlbumController(
|
||||
/// Removes an album by its ID (soft delete).
|
||||
/// </summary>
|
||||
/// <param name="id">The album ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found, 403 if forbidden.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult Delete(Guid id) {
|
||||
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var album = albumRepository.Find(id);
|
||||
var album = await albumRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Maintainer when album.PersonOwnerId == null || !personRepository.IsMaintainerOf(uid!.Value, album.PersonOwnerId.Value):
|
||||
return Forbid();
|
||||
case EAccessLevel.User:
|
||||
return Forbid();
|
||||
}
|
||||
if (accessLevel == EAccessLevel.Maintainer && (album.PersonOwnerId == null || !await personRepository.IsMaintainerOfAsync(uid!.Value, album.PersonOwnerId.Value, cancellationToken)))
|
||||
return Forbid();
|
||||
if (accessLevel == EAccessLevel.User)
|
||||
return Forbid();
|
||||
|
||||
albumRepository.Remove(album);
|
||||
albumRepository.Save();
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace Lactose.Controllers;
|
||||
/// <param name="assetRepository">Asset repository.</param>
|
||||
/// <param name="userRepository">User repository.</param>
|
||||
/// <param name="personRepository">Person repository (for maintainer scope checks).</param>
|
||||
/// <param name="filesystemBrowseService">Filesystem browsing service for the maintenance page.</param>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AssetController(
|
||||
@@ -26,15 +27,17 @@ public class AssetController(
|
||||
LactoseAuthService authService,
|
||||
IAssetRepository assetRepository,
|
||||
IUserRepository userRepository,
|
||||
IPersonRepository personRepository
|
||||
IPersonRepository personRepository,
|
||||
IFilesystemBrowseService filesystemBrowseService
|
||||
) : ControllerBase {
|
||||
/// <summary>
|
||||
/// Gets an asset by its ID with full details, respecting access level.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The full asset details, or 404/401 based on permissions.</returns>
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<AssetDto> Get(Guid id) {
|
||||
public async Task<ActionResult<AssetDto>> Get(Guid id, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
@@ -46,7 +49,7 @@ public class AssetController(
|
||||
"""
|
||||
);
|
||||
|
||||
var asset = assetRepository.FindVisible(id, uid, accessLevel);
|
||||
var asset = await assetRepository.FindVisibleAsync(id, uid, accessLevel, cancellationToken);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found or not visible!");
|
||||
@@ -76,9 +79,10 @@ public class AssetController(
|
||||
/// Searches assets with optional date range, type filter, random ordering, unlinked filter, folder filter, uploader filter, search term, and pagination.
|
||||
/// </summary>
|
||||
/// <param name="searchOptionsDto">Search options including date range, type filter, unlinked filter, folder filter, uploader filter, random ordering, search term, and pagination.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of asset previews.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<List<AssetPreviewDto>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto) {
|
||||
public async Task<ActionResult<List<AssetPreviewDto>>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto, CancellationToken cancellationToken) {
|
||||
var userData = authService.GetUserData(User);
|
||||
var uid = userData?.Id;
|
||||
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
|
||||
@@ -95,11 +99,13 @@ public class AssetController(
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
var assets = assetRepository.GetAssets(
|
||||
var (assets, total) = await assetRepository.GetAssetsAsync(
|
||||
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
|
||||
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
|
||||
searchOptionsDto.Page, searchOptionsDto.PageSize,
|
||||
uid, accessLevel,
|
||||
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search
|
||||
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search,
|
||||
searchOptionsDto.IncludeCount, searchOptionsDto.DeletedOnly, searchOptionsDto.AlbumId, searchOptionsDto.PersonId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
logger.LogTrace(
|
||||
@@ -114,11 +120,9 @@ public class AssetController(
|
||||
"""
|
||||
);
|
||||
|
||||
var dtoList = assets.ToAssetPreviewDto(accessLevel, uid).ToList();
|
||||
|
||||
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
|
||||
logger.LogTrace($"Returning {assets.Count} assets (total: {total})");
|
||||
Response.Headers["X-Total-Count"] = total.ToString();
|
||||
return Ok(dtoList);
|
||||
return Ok(assets);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -129,27 +133,28 @@ public class AssetController(
|
||||
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
|
||||
[HttpGet("unlinked-groups")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult<List<AssetGroupDto>> GetFolderGroups([FromQuery] bool unlinkedOnly = true) {
|
||||
public async Task<ActionResult<List<AssetGroupDto>>> GetFolderGroups([FromQuery] bool unlinkedOnly = true, CancellationToken cancellationToken = default) {
|
||||
var userData = authService.GetUserData(User);
|
||||
var uid = userData?.Id;
|
||||
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
logger.LogTrace("GetFolderGroups: uid={Uid}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", uid, accessLevel, unlinkedOnly);
|
||||
|
||||
var groups = assetRepository.GetFolderGroups(uid, accessLevel, unlinkedOnly);
|
||||
var groups = await assetRepository.GetFolderGroupsAsync(uid, accessLevel, unlinkedOnly, cancellationToken);
|
||||
logger.LogTrace("GetFolderGroups: returned {Count} groups", groups.Count);
|
||||
return Ok(groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Browses assets at a specific directory level within a folder, returning immediate subdirectories
|
||||
/// and a paginated list of assets at that level.
|
||||
/// Browses a folder's filesystem directly, returning the database asset records for the files found on disk.
|
||||
/// Used by the maintenance page; does not reconstruct the folder tree from stored paths.
|
||||
/// </summary>
|
||||
/// <param name="options">Browse options including folder ID, path, pagination, search, and unlinked-only filter.</param>
|
||||
/// <returns>A browse result with subdirectories and assets at the current level.</returns>
|
||||
[HttpGet("browse")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult<AssetBrowseResultDto> Browse([FromQuery] AssetBrowseOptionsDto options) {
|
||||
/// <param name="options">Browse options including folder ID, relative path, pagination, search, and unlinked-only filter.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A browse result with subdirectories and assets at the current level, or 404 if the path is invalid.</returns>
|
||||
[HttpGet("directory")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public async Task<ActionResult<AssetBrowseResultDto>> FsBrowse([FromQuery] AssetBrowseOptionsDto options, CancellationToken cancellationToken) {
|
||||
var userData = authService.GetUserData(User);
|
||||
var uid = userData?.Id;
|
||||
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
|
||||
@@ -157,25 +162,67 @@ public class AssetController(
|
||||
if (options.Page < 0 || options.PageSize < 1 || options.PageSize > PagedParametersDto.MaxPageSize)
|
||||
return BadRequest();
|
||||
|
||||
logger.LogTrace("Browse: folderId={FolderId}, path={Path}, page={Page}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", options.FolderId, options.Path, options.Page, accessLevel, options.UnlinkedOnly);
|
||||
logger.LogTrace("FsBrowse: folderId={FolderId}, path={Path}, page={Page}, unlinkedOnly={UnlinkedOnly}", options.FolderId, options.Path, options.Page, options.UnlinkedOnly);
|
||||
|
||||
var result = assetRepository.BrowseAssets(options.FolderId, options.Path ?? "", options.Page, options.PageSize, uid, accessLevel, options.Search, options.UnlinkedOnly);
|
||||
var result = await filesystemBrowseService.BrowseAsync(options.FolderId, options.Path ?? "", options.Page, options.PageSize, options.UnlinkedOnly, options.Search, uid, accessLevel, cancellationToken);
|
||||
if (result == null) return NotFound();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a paginated list of assets marked broken (processing failed, not deleted).
|
||||
/// </summary>
|
||||
/// <param name="page">The zero-based page number.</param>
|
||||
/// <param name="pageSize">The number of assets per page.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A page of broken assets; total count in the <c>X-Total-Count</c> header.</returns>
|
||||
[HttpGet("broken")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public async Task<ActionResult<List<AssetPreviewDto>>> GetBroken([FromQuery] int page = 0, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel < EAccessLevel.Curator) { return Unauthorized(); }
|
||||
|
||||
if (page < 0 || pageSize < 1 || pageSize > PagedParametersDto.MaxPageSize)
|
||||
return BadRequest();
|
||||
|
||||
var (assets, total) = await assetRepository.GetBrokenAssetsAsync(page, pageSize, cancellationToken);
|
||||
Response.Headers["X-Total-Count"] = total.ToString();
|
||||
return Ok(assets);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the broken (processing failed) state for the given assets so they are picked up
|
||||
/// for re-processing on the next job run.
|
||||
/// </summary>
|
||||
/// <param name="ids">The list of asset IDs to retry.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPost("broken/retry")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public async Task<IStatusCodeActionResult> RetryBroken([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel < EAccessLevel.Curator) { return Unauthorized(); }
|
||||
|
||||
if (ids.Count == 0) return BadRequest();
|
||||
|
||||
await assetRepository.ClearProcessErrorAsync(ids, cancellationToken);
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an asset's metadata.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="dto">The asset update data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto) {
|
||||
public async Task<IStatusCodeActionResult> Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
var asset = assetRepository.FindWithAlbums(id);
|
||||
var asset = await assetRepository.FindWithAlbumsAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||
@@ -183,7 +230,7 @@ public class AssetController(
|
||||
}
|
||||
|
||||
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
|
||||
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
|
||||
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !await IsMaintainedByUser(uid.Value, asset, cancellationToken))) {
|
||||
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to update asset {id} that they do not own");
|
||||
return Unauthorized();
|
||||
}
|
||||
@@ -193,8 +240,18 @@ public class AssetController(
|
||||
asset.Visibility = dto.Visibility ?? asset.Visibility;
|
||||
log += $"Visibility: {asset.Visibility} -> {dto.Visibility ?? asset.Visibility}\n";
|
||||
|
||||
asset.DeletedAt = dto.DeletedAt ?? asset.DeletedAt;
|
||||
log += $"DeletedAt: {asset.DeletedAt} -> {dto.DeletedAt ?? asset.DeletedAt}\n";
|
||||
if (dto.IsDeleted.HasValue) {
|
||||
// Restoring a soft-deleted asset requires its source file to still exist on disk;
|
||||
// otherwise the crawl/integrity jobs would silently re-delete it on their next run.
|
||||
if (!dto.IsDeleted.Value && asset.DeletedAt != null && !System.IO.File.Exists(asset.OriginalPath)) {
|
||||
logger.LogWarning($"Cannot restore asset {id}: source file '{asset.OriginalPath}' does not exist on disk!");
|
||||
return Conflict();
|
||||
}
|
||||
|
||||
DateTime? newDeletedAt = dto.IsDeleted.Value ? DateTime.UtcNow : null;
|
||||
log += $"DeletedAt: {asset.DeletedAt} -> {newDeletedAt}\n";
|
||||
asset.DeletedAt = newDeletedAt;
|
||||
}
|
||||
|
||||
if (accesslevel == EAccessLevel.Admin) {
|
||||
asset.UploadedBy = dto.UploadedBy ?? asset.UploadedBy;
|
||||
@@ -204,7 +261,7 @@ public class AssetController(
|
||||
logger.LogTrace($"Updating asset {id}\n{log}");
|
||||
|
||||
assetRepository.Update(asset);
|
||||
assetRepository.Save();
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -213,13 +270,14 @@ public class AssetController(
|
||||
/// Updates multiple assets in a bulk operation.
|
||||
/// </summary>
|
||||
/// <param name="bulkDto">The bulk update data with asset IDs and shared values.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, or an error response.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto) {
|
||||
public async Task<IStatusCodeActionResult> BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
var assets = assetRepository.FindBulk(bulkDto.Ids);
|
||||
var assets = await assetRepository.FindBulkAsync(bulkDto.Ids, cancellationToken);
|
||||
|
||||
var enumerable = assets as List<Asset> ?? assets.ToList();
|
||||
|
||||
@@ -236,13 +294,20 @@ public class AssetController(
|
||||
//If the user is not an admin, they can only update their own assets
|
||||
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
|
||||
enumerable = accesslevel == EAccessLevel.Maintainer
|
||||
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
|
||||
? await FilterForMaintainerAsync(enumerable, uid.Value, cancellationToken)
|
||||
: enumerable.Where(x => x.UploadedBy == uid).ToList();
|
||||
|
||||
if (bulkDto.Data.IsDeleted == false) {
|
||||
var result = ApplyBulkRestore(enumerable, bulkDto.Data);
|
||||
assetRepository.UpdateBulk(enumerable);
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
enumerable.ForEach(
|
||||
x => {
|
||||
x.Visibility = bulkDto.Data.Visibility ?? x.Visibility;
|
||||
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
|
||||
x.DeletedAt = bulkDto.Data.IsDeleted == true ? DateTime.UtcNow : x.DeletedAt;
|
||||
|
||||
if (accesslevel == EAccessLevel.Admin) {
|
||||
x.UploadedBy = bulkDto.Data.UploadedBy ?? x.UploadedBy;
|
||||
@@ -251,21 +316,54 @@ public class AssetController(
|
||||
);
|
||||
|
||||
assetRepository.UpdateBulk(enumerable);
|
||||
assetRepository.Save();
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a restore request (<c>IsDeleted</c> = <see langword="false"/>) to the given assets.
|
||||
/// Assets whose source file no longer exists on disk are skipped and reported instead of restored,
|
||||
/// since the crawl/integrity jobs would re-delete them otherwise. All other update fields are still
|
||||
/// applied to the assets that are not skipped.
|
||||
/// </summary>
|
||||
/// <param name="assets">The assets selected for the bulk update.</param>
|
||||
/// <param name="data">The shared update values.</param>
|
||||
/// <returns>The outcome listing restored IDs and IDs skipped due to missing files.</returns>
|
||||
private BulkRestoreResultDto ApplyBulkRestore(List<Asset> assets, AssetUpdateDto data) {
|
||||
BulkRestoreResultDto result = new();
|
||||
|
||||
foreach (var x in assets) {
|
||||
if (x.DeletedAt != null && !System.IO.File.Exists(x.OriginalPath)) {
|
||||
result.SkippedMissingFile.Add(x.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool wasRestored = x.DeletedAt != null;
|
||||
|
||||
x.Visibility = data.Visibility ?? x.Visibility;
|
||||
x.DeletedAt = null;
|
||||
|
||||
if (wasRestored) result.Restored.Add(x.Id);
|
||||
}
|
||||
|
||||
if (result.SkippedMissingFile.Count > 0)
|
||||
logger.LogWarning($"Bulk restore: {result.SkippedMissingFile.Count} asset(s) skipped because their source files are missing on disk");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes an asset by its ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult Delete([FromRoute] Guid id) {
|
||||
public async Task<IStatusCodeActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
var asset = assetRepository.FindWithAlbums(id);
|
||||
var asset = await assetRepository.FindWithAlbumsAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||
@@ -273,14 +371,14 @@ public class AssetController(
|
||||
}
|
||||
|
||||
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
|
||||
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
|
||||
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !await IsMaintainedByUser(uid.Value, asset, cancellationToken))) {
|
||||
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to delete asset {id} that they do not own");
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
asset.DeletedAt = DateTime.UtcNow;
|
||||
assetRepository.Update(asset);
|
||||
assetRepository.Save();
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -289,13 +387,14 @@ public class AssetController(
|
||||
/// Soft-deletes multiple assets in a bulk operation.
|
||||
/// </summary>
|
||||
/// <param name="ids">The list of asset IDs to delete.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, or an error response.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||
public async Task<IStatusCodeActionResult> BulkDelete([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
var assets = assetRepository.FindBulk(ids);
|
||||
var assets = await assetRepository.FindBulkAsync(ids, cancellationToken);
|
||||
|
||||
var enumerable = assets as List<Asset> ?? assets.ToList();
|
||||
|
||||
@@ -312,13 +411,13 @@ public class AssetController(
|
||||
//If the user is not an admin, they can only delete their own assets
|
||||
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
|
||||
enumerable = accesslevel == EAccessLevel.Maintainer
|
||||
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
|
||||
? await FilterForMaintainerAsync(enumerable, uid.Value, cancellationToken)
|
||||
: enumerable.Where(x => x.UploadedBy == uid).ToList();
|
||||
|
||||
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
|
||||
|
||||
assetRepository.UpdateBulk(enumerable);
|
||||
assetRepository.Save();
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -326,16 +425,17 @@ public class AssetController(
|
||||
/// Creates a new asset record.
|
||||
/// </summary>
|
||||
/// <param name="dto">The asset creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the new asset ID, or an error response.</returns>
|
||||
[NonAction]
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public ActionResult<Guid> Create([FromBody] AssetCreateDto dto) {
|
||||
public async Task<ActionResult<Guid>> Create([FromBody] AssetCreateDto dto, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
|
||||
if (dto.UploadedBy.HasValue) {
|
||||
if (userRepository.Find(dto.UploadedBy.Value) == null) {
|
||||
if (await userRepository.FindAsync(dto.UploadedBy.Value, cancellationToken) == null) {
|
||||
logger.LogWarning($"User {dto.UploadedBy} does not exist in the database");
|
||||
return BadRequest();
|
||||
}
|
||||
@@ -358,11 +458,12 @@ public class AssetController(
|
||||
Duration = dto.Duration ?? 0,
|
||||
FrameRate = dto.FrameRate ?? 0,
|
||||
OriginalPath = String.Empty,
|
||||
OriginalFilename = String.Empty
|
||||
OriginalFilename = String.Empty,
|
||||
IngestedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
assetRepository.Insert(asset);
|
||||
assetRepository.Save();
|
||||
await assetRepository.InsertAsync(asset, cancellationToken);
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok(asset.Id);
|
||||
}
|
||||
@@ -373,7 +474,32 @@ public class AssetController(
|
||||
/// </summary>
|
||||
/// <param name="uid">The maintainer's user ID.</param>
|
||||
/// <param name="asset">The asset to check.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns><see langword="true"/> if the maintainer is linked via album→person→maintainer chain.</returns>
|
||||
private bool IsMaintainedByUser(Guid uid, Asset asset) =>
|
||||
asset.Albums?.Any(a => a.PersonOwnerId.HasValue && personRepository.IsMaintainerOf(uid, a.PersonOwnerId.Value)) == true;
|
||||
private async Task<bool> IsMaintainedByUser(Guid uid, Asset asset, CancellationToken cancellationToken) {
|
||||
if (asset.Albums == null) return false;
|
||||
foreach (var album in asset.Albums) {
|
||||
if (album.PersonOwnerId.HasValue
|
||||
&& await personRepository.IsMaintainerOfAsync(uid, album.PersonOwnerId.Value, cancellationToken))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters an asset list down to assets uploaded by the maintainer or linked through
|
||||
/// the cosplayers they maintain.
|
||||
/// </summary>
|
||||
/// <param name="assets">The assets to filter.</param>
|
||||
/// <param name="uid">The maintainer's user ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The filtered asset list preserving input order.</returns>
|
||||
private async Task<List<Asset>> FilterForMaintainerAsync(IEnumerable<Asset> assets, Guid uid, CancellationToken cancellationToken) {
|
||||
List<Asset> result = [];
|
||||
foreach (var asset in assets) {
|
||||
if (asset.UploadedBy == uid || await IsMaintainedByUser(uid, asset, cancellationToken))
|
||||
result.Add(asset);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,17 +35,18 @@ public class AuthController(
|
||||
/// Authenticates a user by username/email and password, issuing JWT and refresh tokens.
|
||||
/// </summary>
|
||||
/// <param name="userDto">The login credentials.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>An authentication result with tokens, or an error.</returns>
|
||||
[HttpPost("login")]
|
||||
public ActionResult<AuthResultDto> Login([FromBody] CredentialsDto userDto) {
|
||||
public async Task<ActionResult<AuthResultDto>> Login([FromBody] CredentialsDto userDto, CancellationToken cancellationToken) {
|
||||
User? user;
|
||||
|
||||
if (userDto.Identifier.Contains('@')) {
|
||||
//search for user in database (by email)
|
||||
user = userRepository.FindByEmail(userDto.Identifier);
|
||||
user = await userRepository.FindByEmailAsync(userDto.Identifier, cancellationToken);
|
||||
} else {
|
||||
//search for user in database (by username)
|
||||
user = userRepository.FindByUsername(userDto.Identifier);
|
||||
user = await userRepository.FindByUsernameAsync(userDto.Identifier, cancellationToken);
|
||||
}
|
||||
if (user == null) {
|
||||
return NotFound(new AuthResultDto() {
|
||||
@@ -79,16 +80,16 @@ public class AuthController(
|
||||
case PasswordVerificationResult.Failed: return NotFound(new AuthResultDto() { Success = false, ErrorMessage = "User or Password was wrong"});
|
||||
case PasswordVerificationResult.SuccessRehashNeeded:
|
||||
user.Password = passwordHasher.HashPassword(user, userDto.Password);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
break;
|
||||
case PasswordVerificationResult.Success: break;
|
||||
}
|
||||
|
||||
user.LastLogin = DateTime.UtcNow;
|
||||
|
||||
|
||||
var token = authService.GenerateAccessToken(user);
|
||||
var refreshToken = authService.GenerateRefreshToken(user);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return new AuthResultDto() {
|
||||
UserId = user.Id,
|
||||
Token = token,
|
||||
@@ -97,15 +98,27 @@ public class AuthController(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether user registration is currently enabled.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 if enabled, 403 if disabled.</returns>
|
||||
[HttpGet("register")]
|
||||
public async Task<ActionResult> CheckRegistrationEnabled(CancellationToken cancellationToken) {
|
||||
var regSetting = await settingsRepository.GetAsync(Settings.UserRegistrationEnabled.AsString(), cancellationToken);
|
||||
return regSetting?.Value == "true" ? Ok() : StatusCode(403);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new user account.
|
||||
/// </summary>
|
||||
/// <param name="dto">The registration data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the new user ID, 403 if registration is disabled, or 409 if the user already exists.</returns>
|
||||
//TODO: Switch Guid reply with Authentication Result (giving a complete reason in case of failure or giving the authentication token)
|
||||
[HttpPost("register")]
|
||||
public ActionResult Register([FromBody] UserRegisterDto dto) {
|
||||
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
|
||||
public async Task<ActionResult> Register([FromBody] UserRegisterDto dto, CancellationToken cancellationToken) {
|
||||
var regSetting = await settingsRepository.GetAsync(Settings.UserRegistrationEnabled.AsString(), cancellationToken);
|
||||
if (regSetting?.Value != "true")
|
||||
return StatusCode(403, "Registration is currently disabled on this server");
|
||||
|
||||
@@ -117,7 +130,8 @@ public class AuthController(
|
||||
"""
|
||||
);
|
||||
|
||||
if (userRepository.FindByEmail(dto.Email) != null || userRepository.FindByUsername(dto.Username) != null) {
|
||||
if (await userRepository.FindByEmailAsync(dto.Email, cancellationToken) != null
|
||||
|| await userRepository.FindByUsernameAsync(dto.Username, cancellationToken) != null) {
|
||||
return Conflict("User already exists");
|
||||
}
|
||||
|
||||
@@ -132,7 +146,7 @@ public class AuthController(
|
||||
user.Password = passwordHasher.HashPassword(user, dto.Password);
|
||||
|
||||
userRepository.Insert(user);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return Ok(user.Id);
|
||||
}
|
||||
|
||||
@@ -142,17 +156,17 @@ public class AuthController(
|
||||
/// <returns>200 on success, 401 if unauthorized.</returns>
|
||||
[Authorize]
|
||||
[HttpPost("logout")]
|
||||
public ActionResult Logout() {
|
||||
public async Task<ActionResult> Logout(CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? identity = authService.GetUserData(User);
|
||||
|
||||
if (identity == null) { return Unauthorized(); }
|
||||
|
||||
|
||||
//Reset token from the database
|
||||
User? user = userRepository.Find(identity.Id);
|
||||
User? user = await userRepository.FindAsync(identity.Id, cancellationToken);
|
||||
if (user == null) { return Unauthorized(); }
|
||||
user.RefreshToken = string.Empty;
|
||||
user.RefreshTokenExpires = null;
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -162,10 +176,11 @@ public class AuthController(
|
||||
/// Refreshes an expired JWT access token using a valid refresh token.
|
||||
/// </summary>
|
||||
/// <param name="token">The refresh token request data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A new access token, and optionally a new refresh token.</returns>
|
||||
[HttpPost("refresh")]
|
||||
public ActionResult<AuthResultDto> RefreshToken([FromBody] RefreshDto token) {
|
||||
User? user = userRepository.Find(token.UserId);
|
||||
public async Task<ActionResult<AuthResultDto>> RefreshToken([FromBody] RefreshDto token, CancellationToken cancellationToken) {
|
||||
User? user = await userRepository.FindAsync(token.UserId, cancellationToken);
|
||||
|
||||
if (user == null) {
|
||||
return NotFound(
|
||||
@@ -215,7 +230,7 @@ public class AuthController(
|
||||
authDto.RefreshToken = refreshToken;
|
||||
user.RefreshToken = refreshToken;
|
||||
user.RefreshTokenExpires = DateTime.UtcNow.AddMinutes(LactoseAuthService.LongExpirationTime);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return authDto;
|
||||
|
||||
@@ -29,10 +29,11 @@ namespace Lactose.Controllers;
|
||||
/// Creates a new folder for asset scanning.
|
||||
/// </summary>
|
||||
/// <param name="folderCreateDto">The folder creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Create([FromBody] FolderCreateDto folderCreateDto) {
|
||||
public async Task<ActionResult> Create([FromBody] FolderCreateDto folderCreateDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
@@ -43,40 +44,39 @@ namespace Lactose.Controllers;
|
||||
Active = false
|
||||
};
|
||||
|
||||
folderRepository.Create(newFolder);
|
||||
await folderRepository.InsertAsync(newFolder, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tracked folders.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of all folders.</returns>
|
||||
[HttpGet]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<List<FolderFullDto>> GetAll() {
|
||||
public async Task<ActionResult<List<FolderFullDto>>> GetAll(CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
return Ok(
|
||||
folderRepository.GetAll()
|
||||
.Select(folder => folder.ToFolderDto())
|
||||
.ToList()
|
||||
);
|
||||
List<Folder> folders = await folderRepository.GetAllAsync(cancellationToken);
|
||||
return Ok(folders.Select(folder => folder.ToFolderDto()).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific folder by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The folder details, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<FolderFullDto> Get([FromRoute] Guid id) {
|
||||
public async Task<ActionResult<FolderFullDto>> Get([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
var folder = folderRepository.Get(id);
|
||||
var folder = await folderRepository.GetAsync(id, cancellationToken);
|
||||
|
||||
if (folder == null) return NotFound();
|
||||
|
||||
@@ -88,22 +88,23 @@ namespace Lactose.Controllers;
|
||||
/// </summary>
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <param name="folderUpdateDto">The folder update data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Update([FromRoute] Guid id, [FromBody] FolderUpdateDto folderUpdateDto) {
|
||||
public async Task<ActionResult> Update([FromRoute] Guid id, [FromBody] FolderUpdateDto folderUpdateDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
Folder? folder = folderRepository.Get(id);
|
||||
Folder? folder = await folderRepository.GetAsync(id, cancellationToken);
|
||||
if (folder == null) return NotFound();
|
||||
|
||||
|
||||
folder.BasePath = folderUpdateDto.BasePath ?? folder.BasePath;
|
||||
folder.Active = folderUpdateDto.Active ?? folder.Active;
|
||||
folder.RegexPattern = folderUpdateDto.RegexPattern ?? folder.RegexPattern;
|
||||
|
||||
folderRepository.Update(id, folder);
|
||||
await folderRepository.UpdateAsync(id, folder, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -112,20 +113,21 @@ namespace Lactose.Controllers;
|
||||
/// </summary>
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <param name="count">Number of sample paths to return (default 10, max 50).</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of up to <paramref name="count"/> random asset paths, or 404 if the folder is not found.</returns>
|
||||
[HttpGet("{id}/sample-paths")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<FolderSamplePathsDto> GetSamplePaths([FromRoute] Guid id, [FromQuery] int count = 10) {
|
||||
public async Task<ActionResult<FolderSamplePathsDto>> GetSamplePaths([FromRoute] Guid id, [FromQuery] int count = 10, CancellationToken cancellationToken = default) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
var folder = folderRepository.Get(id);
|
||||
var folder = await folderRepository.GetAsync(id, cancellationToken);
|
||||
if (folder == null) return NotFound();
|
||||
|
||||
count = Math.Clamp(count, 1, 50);
|
||||
|
||||
var paths = assetRepository.GetRandomPathsByFolder(id, count);
|
||||
var paths = await assetRepository.GetRandomPathsByFolderAsync(id, count, cancellationToken);
|
||||
|
||||
return Ok(new FolderSamplePathsDto {
|
||||
FolderId = id,
|
||||
@@ -137,15 +139,16 @@ namespace Lactose.Controllers;
|
||||
/// Deletes a folder (stops tracking it).
|
||||
/// </summary>
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Delete([FromRoute] Guid id) {
|
||||
public async Task<ActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
folderRepository.Delete(id);
|
||||
await folderRepository.RemoveAsync(id, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,17 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
||||
/// <summary>
|
||||
/// Returns a page of past root jobs with total count. Polled less frequently or on demand.
|
||||
/// </summary>
|
||||
/// <param name="page">The zero-based page number.</param>
|
||||
/// <param name="pageSize">The number of records per page.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A page of past jobs and the total count.</returns>
|
||||
[HttpGet("past")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 0, [FromQuery] int pageSize = 5) {
|
||||
public async Task<ActionResult<PastJobsResponse>> GetPast([FromQuery] int page = 0, [FromQuery] int pageSize = 5, CancellationToken cancellationToken = default) {
|
||||
if (page < 0 || pageSize < 1 || pageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
var (jobs, total) = jobManager.GetPastRootJobs(page, pageSize);
|
||||
var (jobs, total) = await jobManager.GetPastRootJobsAsync(page, pageSize, cancellationToken);
|
||||
return Ok(new PastJobsResponse { Jobs = jobs, Total = total });
|
||||
}
|
||||
|
||||
@@ -50,12 +54,16 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
||||
/// Supports delta sync — pass <paramref name="since"/> to receive only children changed after that timestamp.
|
||||
/// The <see cref="ChildrenResponse.Since"/> field contains the timestamp to use for the next delta poll.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The parent job ID.</param>
|
||||
/// <param name="since">Optional timestamp for delta sync.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The children of the job, plus the timestamp for the next delta poll.</returns>
|
||||
[HttpGet("{parentId}/children")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<ChildrenResponse> GetChildren(Guid parentId, [FromQuery] DateTime? since) {
|
||||
public async Task<ActionResult<ChildrenResponse>> GetChildren(Guid parentId, [FromQuery] DateTime? since, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
var children = jobManager.GetChildren(parentId, since);
|
||||
var children = await jobManager.GetChildrenAsync(parentId, since, cancellationToken);
|
||||
return Ok(new ChildrenResponse { Children = children, Since = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
@@ -96,20 +104,20 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
||||
/// Enqueues a new job of the specified type.
|
||||
/// </summary>
|
||||
/// <param name="jobRequest">The job request containing the job type and optional parameters.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 if enqueued, 400 if invalid request, 501 if not implemented.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Enqueue([FromBody] JobRequestDto jobRequest) {
|
||||
public async Task<ActionResult> Enqueue([FromBody] JobRequestDto jobRequest, CancellationToken cancellationToken) {
|
||||
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);
|
||||
if (!TryParseJobOptions(jobRequest.Parameters, out var scanDeep, out var scanFolders, out var scanError))
|
||||
return BadRequest(scanError);
|
||||
await jobScheduler.QueueFileSystemCrawlAsync(scanDeep, scanFolders, cancellationToken);
|
||||
return Ok();
|
||||
case EJobType.ThumbnailGeneration:
|
||||
if (jobRequest.Parameters.Count != 0)
|
||||
@@ -135,22 +143,56 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
||||
|
||||
jobScheduler.QueueIntegrityCheck(null);
|
||||
return Ok();
|
||||
case EJobType.CreatePersons:
|
||||
if (jobRequest.Parameters.Count != 0)
|
||||
return BadRequest("CreatePersons job does not require any parameters.");
|
||||
jobScheduler.QueueCreatePersons(null);
|
||||
return Ok();
|
||||
case EJobType.CreateAlbums:
|
||||
if (jobRequest.Parameters.Count != 0)
|
||||
return BadRequest("CreateAlbums job does not require any parameters.");
|
||||
jobScheduler.QueueCreateAlbums(null);
|
||||
if (!TryParseJobOptions(jobRequest.Parameters, out var albumsDeep, out var albumsFolders, out var albumsError))
|
||||
return BadRequest(albumsError);
|
||||
jobScheduler.QueueCreateAlbums(albumsDeep, albumsFolders);
|
||||
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();
|
||||
case EJobType.AnimatedConversion:
|
||||
if (jobRequest.Parameters.Count != 0)
|
||||
return BadRequest("AnimatedConversion job does not require any parameters.");
|
||||
jobScheduler.QueueAnimatedConversion(null);
|
||||
return Ok();
|
||||
default: return BadRequest("Invalid job type.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the common job parameters for scan/discovery jobs: the literal <c>deep</c> plus repeatable
|
||||
/// <c>folder=<guid></c> entries. Unknown parameters are rejected.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The raw job parameters.</param>
|
||||
/// <param name="deep">Whether a deep scan was requested.</param>
|
||||
/// <param name="folderIds">The selected folder ids (empty means all active folders).</param>
|
||||
/// <param name="error">An error message when parsing fails, otherwise null.</param>
|
||||
/// <returns>True when the parameters are valid.</returns>
|
||||
static bool TryParseJobOptions(List<string> parameters, out bool deep, out List<Guid> folderIds, out string? error) {
|
||||
deep = false;
|
||||
folderIds = new List<Guid>();
|
||||
error = null;
|
||||
|
||||
foreach (var raw in parameters) {
|
||||
var param = raw.Trim();
|
||||
if (string.Equals(param, "deep", StringComparison.OrdinalIgnoreCase)) {
|
||||
deep = true;
|
||||
} else if (param.StartsWith("folder=", StringComparison.OrdinalIgnoreCase)) {
|
||||
var idText = param["folder=".Length..].Trim();
|
||||
if (!Guid.TryParse(idText, out var folderId)) {
|
||||
error = $"Invalid folder parameter '{param}'.";
|
||||
return false;
|
||||
}
|
||||
folderIds.Add(folderId);
|
||||
} else {
|
||||
error = $"Unknown job parameter '{param}'.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
|
||||
namespace Lactose.Controllers;
|
||||
@@ -18,6 +19,7 @@ namespace Lactose.Controllers;
|
||||
/// <param name="mediaRepository">Media repository.</param>
|
||||
/// <param name="assetRepository">Asset repository.</param>
|
||||
/// <param name="signKeyOptions">JWT signing key configuration.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[AllowAnonymous]
|
||||
@@ -26,50 +28,105 @@ public class MediaController(
|
||||
LactoseAuthService authService,
|
||||
IMediaRepository mediaRepository,
|
||||
IAssetRepository assetRepository,
|
||||
IOptions<SignKeyConfiguration> signKeyOptions
|
||||
IOptions<SignKeyConfiguration> signKeyOptions,
|
||||
ILogger<MediaController> logger
|
||||
) : ControllerBase {
|
||||
|
||||
/// <summary>
|
||||
/// Gets the original image file for an asset.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The physical image file, or 404 if not found or access denied.</returns>
|
||||
[HttpGet("{id}")]
|
||||
[HttpGet("original/{id}")]
|
||||
[EnableRateLimiting("media_original")]
|
||||
public ActionResult GetImage(Guid id) {
|
||||
public async Task<ActionResult> GetImage(Guid id, CancellationToken cancellationToken) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
|
||||
var asset = await assetRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
// Animated assets with a converted video on disk serve the converted file (and its MIME type),
|
||||
// never the original animation. A stale ConvertedPath (file missing) falls back to the original.
|
||||
bool hasConverted = HasConvertedFile(asset);
|
||||
|
||||
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||
return PhysicalFile(asset.OriginalPath, asset.MimeType);
|
||||
if (hasConverted)
|
||||
return ServeFile(asset.ConvertedPath, asset.ConvertedMimeType, asset);
|
||||
return ServeFile(asset.OriginalPath, asset.MimeType, asset);
|
||||
}
|
||||
var data = mediaRepository.GetOriginalData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||
|
||||
var accessLevel = user?.AccessLevel ?? EAccessLevel.User;
|
||||
var converted = hasConverted
|
||||
? await mediaRepository.GetConvertedDataAsync(id, user?.Id, accessLevel, cancellationToken)
|
||||
: null;
|
||||
if (converted != null)
|
||||
return ServeFile(converted.Path, converted.MimeType, asset);
|
||||
|
||||
var data = await mediaRepository.GetOriginalDataAsync(id, user?.Id, accessLevel, cancellationToken);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
return ServeFile(data.Path, data.MimeType, asset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves a physical media file, marking it as an attachment when the request asks to download.
|
||||
/// The <c>download</c> query parameter forces <c>Content-Disposition: attachment</c> so a plain
|
||||
/// link/navigation (not a CORS-gated fetch) saves the file to disk regardless of origin.
|
||||
/// </summary>
|
||||
/// <param name="path">The physical file path to serve.</param>
|
||||
/// <param name="mimeType">The MIME type of the file.</param>
|
||||
/// <param name="asset">The asset the file belongs to, used for the attachment filename.</param>
|
||||
/// <returns>The physical file result.</returns>
|
||||
private ActionResult ServeFile(string path, string mimeType, Models.Asset asset) {
|
||||
if (Request.Query.ContainsKey("download"))
|
||||
Response.Headers.ContentDisposition =
|
||||
new ContentDispositionHeaderValue("attachment") { FileName = asset.OriginalFilename }.ToString();
|
||||
return PhysicalFile(path, mimeType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an asset has a converted video file available on disk.
|
||||
/// Logs a warning when the converted path is set but the file is missing.
|
||||
/// </summary>
|
||||
/// <param name="asset">The asset to inspect.</param>
|
||||
/// <returns><see langword="true"/> when the converted path and MIME type are set and the file exists.</returns>
|
||||
private bool HasConvertedFile(Models.Asset asset) {
|
||||
if (string.IsNullOrEmpty(asset.ConvertedPath) || string.IsNullOrEmpty(asset.ConvertedMimeType))
|
||||
return false;
|
||||
|
||||
if (!System.IO.File.Exists(asset.ConvertedPath)) {
|
||||
logger.LogWarning("Converted file for asset {AssetId} is missing on disk at {Path}; falling back to the original.",
|
||||
asset.Id, asset.ConvertedPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the thumbnail image for an asset.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The physical thumbnail file, or 404 if not found or access denied.</returns>
|
||||
[HttpGet("thumb/{id}")]
|
||||
[EnableRateLimiting("media_thumb")]
|
||||
public ActionResult GetThumb(Guid id) {
|
||||
public async Task<ActionResult> GetThumb(Guid id, CancellationToken cancellationToken) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
|
||||
var asset = await assetRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
// Thumbnails are immutable per asset — allow browser/proxy caching
|
||||
Response.Headers.CacheControl = "public, max-age=86400";
|
||||
|
||||
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
|
||||
return PhysicalFile(asset.ThumbnailPath, "image/webp");
|
||||
}
|
||||
var data = mediaRepository.GetThumbData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||
var data = await mediaRepository.GetThumbDataAsync(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User, cancellationToken);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
@@ -79,20 +136,24 @@ public class MediaController(
|
||||
/// Gets the preview image for an asset.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The physical preview file, or 404 if not found or access denied.</returns>
|
||||
[HttpGet("preview/{id}")]
|
||||
[EnableRateLimiting("media_preview")]
|
||||
public ActionResult GetPreview(Guid id) {
|
||||
public async Task<ActionResult> GetPreview(Guid id, CancellationToken cancellationToken) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
|
||||
var asset = await assetRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
// Previews are immutable per asset — allow browser/proxy caching
|
||||
Response.Headers.CacheControl = "public, max-age=86400";
|
||||
|
||||
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
|
||||
return PhysicalFile(asset.PreviewPath, "image/webp");
|
||||
}
|
||||
var data = mediaRepository.GetPreviewData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||
var data = await mediaRepository.GetPreviewDataAsync(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User, cancellationToken);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace Lactose.Controllers;
|
||||
public class PersonController(
|
||||
IPersonRepository personRepository,
|
||||
IAlbumRepository albumRepository,
|
||||
IAssetRepository assetRepository,
|
||||
LactoseAuthService authService
|
||||
) : ControllerBase {
|
||||
/// <summary>
|
||||
@@ -28,15 +29,16 @@ public class PersonController(
|
||||
/// </summary>
|
||||
/// <param name="id">The person ID.</param>
|
||||
/// <param name="albumPaging">Pagination, search, and sort parameters for albums.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The person details with albums, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<PersonDetailedDto> Get([FromRoute] Guid id, [FromQuery] PagedSearchParametersDto albumPaging) {
|
||||
public async Task<ActionResult<PersonDetailedDto>> Get([FromRoute] Guid id, [FromQuery] PagedSearchParametersDto albumPaging, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var person = personRepository.FindVisible(id, uid, accessLevel,
|
||||
var person = await personRepository.FindVisibleAsync(id, uid, accessLevel,
|
||||
albumPaging.Search, albumPaging.SortBy, albumPaging.SortAsc,
|
||||
albumPaging.Page, albumPaging.PageSize);
|
||||
albumPaging.Page, albumPaging.PageSize, cancellationToken);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
return Ok(person.ToPersonDetailedDto(accessLevel, uid));
|
||||
@@ -46,23 +48,25 @@ public class PersonController(
|
||||
/// Searches people with pagination, optional search term, sort options, filtering out cosplayers with no visible content for non-admin users.
|
||||
/// </summary>
|
||||
/// <param name="pagedSearch">Pagination, search, and sort parameters.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A paginated list of person previews.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<List<PersonPreviewDto>> GetAll([FromQuery] PersonSearchParametersDto pagedSearch) {
|
||||
public async Task<ActionResult<List<PersonPreviewDto>>> GetAll([FromQuery] PersonSearchParametersDto pagedSearch, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (pagedSearch.Page < 0 || pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
|
||||
|
||||
var people = personRepository.SearchQuery(
|
||||
var people = await personRepository.SearchQueryAsync(
|
||||
pagedSearch.Search ?? string.Empty,
|
||||
pagedSearch.Page,
|
||||
pagedSearch.PageSize,
|
||||
pagedSearch.SortBy,
|
||||
pagedSearch.SortAsc,
|
||||
uid ?? default,
|
||||
accessLevel
|
||||
).ToList();
|
||||
accessLevel,
|
||||
cancellationToken
|
||||
);
|
||||
return Ok(people);
|
||||
}
|
||||
|
||||
@@ -71,22 +75,23 @@ public class PersonController(
|
||||
/// </summary>
|
||||
/// <param name="id">The person ID.</param>
|
||||
/// <param name="dto">The updated person data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) {
|
||||
public async Task<IActionResult> Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var person = personRepository.Find(id);
|
||||
var person = await personRepository.FindAsync(id, cancellationToken);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
if (accessLevel == EAccessLevel.Maintainer) {
|
||||
if (uid == null || !personRepository.IsMaintainerOf(uid.Value, id))
|
||||
if (uid == null || !await personRepository.IsMaintainerOfAsync(uid.Value, id, cancellationToken))
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
person.Name = dto.Name;
|
||||
person.Name = dto.Name ?? person.Name;
|
||||
person.ProfileAssetId = dto.ProfileAssetId;
|
||||
person.ProfileCropX = dto.ProfileCropX;
|
||||
person.ProfileCropY = dto.ProfileCropY;
|
||||
@@ -96,92 +101,103 @@ public class PersonController(
|
||||
person.Visibility = dto.Visibility.Value;
|
||||
|
||||
if (dto.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
|
||||
personRepository.SetMaintainers(person.Id, dto.MaintainerUserIds);
|
||||
await personRepository.SetMaintainersAsync(person.Id, dto.MaintainerUserIds, cancellationToken);
|
||||
|
||||
person.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
personRepository.Save();
|
||||
await personRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates multiple people in a bulk operation.
|
||||
/// Updates multiple people in a bulk operation, optionally cascading a visibility change to their albums and assets.
|
||||
/// </summary>
|
||||
/// <param name="personBulkDto">The bulk update data.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
/// <returns>200 on success with an optional cascade result.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
public IActionResult BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
|
||||
public async Task<IActionResult> BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
var people = personRepository.FindBulk(personBulkDto.Ids).ToList();
|
||||
|
||||
foreach (var person in people) {
|
||||
person.Name = personBulkDto.Data.Name;
|
||||
person.ProfileAssetId = personBulkDto.Data.ProfileAssetId;
|
||||
person.ProfileCropX = personBulkDto.Data.ProfileCropX;
|
||||
person.ProfileCropY = personBulkDto.Data.ProfileCropY;
|
||||
person.ProfileCropZoom = personBulkDto.Data.ProfileCropZoom;
|
||||
person.Visibility = personBulkDto.Data.Visibility ?? person.Visibility;
|
||||
person.UpdatedAt = DateTime.UtcNow;
|
||||
var result = await personRepository.ExecuteInTransactionAsync(async () => {
|
||||
var people = await personRepository.FindBulkAsync(personBulkDto.Ids, CancellationToken.None);
|
||||
|
||||
if (personBulkDto.Data.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
|
||||
personRepository.SetMaintainers(person.Id, personBulkDto.Data.MaintainerUserIds);
|
||||
}
|
||||
foreach (var person in people) {
|
||||
person.Name = personBulkDto.Data.Name ?? person.Name;
|
||||
person.ProfileAssetId = personBulkDto.Data.ProfileAssetId ?? person.ProfileAssetId;
|
||||
person.ProfileCropX = personBulkDto.Data.ProfileCropX ?? person.ProfileCropX;
|
||||
person.ProfileCropY = personBulkDto.Data.ProfileCropY ?? person.ProfileCropY;
|
||||
person.ProfileCropZoom = personBulkDto.Data.ProfileCropZoom ?? person.ProfileCropZoom;
|
||||
person.Visibility = personBulkDto.Data.Visibility ?? person.Visibility;
|
||||
person.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
personRepository.Save();
|
||||
return Ok();
|
||||
if (personBulkDto.Data.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
|
||||
await personRepository.SetMaintainersAsync(person.Id, personBulkDto.Data.MaintainerUserIds, CancellationToken.None);
|
||||
}
|
||||
|
||||
var result = new CascadeResultDto { PeopleUpdated = people.Count };
|
||||
var cascade = personBulkDto.Cascade;
|
||||
var visibility = personBulkDto.Data.Visibility;
|
||||
|
||||
if (cascade != null && visibility.HasValue) {
|
||||
if (cascade.ToAlbums)
|
||||
result.AlbumsUpdated = await albumRepository.BulkSetVisibilityByPersonIdsAsync(personBulkDto.Ids, visibility.Value, CancellationToken.None);
|
||||
if (cascade.ToAssets)
|
||||
result.AssetsUpdated = await assetRepository.BulkSetVisibilityByPersonIdsAsync(personBulkDto.Ids, visibility.Value, CancellationToken.None);
|
||||
}
|
||||
|
||||
await personRepository.SaveAsync(CancellationToken.None);
|
||||
return result;
|
||||
});
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a person by ID (soft delete).
|
||||
/// </summary>
|
||||
/// <param name="id">The person ID.</param>
|
||||
/// <returns>200 on success, 404 if not found.</returns>
|
||||
/// <summary>
|
||||
/// Deletes a person by ID (hard delete). Cascades to unlink all associated albums.
|
||||
/// </summary>
|
||||
/// <param name="id">The person ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
public IActionResult Delete([FromRoute] Guid id) {
|
||||
var person = personRepository.Find(id);
|
||||
public async Task<IActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
var person = await personRepository.FindAsync(id, cancellationToken);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
// Unlink all albums owned by this person
|
||||
var albums = albumRepository.FindByPerson(id).ToList();
|
||||
var albums = await albumRepository.FindByPersonAsync(id, cancellationToken);
|
||||
foreach (var album in albums) {
|
||||
album.PersonOwnerId = null;
|
||||
}
|
||||
|
||||
personRepository.Remove(person);
|
||||
personRepository.Save();
|
||||
await personRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes multiple people in a bulk operation.
|
||||
/// </summary>
|
||||
/// <param name="ids">The list of person IDs to delete.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
/// <summary>
|
||||
/// Deletes multiple people in a bulk operation (hard delete). Cascades to unlink all associated albums.
|
||||
/// </summary>
|
||||
/// <param name="ids">The list of person IDs to delete.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||
var people = personRepository.FindBulk(ids).ToList();
|
||||
public async Task<IActionResult> BulkDelete([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
|
||||
var people = await personRepository.FindBulkAsync(ids, cancellationToken);
|
||||
|
||||
foreach (var person in people) {
|
||||
// Unlink all albums owned by this person
|
||||
var albums = albumRepository.FindByPerson(person.Id).ToList();
|
||||
var albums = await albumRepository.FindByPersonAsync(person.Id, cancellationToken);
|
||||
foreach (var album in albums) {
|
||||
album.PersonOwnerId = null;
|
||||
}
|
||||
personRepository.Remove(person);
|
||||
}
|
||||
|
||||
personRepository.Save();
|
||||
await personRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -190,18 +206,19 @@ public class PersonController(
|
||||
/// Albums, faces, and maintainers are reassigned to the destination, then source people are hard-deleted.
|
||||
/// </summary>
|
||||
/// <param name="dto">The merge request with destination and source IDs.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
|
||||
[HttpPost("merge")]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
public IActionResult Merge([FromBody] PersonMergeDto dto) {
|
||||
public async Task<IActionResult> Merge([FromBody] PersonMergeDto dto, CancellationToken cancellationToken) {
|
||||
if (dto.SourceIds.Contains(dto.DestinationId))
|
||||
return BadRequest("Destination person ID must not appear in source person IDs.");
|
||||
|
||||
if (personRepository.Find(dto.DestinationId) == null)
|
||||
if (await personRepository.FindAsync(dto.DestinationId, cancellationToken) == null)
|
||||
return NotFound("Destination person not found.");
|
||||
|
||||
personRepository.MergePeople(dto.DestinationId, dto.SourceIds);
|
||||
personRepository.Save();
|
||||
await personRepository.MergePeopleAsync(dto.DestinationId, dto.SourceIds, cancellationToken);
|
||||
await personRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -209,10 +226,11 @@ public class PersonController(
|
||||
/// Creates a new person.
|
||||
/// </summary>
|
||||
/// <param name="dto">The person creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the new person ID.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
public ActionResult<Guid> Create([FromBody] PersonCreateDto dto) {
|
||||
public async Task<ActionResult<Guid>> Create([FromBody] PersonCreateDto dto, CancellationToken cancellationToken) {
|
||||
var person = new Models.Person {
|
||||
Id = Guid.NewGuid(),
|
||||
Name = dto.Name,
|
||||
@@ -220,7 +238,7 @@ public class PersonController(
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
personRepository.Insert(person);
|
||||
personRepository.Save();
|
||||
await personRepository.SaveAsync(cancellationToken);
|
||||
return Ok(person.Id);
|
||||
}
|
||||
}
|
||||
@@ -27,22 +27,24 @@ public class SettingsController(
|
||||
/// Creates a new application setting.
|
||||
/// </summary>
|
||||
/// <param name="settingDto">The setting to create.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Create([FromBody] SettingDto settingDto) {
|
||||
public async Task<ActionResult> Create([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
settingsRepository.Create(
|
||||
await settingsRepository.CreateAsync(
|
||||
new Setting {
|
||||
Name = settingDto.Name,
|
||||
Value = settingDto.Value,
|
||||
Description = settingDto.Description,
|
||||
Type = settingDto.Type,
|
||||
DisplayType = settingDto.DisplayType
|
||||
}
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Ok();
|
||||
@@ -51,28 +53,30 @@ public class SettingsController(
|
||||
/// <summary>
|
||||
/// Gets all application settings.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of all settings.</returns>
|
||||
[HttpGet]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<SettingDto> Get() {
|
||||
public async Task<ActionResult<SettingDto>> Get(CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||
|
||||
return Ok(settingsRepository.Get().Select(s => s.ToSettingDto()).ToList());
|
||||
return Ok((await settingsRepository.GetAllAsync(cancellationToken)).Select(s => s.ToSettingDto()).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing application setting.
|
||||
/// </summary>
|
||||
/// <param name="settingDto">The updated setting data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Update([FromBody] SettingDto settingDto) {
|
||||
public async Task<ActionResult> Update([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||
|
||||
settingsRepository.Update(settingDto.ToSetting());
|
||||
await settingsRepository.UpdateAsync(settingDto.ToSetting(), cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -80,29 +84,31 @@ public class SettingsController(
|
||||
/// Deletes a specific application setting.
|
||||
/// </summary>
|
||||
/// <param name="settingDto">The setting to delete.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Delete([FromBody] SettingDto settingDto) {
|
||||
public async Task<ActionResult> Delete([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||
|
||||
settingsRepository.Delete(settingDto.ToSetting());
|
||||
await settingsRepository.DeleteAsync(settingDto.ToSetting(), cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all application settings.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[Route("delete-all")]
|
||||
public ActionResult DeleteAll() {
|
||||
public async Task<ActionResult> DeleteAll(CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||
|
||||
settingsRepository.Delete();
|
||||
await settingsRepository.DeleteAllAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,12 @@ public class StatsController(
|
||||
/// <summary>
|
||||
/// Returns comprehensive aggregate statistics about the instance.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A <see cref="StatsDto"/> with all gathered statistics.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<StatsDto> Get() {
|
||||
public async Task<ActionResult<StatsDto>> Get(CancellationToken cancellationToken) {
|
||||
logger.LogTrace("Stats requested");
|
||||
var stats = statsRepository.GetStats();
|
||||
var stats = await statsRepository.GetStatsAsync(cancellationToken);
|
||||
return Ok(stats);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@ public class TagController(
|
||||
/// <param name="id">The tag ID.</param>
|
||||
/// <param name="children">Whether to include child tags.</param>
|
||||
/// <param name="ancestors">Whether to include ancestor tags.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The tag with its hierarchy, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<TagDto> Get(Guid id, bool children = false, bool ancestors = false) {
|
||||
Tag? tag = tagRepository.Find(id);
|
||||
public async Task<ActionResult<TagDto>> Get(Guid id, bool children = false, bool ancestors = false, CancellationToken cancellationToken = default) {
|
||||
Tag? tag = await tagRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (tag == null) { return NotFound(); }
|
||||
var tagDto = TagsMapper.ToTagDto(tag);
|
||||
@@ -40,7 +41,7 @@ public class TagController(
|
||||
|
||||
if (children) {
|
||||
tagDto.Children = [];
|
||||
tagRepository.GetChildren(tag.Id).ForEach(t => tagDto.Children.Add(TagsMapper.ToTagDto(t)));
|
||||
(await tagRepository.GetChildrenAsync(tag.Id, cancellationToken)).ForEach(t => tagDto.Children.Add(TagsMapper.ToTagDto(t)));
|
||||
}
|
||||
|
||||
//TODO: Protect from overflow
|
||||
@@ -65,14 +66,15 @@ public class TagController(
|
||||
/// Searches tags with pagination and optional search term.
|
||||
/// </summary>
|
||||
/// <param name="pagedSearch">Pagination and search parameters.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of tags matching the search criteria.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<List<TagDto>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) {
|
||||
public async Task<ActionResult<List<TagDto>>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch, CancellationToken cancellationToken) {
|
||||
pagedSearch.Search ??= string.Empty;
|
||||
if(pagedSearch.Page < 0) { return BadRequest("Page must be greater than 0"); }
|
||||
if(pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest($"PageSize must be between 1 and {PagedParametersDto.MaxPageSize}"); }
|
||||
|
||||
List<Tag> tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize);
|
||||
|
||||
List<Tag> tags = await tagRepository.SearchAsync(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize, cancellationToken);
|
||||
List<TagDto> tagsDto = [];
|
||||
|
||||
tags.ForEach(
|
||||
@@ -91,14 +93,15 @@ public class TagController(
|
||||
/// </summary>
|
||||
/// <param name="id">The tag ID.</param>
|
||||
/// <param name="tagDto">The updated tag data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found, 409 if name conflict.</returns>
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpPost("{id}")]
|
||||
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto) {
|
||||
public async Task<IStatusCodeActionResult> Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto, CancellationToken cancellationToken) {
|
||||
|
||||
IStatusCodeActionResult result = UpdateTag(id, tagDto.Name, tagDto.Parent);
|
||||
IStatusCodeActionResult result = await UpdateTag(id, tagDto.Name, tagDto.Parent, cancellationToken);
|
||||
|
||||
if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); }
|
||||
if (result.StatusCode == StatusCodes.Status200OK) { await tagRepository.SaveAsync(cancellationToken); }
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -107,18 +110,19 @@ public class TagController(
|
||||
/// Updates multiple tags in a bulk operation.
|
||||
/// </summary>
|
||||
/// <param name="tagBulkDto">The bulk update data with IDs and shared update values.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, or the first error response.</returns>
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpPost]
|
||||
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto) {
|
||||
public async Task<IStatusCodeActionResult> BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto, CancellationToken cancellationToken) {
|
||||
|
||||
foreach (Guid id in tagBulkDto.Ids) {
|
||||
IStatusCodeActionResult result = UpdateTag(id, tagBulkDto.Data.Name, tagBulkDto.Data.Parent);
|
||||
IStatusCodeActionResult result = await UpdateTag(id, tagBulkDto.Data.Name, tagBulkDto.Data.Parent, cancellationToken);
|
||||
|
||||
if (result.StatusCode != StatusCodes.Status200OK) { return result; }
|
||||
}
|
||||
|
||||
tagRepository.Save();
|
||||
await tagRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -126,13 +130,14 @@ public class TagController(
|
||||
/// Deletes a tag by its ID (soft delete).
|
||||
/// </summary>
|
||||
/// <param name="id">The tag ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found.</returns>
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult Delete([FromRoute] Guid id) {
|
||||
IStatusCodeActionResult result = DeleteTag(id);
|
||||
public async Task<ActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
IStatusCodeActionResult result = await DeleteTag(id, cancellationToken);
|
||||
|
||||
if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); }
|
||||
if (result.StatusCode == StatusCodes.Status200OK) { await tagRepository.SaveAsync(cancellationToken); }
|
||||
|
||||
return (ActionResult)result;
|
||||
}
|
||||
@@ -141,17 +146,18 @@ public class TagController(
|
||||
/// Deletes multiple tags in a bulk operation.
|
||||
/// </summary>
|
||||
/// <param name="ids">The list of tag IDs to delete.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, or the first error response.</returns>
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpDelete]
|
||||
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||
public async Task<IActionResult> BulkDelete([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
|
||||
foreach (Guid id in ids) {
|
||||
IStatusCodeActionResult result = DeleteTag(id);
|
||||
IStatusCodeActionResult result = await DeleteTag(id, cancellationToken);
|
||||
|
||||
if (result.StatusCode != StatusCodes.Status200OK) { return result; }
|
||||
}
|
||||
|
||||
tagRepository.Save();
|
||||
await tagRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -159,29 +165,30 @@ public class TagController(
|
||||
/// Creates a new tag.
|
||||
/// </summary>
|
||||
/// <param name="tagDto">The tag creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 409 if a tag with the same name already exists, 400 if parent not found.</returns>
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpPut]
|
||||
public ActionResult Create([FromBody] TagCreateDto tagDto) {
|
||||
public async Task<ActionResult> Create([FromBody] TagCreateDto tagDto, CancellationToken cancellationToken) {
|
||||
|
||||
if (tagRepository.FindByName(tagDto.Name) != null) { return Conflict($"Tag with name {tagDto.Name} already exist"); }
|
||||
if (await tagRepository.FindByNameAsync(tagDto.Name, cancellationToken) != null) { return Conflict($"Tag with name {tagDto.Name} already exist"); }
|
||||
|
||||
var tag = new Tag() {
|
||||
Name = tagDto.Name
|
||||
};
|
||||
|
||||
if (tagDto.Parent != null && tagRepository.Find(tagDto.Parent.Value) == null) {
|
||||
if (tagDto.Parent != null && await tagRepository.FindAsync(tagDto.Parent.Value, cancellationToken) == null) {
|
||||
return BadRequest($"No Tag with id {tagDto.Parent} found");
|
||||
}
|
||||
|
||||
tag.ParentId = tagDto.Parent;
|
||||
tagRepository.Insert(tag);
|
||||
tagRepository.Save();
|
||||
await tagRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
IStatusCodeActionResult DeleteTag(Guid id) {
|
||||
Tag? tag = tagRepository.Find(id);
|
||||
async Task<IStatusCodeActionResult> DeleteTag(Guid id, CancellationToken cancellationToken) {
|
||||
Tag? tag = await tagRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (tag == null) { return NotFound($"Tag {id} not found"); }
|
||||
tagRepository.Delete(tag);
|
||||
@@ -189,14 +196,14 @@ public class TagController(
|
||||
}
|
||||
|
||||
|
||||
IStatusCodeActionResult UpdateTag(Guid id, string? name, Guid? parentId) {
|
||||
async Task<IStatusCodeActionResult> UpdateTag(Guid id, string? name, Guid? parentId, CancellationToken cancellationToken) {
|
||||
|
||||
Tag? tag = tagRepository.Find(id);
|
||||
Tag? tag = await tagRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (tag == null) { return NotFound($"Tag {id} not found"); }
|
||||
|
||||
if (name != null) {
|
||||
Tag? foundTag = tagRepository.FindByName(name);
|
||||
Tag? foundTag = await tagRepository.FindByNameAsync(name, cancellationToken);
|
||||
|
||||
if (foundTag != null && foundTag.Id != tag.Id) { return Conflict($"Tag with name '{name}' already exists"); }
|
||||
|
||||
@@ -206,7 +213,7 @@ public class TagController(
|
||||
if (parentId == null) { return Ok(); }
|
||||
|
||||
if (parentId.Value.Equals(Guid.Empty)) { tag.Parent = null; } else {
|
||||
Tag? parent = tagRepository.Find(parentId.Value);
|
||||
Tag? parent = await tagRepository.FindAsync(parentId.Value, cancellationToken);
|
||||
|
||||
if (parent == null) { return NotFound($"Parent tag on {id} was not found"); }
|
||||
tag.Parent = parent;
|
||||
|
||||
@@ -29,10 +29,11 @@ namespace Lactose.Controllers {
|
||||
/// Creates a new user (admin only).
|
||||
/// </summary>
|
||||
/// <param name="userCreateDto">The user creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the new user ID, or conflict if the email/username already exists.</returns>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut]
|
||||
public ActionResult<UserInfoDto> Create([FromBody] UserCreateDto userCreateDto) {
|
||||
public async Task<ActionResult<UserInfoDto>> Create([FromBody] UserCreateDto userCreateDto, CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
|
||||
if (authenticatedUser != null) {
|
||||
@@ -50,8 +51,9 @@ namespace Lactose.Controllers {
|
||||
"""
|
||||
);
|
||||
|
||||
if (userRepository.GetAll().Any(x => x.Email == userCreateDto.Email)) return Conflict("Email already exists");
|
||||
if (userRepository.GetAll().Any(x => x.Username == userCreateDto.Username)) return Conflict("Username already exists");
|
||||
List<User> users = await userRepository.GetAllAsync(cancellationToken);
|
||||
if (users.Any(x => x.Email == userCreateDto.Email)) return Conflict("Email already exists");
|
||||
if (users.Any(x => x.Username == userCreateDto.Username)) return Conflict("Username already exists");
|
||||
|
||||
var user = new User {
|
||||
Username = userCreateDto.Username,
|
||||
@@ -63,7 +65,7 @@ namespace Lactose.Controllers {
|
||||
user.Password = passwordHasher.HashPassword(user, userCreateDto.Password);
|
||||
|
||||
userRepository.Insert(user);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok(user.Id);
|
||||
}
|
||||
@@ -73,9 +75,10 @@ namespace Lactose.Controllers {
|
||||
/// Gets a user by their ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The user ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The user info, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<UserInfoDto> Get(Guid id) {
|
||||
public async Task<ActionResult<UserInfoDto>> Get(Guid id, CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
|
||||
if (authenticatedUser == null) {
|
||||
@@ -85,7 +88,7 @@ namespace Lactose.Controllers {
|
||||
|
||||
logger.LogDebug($"Sending user with id {id}");
|
||||
|
||||
User? user = userRepository.FindWithMaintainedPersons(id);
|
||||
User? user = await userRepository.FindWithMaintainedPersonsAsync(id, cancellationToken);
|
||||
|
||||
if (user is null) return NotFound();
|
||||
|
||||
@@ -98,13 +101,15 @@ namespace Lactose.Controllers {
|
||||
/// <returns>A list of all users.</returns>
|
||||
[Authorize(Roles = "Admin,Curator")]
|
||||
[HttpGet]
|
||||
public ActionResult<List<UserInfoDto>> GetAll() {
|
||||
public async Task<ActionResult<List<UserInfoDto>>> GetAll(CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
EAccessLevel viewerAccessLevel = authenticatedUser?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
logger.LogDebug("Sending Users list");
|
||||
|
||||
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
|
||||
List<User> users = await userRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
return Ok(users.Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
|
||||
}
|
||||
|
||||
|
||||
@@ -112,9 +117,10 @@ namespace Lactose.Controllers {
|
||||
/// Updates an existing user's profile.
|
||||
/// </summary>
|
||||
/// <param name="dto">The user update data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The updated user info, or 403/404 if unauthorized or not found.</returns>
|
||||
[HttpPost("update")]
|
||||
public ActionResult<UserInfoDto> Update([FromBody] UserUpdateDto dto) {
|
||||
public async Task<ActionResult<UserInfoDto>> Update([FromBody] UserUpdateDto dto, CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
|
||||
if (authenticatedUser == null) {
|
||||
@@ -136,7 +142,7 @@ namespace Lactose.Controllers {
|
||||
|
||||
if (authenticatedUser.AccessLevel != EAccessLevel.Admin && authenticatedUser.Id != dto.Id) return Forbid();
|
||||
|
||||
User? user = userRepository.Find(dto.Id);
|
||||
User? user = await userRepository.FindAsync(dto.Id, cancellationToken);
|
||||
if (user == null) return NotFound();
|
||||
|
||||
if (dto.Username != null) { user.Username = dto.Username; }
|
||||
@@ -172,11 +178,11 @@ namespace Lactose.Controllers {
|
||||
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
|
||||
|
||||
if (dto.MaintainedPersonIds != null) {
|
||||
userRepository.SetMaintainedPersons(user.Id, dto.MaintainedPersonIds);
|
||||
await userRepository.SetMaintainedPersonsAsync(user.Id, dto.MaintainedPersonIds, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
|
||||
}
|
||||
|
||||
@@ -184,10 +190,11 @@ namespace Lactose.Controllers {
|
||||
/// Soft-deletes a user by their ID (admin only).
|
||||
/// </summary>
|
||||
/// <param name="id">The user ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the deleted user ID, or 404 if not found.</returns>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult<Guid> Delete([FromRoute] Guid id) {
|
||||
public async Task<ActionResult<Guid>> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
|
||||
if (authenticatedUser == null) {
|
||||
@@ -197,14 +204,14 @@ namespace Lactose.Controllers {
|
||||
|
||||
logger.LogDebug($"Deleting user: {id}");
|
||||
|
||||
User? user = userRepository.Find(id);
|
||||
User? user = await userRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (user == null) return NotFound();
|
||||
|
||||
user.DeletedAt = DateTime.UtcNow;
|
||||
|
||||
//TODO: eventually remove/hide all of the user content if required
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return Ok(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,27 @@
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Converted Path",
|
||||
"Value": "",
|
||||
"Description": "Path where converted videos for animated assets (GIF -> mp4) are stored.",
|
||||
"Type": 0,
|
||||
"DisplayType": 0
|
||||
},
|
||||
{
|
||||
"Name": "Converted Format",
|
||||
"Value": "mp4",
|
||||
"Description": "Container format for converted videos (e.g. mp4).",
|
||||
"Type": 0,
|
||||
"DisplayType": 0
|
||||
},
|
||||
{
|
||||
"Name": "FFmpeg Path",
|
||||
"Value": "ffmpeg",
|
||||
"Description": "Path to the ffmpeg executable. Defaults to 'ffmpeg' on PATH.",
|
||||
"Type": 0,
|
||||
"DisplayType": 0
|
||||
},
|
||||
{
|
||||
"Name": "Job Batch Size",
|
||||
"Value": "200",
|
||||
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
RUN mkdir -p /app/thumbnails /app/previews && chown -R $APP_UID /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir -p /app/thumbnails /app/previews /app/converted && chown -R $APP_UID /app
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Converts animated assets (GIF) to video (mp4 via ffmpeg) and extracts a first-frame WebP thumbnail.
|
||||
/// Original files are never modified; outputs are stored under the Converted Path setting.
|
||||
/// Master creates one sub-job per batch of assets.
|
||||
/// </summary>
|
||||
public sealed class ConvertAnimatedJob : Job {
|
||||
ConvertAnimatedJob? ParentJob;
|
||||
Asset[]? Batch;
|
||||
int ThumbnailSize;
|
||||
string ThumbnailPath = "";
|
||||
string ConvertedPath = "";
|
||||
string FfmpegPath = "ffmpeg";
|
||||
string ExpectedFormat = "mp4";
|
||||
string ExpectedThumbnailFormat = "webp";
|
||||
int processedAssets;
|
||||
int failedAssets;
|
||||
int childJobFailures;
|
||||
int childJobsWithErrors;
|
||||
int totalChildJobs;
|
||||
int totalAssets = 1;
|
||||
int batchSize;
|
||||
List<Job> childJobs = [];
|
||||
ILogger<ConvertAnimatedJob> Logger;
|
||||
ISettingsRepository SettingsRepository;
|
||||
IAssetRepository AssetRepository;
|
||||
JobManager? JobManager;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a master animated-asset conversion job.
|
||||
/// </summary>
|
||||
public ConvertAnimatedJob(
|
||||
ILogger<ConvertAnimatedJob> logger,
|
||||
ISettingsRepository settingsRepository,
|
||||
IAssetRepository assetRepository,
|
||||
JobManager jobManager
|
||||
) {
|
||||
Logger = logger;
|
||||
SettingsRepository = settingsRepository;
|
||||
AssetRepository = assetRepository;
|
||||
JobManager = jobManager;
|
||||
Name = "Animated Asset Conversion Job";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a sub-job to convert a batch of animated assets.
|
||||
/// </summary>
|
||||
public ConvertAnimatedJob(
|
||||
ConvertAnimatedJob parentJob,
|
||||
IEnumerable<Asset> batch,
|
||||
int thumbnailSize,
|
||||
string thumbnailPath,
|
||||
string convertedPath,
|
||||
string ffmpegPath,
|
||||
string expectedFormat,
|
||||
int batchSize,
|
||||
ILogger<ConvertAnimatedJob> logger,
|
||||
ISettingsRepository settingsRepository,
|
||||
IAssetRepository assetRepository
|
||||
) {
|
||||
ParentJob = parentJob;
|
||||
ParentJobId = parentJob.Id;
|
||||
Batch = batch.ToArray();
|
||||
ThumbnailSize = thumbnailSize;
|
||||
ThumbnailPath = thumbnailPath;
|
||||
ConvertedPath = convertedPath;
|
||||
FfmpegPath = ffmpegPath;
|
||||
ExpectedFormat = expectedFormat;
|
||||
this.batchSize = batchSize;
|
||||
Logger = logger;
|
||||
SettingsRepository = settingsRepository;
|
||||
AssetRepository = assetRepository;
|
||||
Name = $"Animated Conversion Batch ({Batch.Length} assets)";
|
||||
totalAssets = Batch.Length;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task TaskJob(CancellationToken token) {
|
||||
if (ParentJob == null) {
|
||||
await MasterJob(token);
|
||||
} else {
|
||||
await SlaveJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
async Task SlaveJob(CancellationToken token) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Batch == null || Batch.Length == 0) {
|
||||
Logger.LogWarning("Sub-job started with an empty batch. Completing.");
|
||||
JobStatus.Complete("Empty batch — nothing to process.");
|
||||
return;
|
||||
}
|
||||
|
||||
JobStatus.Start();
|
||||
|
||||
if (string.IsNullOrEmpty(ThumbnailPath) || string.IsNullOrEmpty(ConvertedPath)) {
|
||||
Logger.LogError("Thumbnail or converted output path is not set. Cannot convert assets.");
|
||||
JobStatus.Fail("Thumbnail or converted output path is not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < Batch.Length; i++) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
}
|
||||
|
||||
var asset = Batch[i];
|
||||
JobStatus.UpdateProgress((float)i / Batch.Length, $"Processing {asset.OriginalPath}");
|
||||
|
||||
try {
|
||||
if (string.IsNullOrEmpty(asset.OriginalPath) || !File.Exists(asset.OriginalPath)) {
|
||||
var errorMsg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
|
||||
Logger.LogWarning(errorMsg);
|
||||
JobHelpers.MarkBroken(asset, errorMsg);
|
||||
failedAssets++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var thumbPath = PathUtils.PathFromGuid(asset.Id, ThumbnailPath, ExpectedThumbnailFormat);
|
||||
var videoPath = PathUtils.PathFromGuid(asset.Id, ConvertedPath, ExpectedFormat);
|
||||
|
||||
var thumbOutDir = Path.GetDirectoryName(thumbPath);
|
||||
if (thumbOutDir != null && !Directory.Exists(thumbOutDir))
|
||||
Directory.CreateDirectory(thumbOutDir);
|
||||
var videoOutDir = Path.GetDirectoryName(videoPath);
|
||||
if (videoOutDir != null && !Directory.Exists(videoOutDir))
|
||||
Directory.CreateDirectory(videoOutDir);
|
||||
|
||||
// First frame as a static WebP thumbnail (avoids decoding the whole animation).
|
||||
if (asset.ThumbnailPath != thumbPath || !File.Exists(thumbPath)) {
|
||||
var thumbArgs = $"-y -i \"{asset.OriginalPath}\" -vf \"scale={ThumbnailSize}:-1\" -frames:v 1 \"{thumbPath}\"";
|
||||
await FfmpegRunner.RunAsync(FfmpegPath, thumbArgs, Logger, token);
|
||||
}
|
||||
|
||||
// Full animation → video (mp4 / H.264, no audio, faststart for streaming).
|
||||
if (asset.ConvertedPath != videoPath || !File.Exists(videoPath)) {
|
||||
var videoArgs = $"-y -i \"{asset.OriginalPath}\" -c:v libx264 -crf 23 -pix_fmt yuv420p -movflags +faststart -an \"{videoPath}\"";
|
||||
await FfmpegRunner.RunAsync(FfmpegPath, videoArgs, Logger, token);
|
||||
}
|
||||
|
||||
asset.ThumbnailPath = thumbPath;
|
||||
asset.ThumbnailSize = ThumbnailSize;
|
||||
asset.ThumbnailFormat = ExpectedThumbnailFormat;
|
||||
asset.ConvertedPath = videoPath;
|
||||
asset.ConvertedMimeType = MimeTypeFor(ExpectedFormat);
|
||||
asset.ConvertedFormat = ExpectedFormat;
|
||||
processedAssets++;
|
||||
} catch (OperationCanceledException) {
|
||||
throw;
|
||||
} catch (Exception ex) {
|
||||
Logger.LogError(ex, "Failed to convert animated asset ID {Id} at {Path}.", asset.Id, asset.OriginalPath);
|
||||
JobHelpers.MarkBroken(asset, $"Animated conversion failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
}
|
||||
}
|
||||
|
||||
AssetRepository.UpdateBulk(Batch);
|
||||
await AssetRepository.SaveAsync(token);
|
||||
|
||||
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
||||
Logger.LogInformation(resultMsg);
|
||||
|
||||
if (failedAssets == Batch.Length)
|
||||
JobStatus.Fail($"All {failedAssets:N0} assets in batch failed.");
|
||||
else if (failedAssets > 0)
|
||||
JobStatus.CompleteWithErrors(resultMsg);
|
||||
else
|
||||
JobStatus.Complete(resultMsg);
|
||||
}
|
||||
|
||||
async Task 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 animated conversion job.");
|
||||
|
||||
var thumbPathSetting = await SettingsRepository.GetAsync(Settings.ThumbnailPath.AsString(), token);
|
||||
var convertedPathSetting = await SettingsRepository.GetAsync(Settings.ConvertedPath.AsString(), token);
|
||||
var convertedFormatSetting = await SettingsRepository.GetAsync(Settings.ConvertedFormat.AsString(), token);
|
||||
var ffmpegPathSetting = await SettingsRepository.GetAsync(Settings.FfmpegPath.AsString(), token);
|
||||
var thumbSizeSetting = await SettingsRepository.GetAsync(Settings.ThumbnailSize.AsString(), token);
|
||||
var batchSizeSetting = await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
|
||||
|
||||
if (thumbPathSetting == null || convertedPathSetting == null
|
||||
|| string.IsNullOrWhiteSpace(thumbPathSetting.Value) || string.IsNullOrWhiteSpace(convertedPathSetting.Value)) {
|
||||
Logger.LogError("Thumbnail path or converted path is not set. Cannot proceed.");
|
||||
JobStatus.Fail("Thumbnail path or converted path is not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(thumbSizeSetting?.Value, out var thumbnailSize)) {
|
||||
thumbnailSize = 512;
|
||||
Logger.LogWarning("Thumbnail Size setting not found or invalid. Defaulting to {Size}.", thumbnailSize);
|
||||
}
|
||||
|
||||
var convertedFormat = string.IsNullOrWhiteSpace(convertedFormatSetting?.Value) ? "mp4" : convertedFormatSetting.Value;
|
||||
var ffmpegPath = string.IsNullOrWhiteSpace(ffmpegPathSetting?.Value) ? "ffmpeg" : ffmpegPathSetting.Value;
|
||||
|
||||
batchSize = 200;
|
||||
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
|
||||
batchSize = Math.Max(1, parsed);
|
||||
|
||||
totalAssets = await AssetRepository.CountAssetsNeedingConversionAsync(token);
|
||||
Logger.LogInformation("Found {Count} animated assets needing conversion.", totalAssets);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
JobStatus.Complete("No animated assets need conversion.");
|
||||
return;
|
||||
}
|
||||
|
||||
var childTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
int pending = 0;
|
||||
|
||||
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
||||
var batch = await AssetRepository.GetAssetsNeedingConversionAsync(batchSize, offset, token);
|
||||
var batchList = batch.ToList();
|
||||
|
||||
if (batchList.Count == 0) continue;
|
||||
|
||||
var job = JobManager.CreateJob<ConvertAnimatedJob>(
|
||||
this, batchList, thumbnailSize, thumbPathSetting.Value!, convertedPathSetting.Value!, ffmpegPath, convertedFormat, batchSize
|
||||
);
|
||||
job.LinkParentToken(token);
|
||||
lock (childJobs) { childJobs.Add(job); }
|
||||
|
||||
job.Done += (o, _) => {
|
||||
var sub = (ConvertAnimatedJob)o!;
|
||||
lock (childJobs) { childJobs.Remove(sub); }
|
||||
Interlocked.Add(ref processedAssets, sub.processedAssets);
|
||||
Interlocked.Add(ref failedAssets, sub.failedAssets);
|
||||
switch (sub.JobStatus.Status) {
|
||||
case EJobStatus.Failed:
|
||||
case EJobStatus.Canceled:
|
||||
Interlocked.Increment(ref childJobFailures);
|
||||
break;
|
||||
case EJobStatus.CompletedWithErrors:
|
||||
Interlocked.Increment(ref childJobsWithErrors);
|
||||
break;
|
||||
}
|
||||
if (Interlocked.Decrement(ref pending) == 0)
|
||||
childTcs.TrySetResult();
|
||||
};
|
||||
|
||||
Interlocked.Increment(ref pending);
|
||||
Interlocked.Increment(ref totalChildJobs);
|
||||
JobManager.EnqueueJob(job);
|
||||
|
||||
JobStatus.UpdateProgress(
|
||||
(float)offset / totalAssets,
|
||||
$"Created batch at {offset:N0}/{totalAssets:N0}."
|
||||
);
|
||||
}
|
||||
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
}
|
||||
|
||||
var remain = Interlocked.CompareExchange(ref pending, 0, 0);
|
||||
if (remain == 0) {
|
||||
childTcs.TrySetResult();
|
||||
} else {
|
||||
using var ctr = token.Register(() => childTcs.TrySetCanceled(token));
|
||||
try {
|
||||
await childTcs.Task;
|
||||
} catch (TaskCanceledException) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (childJobFailures > 0)
|
||||
JobStatus.Fail($"{childJobFailures:N0} of {totalChildJobs:N0} conversion batch(es) failed.");
|
||||
else if (childJobsWithErrors > 0 || failedAssets > 0)
|
||||
JobStatus.CompleteWithErrors($"{failedAssets:N0} of {totalAssets:N0} animated conversions failed.");
|
||||
else
|
||||
JobStatus.Complete("All animated assets converted successfully.");
|
||||
}
|
||||
|
||||
static string MimeTypeFor(string format) => format switch {
|
||||
"webm" => "video/webm",
|
||||
"mp4" => "video/mp4",
|
||||
_ => $"video/{format}"
|
||||
};
|
||||
}
|
||||
+246
-288
@@ -2,399 +2,335 @@ using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Scans active folders with regex patterns in a streaming fashion (suitable for millions of assets),
|
||||
/// creates missing Person and Album entities, and assigns matching assets to their albums.
|
||||
/// Discovers persons and albums from asset paths using folder regex patterns.
|
||||
/// Scans active folders in a streaming, keyset-paginated fashion (suitable for millions of assets),
|
||||
/// creates any missing Person and Album entities, assigns matching assets to their albums, and
|
||||
/// backfills cover images. Runs incrementally (only new assets since the last scan) unless a deep scan
|
||||
/// or a changed regex pattern forces a full rescan.
|
||||
/// </summary>
|
||||
public class CreateAlbumsJob(ILogger<CreateAlbumsJob> logger, IServiceProvider serviceProvider) : Job {
|
||||
const int ScanBatchSize = 5000;
|
||||
const int SaveBatchSize = 1000;
|
||||
public class CreateAlbumsJob(
|
||||
bool deep,
|
||||
IReadOnlyList<Guid>? folderIds,
|
||||
ILogger<CreateAlbumsJob> logger,
|
||||
IServiceProvider serviceProvider) : Job {
|
||||
const int ScanBatchSize = 5000;
|
||||
const int StagingFlushSize = 2000;
|
||||
const int AssignmentBatchSize = 2000;
|
||||
const int LookupChunkSize = 1000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Create Albums Job";
|
||||
public override string Name => "Create Persons & Albums Job";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task TaskJob(CancellationToken token) {
|
||||
try {
|
||||
logger.LogInformation("Starting create albums job...");
|
||||
logger.LogInformation("Starting create persons & albums job (deep={Deep}, folders={FolderCount})...",
|
||||
deep, folderIds?.Count ?? 0);
|
||||
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
|
||||
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
|
||||
var personRepo = scope.ServiceProvider.GetRequiredService<IPersonRepository>();
|
||||
var albumRepo = scope.ServiceProvider.GetRequiredService<IAlbumRepository>();
|
||||
var albumRepo = scope.ServiceProvider.GetRequiredService<IAlbumRepository>();
|
||||
|
||||
var folders = folderRepo.GetAll()
|
||||
var activeFolders = (await folderRepo.GetAllAsync(token))
|
||||
.Where(f => f.Active && !string.IsNullOrWhiteSpace(f.RegexPattern))
|
||||
.ToList();
|
||||
|
||||
var folders = folderIds is { Count: > 0 }
|
||||
? activeFolders.Where(f => folderIds.Contains(f.Id)).ToList()
|
||||
: activeFolders;
|
||||
|
||||
if (folders.Count == 0) {
|
||||
logger.LogInformation("No active folders with a regex pattern found.");
|
||||
JobStatus.Complete("No active folders with a regex pattern found.");
|
||||
logger.LogInformation("No matching active folders with a regex pattern found.");
|
||||
JobStatus.Complete("No matching active folders with a regex pattern found.");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Scanning {FolderCount} active folders for person/album patterns...", folders.Count);
|
||||
// Fresh staging table for this run.
|
||||
await db.Database.ExecuteSqlRawAsync("TRUNCATE TABLE \"AssetAlbumStaging\"", token);
|
||||
|
||||
logger.LogInformation("Scanning {FolderCount} folder(s) for person/album patterns...", folders.Count);
|
||||
|
||||
// ── Phase 1: Incremental scan → stage matched (asset, person, album) rows ──
|
||||
var personAlbumCounts = new ConcurrentDictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
var albumKeys = new ConcurrentDictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
int uniquePersons = 0, uniqueAlbums = 0, totalAssetsScanned = 0, totalAssetsMatched = 0;
|
||||
var stagedBuffer = new List<AssetAlbumStaging>();
|
||||
var folderCursorUpdates = new List<(Guid FolderId, DateTime ScanAt, string Regex)>();
|
||||
|
||||
// ── Phase 1: Streaming scan → collect unique (person, album) names only ──
|
||||
var personAlbumNames = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
float folderIndex = 0;
|
||||
int uniquePersons = 0;
|
||||
int uniqueAlbums = 0;
|
||||
int totalAssetsScanned = 0;
|
||||
int totalAssetsMatched = 0;
|
||||
|
||||
foreach (var folder in folders) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
bool folderDeep = deep || folder.LastDiscoveryScannedRegex != folder.RegexPattern;
|
||||
DateTime? cursor = folderDeep ? null : folder.LastDiscoveryScanAt;
|
||||
|
||||
var regex = new Regex(folder.RegexPattern!, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
var groupNames = regex.GetGroupNames().Select(g => g.ToLowerInvariant()).ToHashSet();
|
||||
|
||||
if (!groupNames.Contains("person") || !groupNames.Contains("group")) {
|
||||
logger.LogWarning("Folder {FolderId} ({BasePath}) regex lacks 'person' and/or 'group' named groups. Skipping.", folder.Id, folder.BasePath);
|
||||
if (!groupNames.Contains("person")) {
|
||||
logger.LogWarning("Folder {FolderId} ({BasePath}) regex lacks a 'person' named group. Skipping.", folder.Id, folder.BasePath);
|
||||
continue;
|
||||
}
|
||||
bool hasGroup = groupNames.Contains("group");
|
||||
|
||||
var totalInFolder = await db.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.CountAsync(token);
|
||||
DateTime scanAt = DateTime.UtcNow;
|
||||
int folderScanned = 0, folderMatched = 0;
|
||||
Guid lastId = Guid.Empty;
|
||||
using var threadLocalRegex = new ThreadLocal<Regex>(() =>
|
||||
new Regex(folder.RegexPattern!, RegexOptions.IgnoreCase | RegexOptions.Compiled));
|
||||
|
||||
int folderScanned = 0;
|
||||
int folderMatched = 0;
|
||||
|
||||
for (int offset = 0; offset < totalInFolder; offset += ScanBatchSize) {
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var paths = await db.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
|
||||
var batch = await db.Assets.AsNoTracking()
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null && a.Id > lastId
|
||||
&& (cursor == null || a.IngestedAt >= cursor.Value))
|
||||
.OrderBy(a => a.Id)
|
||||
.Skip(offset)
|
||||
.Take(ScanBatchSize)
|
||||
.Select(a => a.OriginalPath)
|
||||
.Select(a => new { a.Id, a.OriginalPath })
|
||||
.ToListAsync(token);
|
||||
|
||||
int batchMatched = 0;
|
||||
foreach (var path in paths) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var match = regex.Match(path);
|
||||
if (!match.Success) continue;
|
||||
if (batch.Count == 0) break;
|
||||
lastId = batch[^1].Id;
|
||||
|
||||
var matched = new List<AssetAlbumStaging>(batch.Count);
|
||||
Parallel.ForEach(batch, new ParallelOptions {
|
||||
CancellationToken = token,
|
||||
MaxDegreeOfParallelism = Environment.ProcessorCount
|
||||
}, item => {
|
||||
var match = threadLocalRegex.Value!.Match(item.OriginalPath);
|
||||
if (!match.Success) return;
|
||||
|
||||
var personGroup = match.Groups["person"];
|
||||
var groupGroup = match.Groups["group"];
|
||||
if (!personGroup.Success || !groupGroup.Success) continue;
|
||||
if (!personGroup.Success) return;
|
||||
|
||||
var personName = ToTitleCase(personGroup.Value.Replace('_', ' ').Trim());
|
||||
var albumName = ToTitleCase(groupGroup.Value.Replace('_', ' ').Trim());
|
||||
if (string.IsNullOrWhiteSpace(personName)) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(personName) || string.IsNullOrWhiteSpace(albumName))
|
||||
continue;
|
||||
|
||||
if (!personAlbumNames.TryGetValue(personName, out var albums)) {
|
||||
albums = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
personAlbumNames[personName] = albums;
|
||||
uniquePersons++;
|
||||
string? albumName = null;
|
||||
if (hasGroup) {
|
||||
var groupGroup = match.Groups["group"];
|
||||
if (groupGroup.Success) {
|
||||
var candidate = ToTitleCase(groupGroup.Value.Replace('_', ' ').Trim());
|
||||
if (!string.IsNullOrWhiteSpace(candidate)) albumName = candidate;
|
||||
}
|
||||
}
|
||||
if (albums.Add(albumName))
|
||||
uniqueAlbums++;
|
||||
|
||||
batchMatched++;
|
||||
lock (matched) matched.Add(new AssetAlbumStaging {
|
||||
Id = Guid.NewGuid(), AssetId = item.Id, PersonName = personName, AlbumName = albumName
|
||||
});
|
||||
});
|
||||
|
||||
stagedBuffer.AddRange(matched);
|
||||
folderScanned += batch.Count;
|
||||
folderMatched += matched.Count;
|
||||
|
||||
if (stagedBuffer.Count >= StagingFlushSize) {
|
||||
await FlushStaging(db, stagedBuffer, token);
|
||||
stagedBuffer.Clear();
|
||||
}
|
||||
|
||||
folderScanned += paths.Count;
|
||||
folderMatched += batchMatched;
|
||||
totalAssetsScanned += paths.Count;
|
||||
totalAssetsMatched += batchMatched;
|
||||
// Track unique counts for progress display (cheap approximate).
|
||||
foreach (var m in matched) {
|
||||
if (!personAlbumCounts.ContainsKey(m.PersonName)) uniquePersons++;
|
||||
personAlbumCounts.AddOrUpdate(m.PersonName, 1, (_, v) => v + 1);
|
||||
if (m.AlbumName != null) {
|
||||
var key = $"{m.PersonName}|{m.AlbumName}";
|
||||
if (albumKeys.TryAdd(key, 1)) uniqueAlbums++;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep progress bar at per-folder level, but update the message per batch
|
||||
// so the expanded details panel shows live counts
|
||||
float progress = folderIndex / folders.Count * 0.15f;
|
||||
float progress = folderIndex / folders.Count * 0.25f;
|
||||
JobStatus.UpdateProgress(progress,
|
||||
$"Scanning {folder.BasePath}: {folderScanned:N0}/{totalInFolder:N0} — {uniquePersons} persons, {uniqueAlbums} albums found so far");
|
||||
$"Scanning {folder.BasePath}: {folderScanned:N0} scanned — {uniquePersons} persons, {uniqueAlbums} albums");
|
||||
}
|
||||
|
||||
if (stagedBuffer.Count > 0) {
|
||||
await FlushStaging(db, stagedBuffer, token);
|
||||
stagedBuffer.Clear();
|
||||
}
|
||||
|
||||
totalAssetsScanned += folderScanned;
|
||||
totalAssetsMatched += folderMatched;
|
||||
folderCursorUpdates.Add((folder.Id, scanAt, folder.RegexPattern!));
|
||||
|
||||
folderIndex++;
|
||||
float folderProgress = folderIndex / folders.Count * 0.15f;
|
||||
JobStatus.UpdateProgress(folderProgress,
|
||||
$"Scanned folder {folder.BasePath}: {folderScanned:N0} assets, {folderMatched} matched — {uniquePersons} persons, {uniqueAlbums} albums");
|
||||
JobStatus.UpdateProgress(folderIndex / folders.Count * 0.25f,
|
||||
$"Scanned {folder.BasePath}: {folderMatched:N0} matched — {uniquePersons} persons, {uniqueAlbums} albums");
|
||||
|
||||
logger.LogInformation("Scanned folder {BasePath}: {Scanned:N0} assets, {Matched} matched. Cumulative: {Persons} persons, {Albums} albums",
|
||||
folder.BasePath, folderScanned, folderMatched, uniquePersons, uniqueAlbums);
|
||||
}
|
||||
|
||||
if (personAlbumNames.Count == 0) {
|
||||
if (totalAssetsMatched == 0)
|
||||
logger.LogInformation("No person/album pairs extracted from asset paths.");
|
||||
JobStatus.Complete("No person/album pairs extracted from asset paths.");
|
||||
return;
|
||||
|
||||
JobStatus.UpdateProgress(0.25f, $"Discovered {uniquePersons} persons, {uniqueAlbums} albums from {totalAssetsMatched:N0} matched assets.");
|
||||
|
||||
// ── Phase 2: Create missing persons ──
|
||||
var distinctPersons = await db.AssetAlbumStaging.Select(s => s.PersonName).Distinct().ToListAsync(token);
|
||||
var existingPersons = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var chunk in distinctPersons.Chunk(LookupChunkSize)) {
|
||||
foreach (var p in await personRepo.FindByNamesAsync(chunk, token))
|
||||
existingPersons.TryAdd(p.Name, p.Id);
|
||||
}
|
||||
|
||||
int totalGroups = personAlbumNames.Sum(kv => kv.Value.Count);
|
||||
logger.LogInformation("Discovery complete: {PersonCount} persons, {GroupCount} album groups from {Matched:N0} matched assets.",
|
||||
personAlbumNames.Count, totalGroups, totalAssetsMatched);
|
||||
JobStatus.UpdateProgress(0.15f, $"Discovered {personAlbumNames.Count} persons, {totalGroups} albums from {totalAssetsMatched:N0} matched assets.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// ── Phase 2: Create missing persons and albums ──
|
||||
|
||||
var personNames = personAlbumNames.Keys;
|
||||
var existingPersons = personRepo.FindByNames(personNames)
|
||||
.ToDictionary(p => p.Name, p => p.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var missingPersonCount = personNames.Count(n => !existingPersons.ContainsKey(n));
|
||||
if (missingPersonCount > 0)
|
||||
logger.LogInformation("{Existing} existing persons found, {Missing} missing to create.", existingPersons.Count, missingPersonCount);
|
||||
else
|
||||
logger.LogInformation("All {Count} persons already exist.", existingPersons.Count);
|
||||
|
||||
var personCreated = 0;
|
||||
foreach (var name in personNames) {
|
||||
int personCreated = 0;
|
||||
foreach (var name in distinctPersons) {
|
||||
if (existingPersons.ContainsKey(name)) continue;
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
var person = new Person {
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
var person = new Person { Id = Guid.NewGuid(), Name = name, CreatedAt = DateTime.UtcNow };
|
||||
personRepo.Insert(person);
|
||||
existingPersons[name] = person.Id;
|
||||
personCreated++;
|
||||
|
||||
if (personCreated % 10 == 0) {
|
||||
JobStatus.UpdateProgress(0.15f + (float)personCreated / missingPersonCount * 0.10f,
|
||||
$"Creating persons: {personCreated}/{missingPersonCount} — {name}");
|
||||
}
|
||||
}
|
||||
|
||||
if (personCreated > 0) {
|
||||
personRepo.Save();
|
||||
await personRepo.SaveAsync(token);
|
||||
logger.LogInformation("Created {Count} missing persons.", personCreated);
|
||||
}
|
||||
|
||||
JobStatus.UpdateProgress(0.25f, $"Persons resolved (created {personCreated} new). Checking existing albums...");
|
||||
JobStatus.UpdateProgress(0.35f, $"Persons resolved (created {personCreated} new). Checking existing albums...");
|
||||
|
||||
var existingAlbumKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var allAlbumTitles = personAlbumNames.Values.SelectMany(v => v).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
if (allAlbumTitles.Count > 0) {
|
||||
var existing = await db.Albums
|
||||
.Where(a => allAlbumTitles.Contains(a.Title))
|
||||
.Select(a => new { a.Title, a.PersonOwnerId })
|
||||
.ToListAsync(token);
|
||||
foreach (var a in existing)
|
||||
existingAlbumKeys.Add($"{a.Title}|{a.PersonOwnerId}");
|
||||
}
|
||||
// ── Phase 3: Create missing albums ──
|
||||
var pairs = await db.AssetAlbumStaging
|
||||
.Where(s => s.AlbumName != null)
|
||||
.Select(s => new { s.PersonName, AlbumName = s.AlbumName! })
|
||||
.Distinct()
|
||||
.ToListAsync(token);
|
||||
|
||||
logger.LogInformation("Found {Count} existing album-person pairings in DB.", existingAlbumKeys.Count);
|
||||
// albumName|personId → albumId (existing + newly created)
|
||||
var albumIdByKey = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var albumIdMap = new Dictionary<(string Person, string Album), Guid>();
|
||||
int albumCreated = 0;
|
||||
int totalAlbumSlots = personAlbumNames.Sum(kv => kv.Value.Count);
|
||||
int newAlbumSlots = totalAlbumSlots - existingAlbumKeys.Count;
|
||||
int albumsChecked = 0;
|
||||
foreach (var group in pairs.GroupBy(p => p.PersonName, StringComparer.OrdinalIgnoreCase)) {
|
||||
if (!existingPersons.TryGetValue(group.Key, out var personId)) continue;
|
||||
var titles = group.Select(p => p.AlbumName).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
if (newAlbumSlots > 0)
|
||||
logger.LogInformation("{New} new albums to create out of {Total} total.", newAlbumSlots, totalAlbumSlots);
|
||||
|
||||
foreach (var (personName, albumNames) in personAlbumNames) {
|
||||
if (!existingPersons.TryGetValue(personName, out var personId)) continue;
|
||||
|
||||
foreach (var albumName in albumNames) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
albumsChecked++;
|
||||
var existsKey = $"{albumName}|{personId}";
|
||||
if (existingAlbumKeys.Contains(existsKey)) continue;
|
||||
|
||||
var albumId = Guid.NewGuid();
|
||||
albumRepo.Insert(new Album {
|
||||
Id = albumId,
|
||||
Title = albumName,
|
||||
PersonOwnerId = personId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
albumIdMap[(personName, albumName)] = albumId;
|
||||
existingAlbumKeys.Add(existsKey);
|
||||
albumCreated++;
|
||||
|
||||
if (albumCreated % 10 == 0) {
|
||||
JobStatus.UpdateProgress(0.25f + (float)albumsChecked / totalAlbumSlots * 0.15f,
|
||||
$"Creating albums: {albumCreated}/{newAlbumSlots} new — {albumName}");
|
||||
}
|
||||
foreach (var titleChunk in titles.Chunk(LookupChunkSize)) {
|
||||
var existing = await db.Albums
|
||||
.Where(a => a.PersonOwnerId == personId && titleChunk.Contains(a.Title))
|
||||
.Select(a => new { a.Title, a.Id })
|
||||
.ToListAsync(token);
|
||||
foreach (var a in existing)
|
||||
albumIdByKey.TryAdd($"{a.Title}|{personId}", a.Id);
|
||||
}
|
||||
}
|
||||
|
||||
int albumCreated = 0;
|
||||
foreach (var pair in pairs) {
|
||||
if (!existingPersons.TryGetValue(pair.PersonName, out var personId)) continue;
|
||||
var key = $"{pair.AlbumName}|{personId}";
|
||||
if (albumIdByKey.ContainsKey(key)) continue;
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
var albumId = Guid.NewGuid();
|
||||
albumRepo.Insert(new Album {
|
||||
Id = albumId, Title = pair.AlbumName, PersonOwnerId = personId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
albumIdByKey[key] = albumId;
|
||||
albumCreated++;
|
||||
}
|
||||
if (albumCreated > 0) {
|
||||
albumRepo.Save();
|
||||
await albumRepo.SaveAsync(token);
|
||||
logger.LogInformation("Created {Count} new albums.", albumCreated);
|
||||
}
|
||||
|
||||
// Build lookup for the assignment phase (covers both new and pre-existing albums)
|
||||
var albumLookup = new Dictionary<(string Person, string Album), Guid>();
|
||||
foreach (var (personName, albumNames) in personAlbumNames) {
|
||||
if (!existingPersons.TryGetValue(personName, out var personId)) continue;
|
||||
JobStatus.UpdateProgress(0.45f, $"Entities complete: {personCreated} persons, {albumCreated} albums. Assigning assets...");
|
||||
|
||||
foreach (var albumName in albumNames) {
|
||||
if (albumIdMap.TryGetValue((personName, albumName), out var id)) {
|
||||
albumLookup[(personName, albumName)] = id;
|
||||
} else {
|
||||
var existing = await db.Albums
|
||||
.Where(a => a.Title == albumName && a.PersonOwnerId == personId)
|
||||
.Select(a => a.Id)
|
||||
.FirstOrDefaultAsync(token);
|
||||
if (existing != Guid.Empty)
|
||||
albumLookup[(personName, albumName)] = existing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JobStatus.UpdateProgress(0.40f, $"Entities complete: {personCreated} persons, {albumCreated} albums created. Assigning assets...");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// ── Phase 3: Re-scan assets in batches, assign to albums via Asset.Albums ──
|
||||
// ── Phase 4: Assign assets to albums (bulk, idempotent) ──
|
||||
var personIdByName = existingPersons; // name → id (includes newly created)
|
||||
int assetAssignments = 0;
|
||||
int pendingBeforeSave = 0;
|
||||
folderIndex = 0;
|
||||
long processed = 0;
|
||||
Guid lastStagingId = Guid.Empty;
|
||||
var assignmentBuffer = new List<(Guid AlbumId, Guid AssetId)>(AssignmentBatchSize);
|
||||
long totalToAssign = await db.AssetAlbumStaging.CountAsync(s => s.AlbumName != null, token);
|
||||
|
||||
foreach (var folder in folders) {
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var regex = new Regex(folder.RegexPattern!, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
var groupNames = regex.GetGroupNames().Select(g => g.ToLowerInvariant()).ToHashSet();
|
||||
var rows = await db.AssetAlbumStaging.AsNoTracking()
|
||||
.Where(s => s.Id > lastStagingId && s.AlbumName != null)
|
||||
.OrderBy(s => s.Id)
|
||||
.Take(ScanBatchSize)
|
||||
.Select(s => new { s.Id, AssetId = s.AssetId, s.PersonName, AlbumName = s.AlbumName! })
|
||||
.ToListAsync(token);
|
||||
|
||||
if (!groupNames.Contains("person") || !groupNames.Contains("group"))
|
||||
continue;
|
||||
if (rows.Count == 0) break;
|
||||
lastStagingId = rows[^1].Id;
|
||||
processed += rows.Count;
|
||||
|
||||
var totalInFolder = await db.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.CountAsync(token);
|
||||
|
||||
int folderAssigned = 0;
|
||||
int folderScanned = 0;
|
||||
|
||||
for (int offset = 0; offset < totalInFolder; offset += ScanBatchSize) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var assets = await db.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.OrderBy(a => a.Id)
|
||||
.Skip(offset)
|
||||
.Take(ScanBatchSize)
|
||||
.Select(a => new { a.Id, a.OriginalPath })
|
||||
.ToListAsync(token);
|
||||
|
||||
var batchAssetIds = assets.Select(a => a.Id).ToList();
|
||||
var existingPairs = new HashSet<(Guid AlbumId, Guid AssetId)>();
|
||||
if (batchAssetIds.Count > 0) {
|
||||
var existing = await db.Albums
|
||||
.Where(a => a.Assets!.Any(asset => batchAssetIds.Contains(asset.Id)))
|
||||
.SelectMany(a => a.Assets!.Select(asset => new { AlbumId = a.Id, AssetId = asset.Id }))
|
||||
.Where(x => batchAssetIds.Contains(x.AssetId))
|
||||
.Select(x => new { x.AlbumId, x.AssetId })
|
||||
.ToListAsync(token);
|
||||
foreach (var pair in existing)
|
||||
existingPairs.Add((pair.AlbumId, pair.AssetId));
|
||||
foreach (var row in rows) {
|
||||
if (!personIdByName.TryGetValue(row.PersonName, out var personId)) continue;
|
||||
if (!albumIdByKey.TryGetValue($"{row.AlbumName}|{personId}", out var albumId)) continue;
|
||||
assignmentBuffer.Add((albumId, row.AssetId));
|
||||
if (assignmentBuffer.Count >= AssignmentBatchSize) {
|
||||
await InsertAlbumAssetBatch(db, assignmentBuffer, token);
|
||||
assetAssignments += assignmentBuffer.Count;
|
||||
assignmentBuffer.Clear();
|
||||
}
|
||||
|
||||
foreach (var asset in assets) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var match = regex.Match(asset.OriginalPath);
|
||||
if (!match.Success) continue;
|
||||
|
||||
var personGroup = match.Groups["person"];
|
||||
var groupGroup = match.Groups["group"];
|
||||
if (!personGroup.Success || !groupGroup.Success) continue;
|
||||
|
||||
var personName = ToTitleCase(personGroup.Value.Replace('_', ' ').Trim());
|
||||
var albumName = ToTitleCase(groupGroup.Value.Replace('_', ' ').Trim());
|
||||
|
||||
if (string.IsNullOrWhiteSpace(personName) || string.IsNullOrWhiteSpace(albumName))
|
||||
continue;
|
||||
|
||||
if (!albumLookup.TryGetValue((personName, albumName), out var albumId))
|
||||
continue;
|
||||
|
||||
var assetStub = db.Assets.Local.FirstOrDefault(a => a.Id == asset.Id);
|
||||
if (assetStub == null) {
|
||||
assetStub = new Asset {
|
||||
Id = asset.Id,
|
||||
OriginalPath = string.Empty,
|
||||
OriginalFilename = string.Empty,
|
||||
Type = EAssetType.Image,
|
||||
CreatedAt = DateTime.MinValue
|
||||
};
|
||||
db.Entry(assetStub).State = EntityState.Unchanged;
|
||||
}
|
||||
|
||||
var albumStub = db.Albums.Local.FirstOrDefault(a => a.Id == albumId);
|
||||
if (albumStub == null) {
|
||||
albumStub = new Album { Id = albumId };
|
||||
db.Entry(albumStub).State = EntityState.Unchanged;
|
||||
}
|
||||
|
||||
if (assetStub.Albums == null)
|
||||
assetStub.Albums = new List<Album>();
|
||||
|
||||
if (!existingPairs.Contains((albumId, asset.Id)) && !assetStub.Albums.Any(a => a.Id == albumId)) {
|
||||
assetStub.Albums.Add(albumStub);
|
||||
assetAssignments++;
|
||||
folderAssigned++;
|
||||
}
|
||||
pendingBeforeSave++;
|
||||
|
||||
if (pendingBeforeSave >= SaveBatchSize) {
|
||||
await db.SaveChangesAsync(token);
|
||||
db.ChangeTracker.Clear();
|
||||
pendingBeforeSave = 0;
|
||||
}
|
||||
}
|
||||
|
||||
folderScanned += assets.Count;
|
||||
|
||||
// Per-batch message update while keeping progress at per-folder level
|
||||
float batchScanFraction = Math.Min(1f, (float)(offset + ScanBatchSize) / totalInFolder);
|
||||
float progress = 0.40f + (folderIndex + batchScanFraction) / folders.Count * 0.60f;
|
||||
JobStatus.UpdateProgress(progress,
|
||||
$"Assigning in {folder.BasePath}: {Math.Min(offset + ScanBatchSize, totalInFolder):N0}/{totalInFolder:N0} — {assetAssignments:N0} total");
|
||||
}
|
||||
|
||||
folderIndex++;
|
||||
float folderProgress = 0.40f + folderIndex / folders.Count * 0.60f;
|
||||
JobStatus.UpdateProgress(folderProgress,
|
||||
$"Assigned {folderAssigned:N0} assets in {folder.BasePath} ({assetAssignments:N0} total so far)");
|
||||
|
||||
logger.LogInformation("Assigned {FolderAssignments:N0} assets in folder {BasePath} ({Total:N0} total)",
|
||||
folderAssigned, folder.BasePath, assetAssignments);
|
||||
float assignFraction = totalToAssign == 0 ? 1f : (float)processed / totalToAssign;
|
||||
JobStatus.UpdateProgress(0.45f + Math.Clamp(assignFraction, 0f, 1f) * 0.25f,
|
||||
$"Assigning assets: {assetAssignments:N0} assigned");
|
||||
}
|
||||
|
||||
if (pendingBeforeSave > 0)
|
||||
await db.SaveChangesAsync(token);
|
||||
if (assignmentBuffer.Count > 0) {
|
||||
await InsertAlbumAssetBatch(db, assignmentBuffer, token);
|
||||
assetAssignments += assignmentBuffer.Count;
|
||||
assignmentBuffer.Clear();
|
||||
}
|
||||
|
||||
db.ChangeTracker.Clear();
|
||||
JobStatus.UpdateProgress(0.70f, $"Assigned {assetAssignments:N0} assets. Setting cover images...");
|
||||
|
||||
logger.LogInformation("Setting cover images for albums without one...");
|
||||
// ── Phase 5: Bulk cover assignment (one grouped query) ──
|
||||
int coversAssigned = 0;
|
||||
var albumsNeedingCover = await db.Albums
|
||||
var covers = await db.Albums
|
||||
.Where(a => a.CoverAssetId == null)
|
||||
.Select(a => new {
|
||||
AlbumId = a.Id,
|
||||
AssetId = a.Assets!
|
||||
.Where(s => s.DeletedAt == null)
|
||||
.OrderBy(s => s.OriginalFilename)
|
||||
.Select(s => s.Id)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.Where(x => x.AssetId != Guid.Empty)
|
||||
.ToListAsync(token);
|
||||
|
||||
foreach (var album in albumsNeedingCover) {
|
||||
var firstAsset = await db.Assets
|
||||
.Where(a => a.Albums!.Any(al => al.Id == album.Id) && a.DeletedAt == null)
|
||||
.OrderBy(a => a.OriginalFilename)
|
||||
.Select(a => new { a.Id })
|
||||
.FirstOrDefaultAsync(token);
|
||||
|
||||
if (firstAsset != null) {
|
||||
album.CoverAssetId = firstAsset.Id;
|
||||
if (covers.Count > 0) {
|
||||
var albumIds = covers.Select(c => c.AlbumId).Distinct().ToList();
|
||||
var albums = await db.Albums.Where(a => albumIds.Contains(a.Id)).ToListAsync(token);
|
||||
foreach (var cover in covers) {
|
||||
var album = albums.FirstOrDefault(a => a.Id == cover.AlbumId);
|
||||
if (album == null || album.CoverAssetId != null) continue;
|
||||
album.CoverAssetId = cover.AssetId;
|
||||
coversAssigned++;
|
||||
}
|
||||
if (coversAssigned > 0) {
|
||||
await db.SaveChangesAsync(token);
|
||||
logger.LogInformation("Assigned cover images to {Count} albums.", coversAssigned);
|
||||
}
|
||||
}
|
||||
|
||||
if (coversAssigned > 0) {
|
||||
await db.SaveChangesAsync(token);
|
||||
logger.LogInformation("Assigned cover images to {Count} albums.", coversAssigned);
|
||||
// ── Phase 6: Commit folder cursors + clean staging ──
|
||||
foreach (var (folderId, scanAt, regex) in folderCursorUpdates) {
|
||||
var f = await db.Folders.FirstOrDefaultAsync(x => x.Id == folderId, token);
|
||||
if (f == null) continue;
|
||||
f.LastDiscoveryScanAt = scanAt;
|
||||
f.LastDiscoveryScannedRegex = regex;
|
||||
}
|
||||
await db.SaveChangesAsync(token);
|
||||
await TruncateStaging(db, token);
|
||||
|
||||
logger.LogInformation("Completed: {Assigned} assets assigned across {PersonsCreated} persons, {AlbumsCreated} albums.",
|
||||
assetAssignments, personCreated, albumCreated);
|
||||
@@ -405,6 +341,28 @@ public class CreateAlbumsJob(ILogger<CreateAlbumsJob> logger, IServiceProvider s
|
||||
}
|
||||
}
|
||||
|
||||
static async Task FlushStaging(LactoseDbContext db, List<AssetAlbumStaging> buffer, CancellationToken token) {
|
||||
db.AssetAlbumStaging.AddRange(buffer);
|
||||
await db.SaveChangesAsync(token);
|
||||
db.ChangeTracker.Clear();
|
||||
}
|
||||
|
||||
static async Task TruncateStaging(LactoseDbContext db, CancellationToken token) =>
|
||||
await db.AssetAlbumStaging.ExecuteDeleteAsync(token);
|
||||
|
||||
static async Task InsertAlbumAssetBatch(LactoseDbContext db, List<(Guid AlbumId, Guid AssetId)> batch, CancellationToken token) {
|
||||
var sql = new StringBuilder("INSERT INTO \"AlbumAsset\" (\"AlbumsId\",\"AssetsId\") VALUES ");
|
||||
var parameters = new List<NpgsqlParameter>(batch.Count * 2);
|
||||
for (int i = 0; i < batch.Count; i++) {
|
||||
if (i > 0) sql.Append(',');
|
||||
sql.Append($"(@a{i},@s{i})");
|
||||
parameters.Add(new NpgsqlParameter($"@a{i}", batch[i].AlbumId));
|
||||
parameters.Add(new NpgsqlParameter($"@s{i}", batch[i].AssetId));
|
||||
}
|
||||
sql.Append(" ON CONFLICT DO NOTHING");
|
||||
await db.Database.ExecuteSqlRawAsync(sql.ToString(), parameters.ToArray(), token);
|
||||
}
|
||||
|
||||
static string ToTitleCase(string input) =>
|
||||
CultureInfo.CurrentCulture.TextInfo.ToTitleCase(input.ToLowerInvariant());
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Scans active folders with regex patterns in a streaming fashion (suitable for millions of assets),
|
||||
/// extracts person names from asset paths, and creates any missing Person entities.
|
||||
/// </summary>
|
||||
public class CreatePersonsJob(ILogger<CreatePersonsJob> logger, IServiceProvider serviceProvider) : Job {
|
||||
const int ScanBatchSize = 5000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Create Persons Job";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task TaskJob(CancellationToken token) {
|
||||
try {
|
||||
logger.LogInformation("Starting create persons job...");
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
|
||||
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
|
||||
var personRepo = scope.ServiceProvider.GetRequiredService<IPersonRepository>();
|
||||
|
||||
var folders = folderRepo.GetAll()
|
||||
.Where(f => f.Active && !string.IsNullOrWhiteSpace(f.RegexPattern))
|
||||
.ToList();
|
||||
|
||||
if (folders.Count == 0) {
|
||||
logger.LogInformation("No active folders with a regex pattern found.");
|
||||
JobStatus.Complete("No active folders with a regex pattern found.");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Scanning {FolderCount} active folders for person names...", folders.Count);
|
||||
|
||||
// ── Phase 1: Streaming scan → collect unique person names ──
|
||||
var personNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
float folderIndex = 0;
|
||||
int totalAssetsMatched = 0;
|
||||
|
||||
foreach (var folder in folders) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var regex = new Regex(folder.RegexPattern!, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
if (!regex.GetGroupNames().Contains("person", StringComparer.OrdinalIgnoreCase)) {
|
||||
logger.LogWarning("Folder {FolderId} ({BasePath}) regex is missing the 'person' named group. Skipping.", folder.Id, folder.BasePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
var totalInFolder = await db.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.CountAsync(token);
|
||||
|
||||
int folderScanned = 0;
|
||||
int folderMatched = 0;
|
||||
|
||||
for (int offset = 0; offset < totalInFolder; offset += ScanBatchSize) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var paths = await db.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.OrderBy(a => a.Id)
|
||||
.Skip(offset)
|
||||
.Take(ScanBatchSize)
|
||||
.Select(a => a.OriginalPath)
|
||||
.ToListAsync(token);
|
||||
|
||||
int batchMatched = 0;
|
||||
foreach (var path in paths) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var match = regex.Match(path);
|
||||
if (!match.Success) continue;
|
||||
|
||||
var group = match.Groups["person"];
|
||||
if (!group.Success) continue;
|
||||
|
||||
var name = ToTitleCase(group.Value.Replace('_', ' ').Trim());
|
||||
if (!string.IsNullOrWhiteSpace(name)) {
|
||||
personNames.Add(name);
|
||||
batchMatched++;
|
||||
}
|
||||
}
|
||||
|
||||
folderScanned += paths.Count;
|
||||
folderMatched += batchMatched;
|
||||
|
||||
// Per-batch message update while keeping progress at per-folder level
|
||||
float progress = folderIndex / folders.Count * 0.5f;
|
||||
JobStatus.UpdateProgress(progress,
|
||||
$"Scanning {folder.BasePath}: {folderScanned:N0}/{totalInFolder:N0} — {personNames.Count:N0} persons found so far");
|
||||
}
|
||||
|
||||
totalAssetsMatched += folderMatched;
|
||||
folderIndex++;
|
||||
float folderProgress = folderIndex / folders.Count * 0.5f;
|
||||
JobStatus.UpdateProgress(folderProgress,
|
||||
$"Scanned folder {folder.BasePath}: {folderScanned:N0} assets, {folderMatched:N0} matched — {personNames.Count:N0} unique persons");
|
||||
|
||||
logger.LogInformation("Scanned folder {BasePath}: {Scanned:N0} assets, {Matched} matched. Cumulative: {Persons} unique persons",
|
||||
folder.BasePath, folderScanned, folderMatched, personNames.Count);
|
||||
}
|
||||
|
||||
if (personNames.Count == 0) {
|
||||
logger.LogInformation("No person names extracted from asset paths.");
|
||||
JobStatus.Complete("No person names extracted from asset paths.");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Extracted {Count} unique person names from {Matched:N0} matched assets. Checking existing...",
|
||||
personNames.Count, totalAssetsMatched);
|
||||
JobStatus.UpdateProgress(0.5f, $"Extracted {personNames.Count:N0} unique person names from {totalAssetsMatched:N0} matched assets.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
|
||||
// ── Phase 2: Create missing persons ──
|
||||
|
||||
var existingNames = personRepo.FindByNames(personNames)
|
||||
.Select(p => p.Name)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var missingNames = personNames
|
||||
.Where(n => !existingNames.Contains(n))
|
||||
.ToList();
|
||||
|
||||
if (missingNames.Count == 0) {
|
||||
logger.LogInformation("All {Count} persons already exist.", personNames.Count);
|
||||
JobStatus.Complete($"All {personNames.Count:N0} persons already exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Creating {Count} missing persons ({Existing} already exist).",
|
||||
missingNames.Count, existingNames.Count);
|
||||
|
||||
float created = 0;
|
||||
foreach (var name in missingNames) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
personRepo.Insert(new Person {
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
created++;
|
||||
float progress = 0.5f + created / missingNames.Count * 0.5f;
|
||||
JobStatus.UpdateProgress(progress, $"Creating persons: {created:N0}/{missingNames.Count:N0} — {name}");
|
||||
}
|
||||
|
||||
personRepo.Save();
|
||||
logger.LogInformation("Created {Count} new persons.", missingNames.Count);
|
||||
JobStatus.Complete($"Created {missingNames.Count:N0} new persons from {totalAssetsMatched:N0} matched assets.");
|
||||
} catch (OperationCanceledException) {
|
||||
JobStatus.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
static string ToTitleCase(string input) =>
|
||||
CultureInfo.CurrentCulture.TextInfo.ToTitleCase(input.ToLowerInvariant());
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Runs ffmpeg/ffprobe invocations for media conversion jobs.
|
||||
/// </summary>
|
||||
internal static class FfmpegRunner {
|
||||
/// <summary>
|
||||
/// Executes ffmpeg with the given arguments, capturing output for logging.
|
||||
/// </summary>
|
||||
/// <param name="ffmpegPath">Path to the ffmpeg executable.</param>
|
||||
/// <param name="arguments">Command-line arguments to pass to ffmpeg.</param>
|
||||
/// <param name="logger">Logger for ffmpeg output.</param>
|
||||
/// <param name="token">Cancellation token; the process is killed if cancelled.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when ffmpeg exits with a non-zero exit code.</exception>
|
||||
internal static async Task RunAsync(string ffmpegPath, string arguments, ILogger logger, CancellationToken token) {
|
||||
var startInfo = new ProcessStartInfo {
|
||||
FileName = ffmpegPath,
|
||||
Arguments = arguments,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
process.Start();
|
||||
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync();
|
||||
var stderrTask = process.StandardError.ReadToEndAsync();
|
||||
|
||||
try {
|
||||
await process.WaitForExitAsync(token);
|
||||
} catch (OperationCanceledException) {
|
||||
try { process.Kill(entireProcessTree: true); } catch { /* already exited */ }
|
||||
throw;
|
||||
}
|
||||
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
|
||||
if (!string.IsNullOrEmpty(stdout))
|
||||
logger.LogTrace("ffmpeg stdout: {Output}", stdout);
|
||||
if (!string.IsNullOrEmpty(stderr))
|
||||
logger.LogTrace("ffmpeg stderr: {Output}", stderr);
|
||||
|
||||
if (process.ExitCode != 0) {
|
||||
logger.LogError("ffmpeg exited with code {Code}. Stderr: {Error}", process.ExitCode, stderr);
|
||||
throw new InvalidOperationException($"ffmpeg exited with code {process.ExitCode}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+240
-141
@@ -1,204 +1,316 @@
|
||||
using Butter;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using System.Collections;
|
||||
using System.Data;
|
||||
using static System.String;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Crawls a file system directory, recursively discovering assets and creating child crawl jobs for subdirectories.
|
||||
/// Crawls a file system directory, incrementally discovering and reconciling assets against the database.
|
||||
/// Uses per-directory modification timestamps to skip file enumeration in unchanged directories,
|
||||
/// and batches all database writes instead of saving once per file.
|
||||
/// </summary>
|
||||
public sealed class FileSystemCrawlJob : Job {
|
||||
/// <inheritdoc />
|
||||
public override string Name { get; } = "Directory Scan: ";
|
||||
public override string Name { get; }
|
||||
/// <inheritdoc />
|
||||
public override JobStatus JobStatus { get; }
|
||||
IAssetRepository assetRepository;
|
||||
ISettingsRepository settingsRepository;
|
||||
string workingPath;
|
||||
Guid folderId;
|
||||
ILogger<FileSystemCrawlJob> logger;
|
||||
JobManager jobManager;
|
||||
List<Job> childJobs = [];
|
||||
int pendingChildren;
|
||||
readonly Guid folderId;
|
||||
readonly string workingPath;
|
||||
readonly bool deep;
|
||||
readonly ILogger<FileSystemCrawlJob> logger;
|
||||
|
||||
const int ProgressUpdateEveryDirs = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises a new file system crawl job for the specified folder.
|
||||
/// Initialises a new file-system crawl job for the specified folder.
|
||||
/// </summary>
|
||||
/// <param name="folderId">The folder ID.</param>
|
||||
/// <param name="workingPath">The base path to crawl.</param>
|
||||
/// <param name="deep">When true, ignores recorded scan state and reconciles every asset.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="assetRepository">The asset repository.</param>
|
||||
/// <param name="settingsRepository">The settings repository.</param>
|
||||
/// <param name="jobManager">The job manager.</param>
|
||||
public FileSystemCrawlJob(
|
||||
Guid folderId,
|
||||
string workingPath,
|
||||
ILogger<FileSystemCrawlJob> logger,
|
||||
IAssetRepository assetRepository,
|
||||
ISettingsRepository settingsRepository,
|
||||
JobManager jobManager
|
||||
) {
|
||||
JobStatus = new(this);
|
||||
this.logger = logger;
|
||||
this.assetRepository = assetRepository;
|
||||
this.settingsRepository = settingsRepository;
|
||||
this.workingPath = workingPath;
|
||||
public FileSystemCrawlJob(Guid folderId, string workingPath, bool deep, ILogger<FileSystemCrawlJob> logger) {
|
||||
JobStatus = new(this);
|
||||
this.folderId = folderId;
|
||||
this.jobManager = jobManager;
|
||||
Name += $"{workingPath}";
|
||||
this.workingPath = workingPath;
|
||||
this.deep = deep;
|
||||
this.logger = logger;
|
||||
Name = $"Directory Scan: {workingPath}";
|
||||
}
|
||||
|
||||
///<inheritdoc />
|
||||
protected override async Task TaskJob(CancellationToken token) {
|
||||
logger.LogInformation($"Started crawling directory {workingPath}");
|
||||
logger.LogInformation("Started crawling directory {Path} (deep={Deep})", workingPath, deep);
|
||||
|
||||
DirectoryInfo dir = new(workingPath);
|
||||
|
||||
if (!dir.Exists) {
|
||||
var root = new DirectoryInfo(workingPath);
|
||||
if (!root.Exists) {
|
||||
logger.LogWarning("Directory {Dir} does not exist. Skipping crawl.", workingPath);
|
||||
JobStatus.Complete("Directory does not exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
var childTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var db = Scope!.ServiceProvider.GetRequiredService<LactoseDbContext>();
|
||||
var settingsRepo = Scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
|
||||
|
||||
int steps,
|
||||
totalSteps;
|
||||
|
||||
// Resolve system uploader from settings once
|
||||
Guid? uploaderId = null;
|
||||
var uploaderSetting = settingsRepository.Get(Settings.SystemUploaderId.AsString())?.Value;
|
||||
if (Guid.TryParse(uploaderSetting, out var parsed))
|
||||
uploaderId = parsed;
|
||||
var uploaderSetting = (await settingsRepo.GetAsync(Settings.SystemUploaderId.AsString(), token))?.Value;
|
||||
if (Guid.TryParse(uploaderSetting, out var parsed)) uploaderId = parsed;
|
||||
|
||||
var files = dir.GetFiles();
|
||||
logger.LogDebug($"Found {files.Length} files.");
|
||||
// Separate context for asset reconciliation so the change tracker can be cleared per directory
|
||||
// without detaching the scan-state entities tracked by `db`.
|
||||
using var assetScope = Scope.ServiceProvider.CreateScope();
|
||||
var assetDb = assetScope.ServiceProvider.GetRequiredService<LactoseDbContext>();
|
||||
|
||||
var folders = dir.GetDirectories();
|
||||
logger.LogDebug($"Found {folders.Length} folders.");
|
||||
var existingStates = await db.DirectoryScanStates
|
||||
.Where(s => s.FolderId == folderId)
|
||||
.ToDictionaryAsync(s => s.Path, StringComparer.Ordinal, token);
|
||||
|
||||
totalSteps = files.Length + folders.Length;
|
||||
steps = 0;
|
||||
// Progress denominator: reuse the number of directories recorded by the previous scan, which is
|
||||
// accurate once a first run has completed. On the very first run (no state yet) there is no
|
||||
// denominator, so the bar ramps heuristically while the live message shows real progress.
|
||||
int totalDirs = Math.Max(existingStates.Count, 1);
|
||||
bool haveDenominator = existingStates.Count > 0;
|
||||
logger.LogInformation("Crawling {Path}: {Total} known directories.", workingPath, totalDirs);
|
||||
JobStatus.UpdateProgress(0.02f, haveDenominator
|
||||
? $"Scanning {totalDirs:N0} directories..."
|
||||
: "Scanning... (first run)");
|
||||
|
||||
foreach (var folder in folders) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel();
|
||||
return;
|
||||
}
|
||||
int dirsProcessed = 0, dirsSkipped = 0, walkedDirs = 0,
|
||||
filesAdded = 0, filesUpdated = 0, filesDeleted = 0;
|
||||
var lastProgressUpdate = DateTime.UtcNow;
|
||||
var seenPaths = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
steps++;
|
||||
var job = jobManager.CreateJob<FileSystemCrawlJob>(folderId, folder.FullName);
|
||||
job.ParentJobId = Id;
|
||||
job.LinkParentToken(token);
|
||||
job.Done += (o, _) => {
|
||||
lock (childJobs) { childJobs.Remove((o as Job)!); }
|
||||
if (Interlocked.Decrement(ref pendingChildren) == 0)
|
||||
childTcs.TrySetResult();
|
||||
};
|
||||
Interlocked.Increment(ref pendingChildren);
|
||||
lock (childJobs) { childJobs.Add(job); }
|
||||
jobManager.EnqueueJob(job);
|
||||
logger.LogDebug($"Enqueued crawl job for folder {folder.FullName}");
|
||||
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing folder {folder.FullName}");
|
||||
}
|
||||
var stack = new Stack<string>();
|
||||
stack.Push(root.FullName);
|
||||
|
||||
foreach (var file in files) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel();
|
||||
return;
|
||||
}
|
||||
while (stack.Count > 0) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var currentPath = stack.Pop();
|
||||
seenPaths.Add(currentPath);
|
||||
|
||||
steps++;
|
||||
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing file {file.FullName}");
|
||||
var currentDir = new DirectoryInfo(currentPath);
|
||||
if (!currentDir.Exists) continue;
|
||||
walkedDirs++;
|
||||
|
||||
var existing = assetRepository.FindByPath(file.FullName);
|
||||
|
||||
if (existing != null) {
|
||||
if (!file.Exists) {
|
||||
existing.DeletedAt = DateTime.UtcNow;
|
||||
assetRepository.Update(existing);
|
||||
assetRepository.Save();
|
||||
logger.LogInformation("Soft-deleted asset {Asset} — file no longer exists.", existing.OriginalPath);
|
||||
} else if (file.LastWriteTimeUtc > (existing.UpdatedAt ?? existing.CreatedAt)) {
|
||||
existing.FileSize = file.Length;
|
||||
existing.UpdatedAt = file.LastWriteTimeUtc;
|
||||
if (existing.UploadedBy == null && uploaderId.HasValue)
|
||||
existing.UploadedBy = uploaderId;
|
||||
assetRepository.Update(existing);
|
||||
assetRepository.Save();
|
||||
logger.LogInformation("Updated asset {Asset} — file modification detected.", existing.OriginalPath);
|
||||
} else if (existing.UploadedBy == null && uploaderId.HasValue) {
|
||||
existing.UploadedBy = uploaderId;
|
||||
assetRepository.Update(existing);
|
||||
assetRepository.Save();
|
||||
logger.LogInformation("Backfilled UploadedBy for asset {Asset}.", existing.OriginalPath);
|
||||
// A directory's mtime changes only when its direct children change; descendant changes bump
|
||||
// only their own parent. So we must always descend, but skip file reconciliation for clean dirs.
|
||||
bool changed = deep;
|
||||
if (!changed) {
|
||||
if (!existingStates.TryGetValue(currentPath, out var state)) {
|
||||
changed = true; // new directory
|
||||
} else {
|
||||
changed = state.LastMtime != currentDir.LastWriteTimeUtc;
|
||||
}
|
||||
}
|
||||
|
||||
List<DirectoryInfo> subdirs;
|
||||
FileInfo[]? files = null;
|
||||
try {
|
||||
subdirs = currentDir.GetDirectories().ToList();
|
||||
if (changed) files = currentDir.GetFiles();
|
||||
} catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Could not enumerate directory {Path}.", currentPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
var asset = AssetFromPath(file.FullName, uploaderId);
|
||||
if (asset == null) continue;
|
||||
if (changed) {
|
||||
dirsProcessed++;
|
||||
bool reconciled = await ReconcileDirectory(assetDb, currentPath, files!, uploaderId, token,
|
||||
(added, updated, deleted) => { filesAdded += added; filesUpdated += updated; filesDeleted += deleted; });
|
||||
|
||||
try {
|
||||
assetRepository.Insert(asset);
|
||||
assetRepository.Save();
|
||||
logger.LogInformation("Inserted asset {Asset} into the database.", asset.OriginalPath);
|
||||
} catch (DuplicateNameException) {
|
||||
logger.LogWarning("Asset {Asset} already exists in the database. Skipping.", asset.OriginalPath);
|
||||
} catch (Exception e) {
|
||||
logger.LogError(e, "Error inserting asset {Asset} into the database. {e}", asset.OriginalPath, e);
|
||||
// Only record scan state when the reconciliation batch actually committed; a dropped
|
||||
// batch leaves the mtime stale so the directory is retried on the next run.
|
||||
if (reconciled) {
|
||||
if (existingStates.TryGetValue(currentPath, out var existingState)) {
|
||||
existingState.LastMtime = currentDir.LastWriteTimeUtc;
|
||||
} else {
|
||||
existingStates[currentPath] = new DirectoryScanState {
|
||||
Id = Guid.NewGuid(),
|
||||
FolderId = folderId,
|
||||
Path = currentPath,
|
||||
LastMtime = currentDir.LastWriteTimeUtc
|
||||
};
|
||||
db.DirectoryScanStates.Add(existingStates[currentPath]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dirsSkipped++;
|
||||
}
|
||||
|
||||
foreach (var subdir in subdirs)
|
||||
stack.Push(subdir.FullName);
|
||||
|
||||
if (walkedDirs % ProgressUpdateEveryDirs == 0
|
||||
|| DateTime.UtcNow - lastProgressUpdate > TimeSpan.FromMilliseconds(500)) {
|
||||
lastProgressUpdate = DateTime.UtcNow;
|
||||
// Ramp heuristically when we have no accurate denominator (first run); otherwise
|
||||
// progress is walkedDirs/totalDirs. Cap before the final 0.95 "saving" step.
|
||||
float progress = haveDenominator
|
||||
? Math.Clamp(0.05f + 0.90f * walkedDirs / totalDirs, 0.05f, 0.90f)
|
||||
: Math.Clamp(0.05f + 0.80f * Math.Min(1f, walkedDirs / 5000f), 0.05f, 0.85f);
|
||||
JobStatus.UpdateProgress(progress,
|
||||
haveDenominator
|
||||
? $"Scanning {workingPath}: {walkedDirs:N0}/{totalDirs:N0} dirs — {filesAdded:N0} added, {filesUpdated:N0} updated, {filesDeleted:N0} removed"
|
||||
: $"Scanning {workingPath}: {walkedDirs:N0} dirs — {filesAdded:N0} added, {filesUpdated:N0} updated, {filesDeleted:N0} removed");
|
||||
}
|
||||
}
|
||||
|
||||
var childCount = Interlocked.CompareExchange(ref pendingChildren, 0, 0);
|
||||
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Waiting for {childCount:N0} child jobs to complete.");
|
||||
JobStatus.Wait();
|
||||
JobStatus.UpdateProgress(0.95f, "Saving scan state...");
|
||||
|
||||
if (childCount > 0) {
|
||||
using var ctr = token.Register(() => childTcs.TrySetCanceled(token));
|
||||
try {
|
||||
await childTcs.Task.ConfigureAwait(false);
|
||||
} catch (TaskCanceledException) {
|
||||
JobStatus.Cancel();
|
||||
return;
|
||||
}
|
||||
// Drop scan-state rows for directories that no longer exist on disk.
|
||||
var staleStates = existingStates.Values.Where(s => !seenPaths.Contains(s.Path)).ToList();
|
||||
db.DirectoryScanStates.RemoveRange(staleStates);
|
||||
|
||||
var folderEntity = await db.Folders.FirstOrDefaultAsync(f => f.Id == folderId, token);
|
||||
if (folderEntity != null) {
|
||||
folderEntity.LastFileScanAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
JobStatus.Complete("Crawl completed successfully.");
|
||||
await db.SaveChangesAsync(token);
|
||||
|
||||
logger.LogInformation("Crawl complete for {Path}: {Dirs} dirs processed, {Skipped} skipped; {Added} added, {Updated} updated, {Deleted} removed.",
|
||||
workingPath, dirsProcessed, dirsSkipped, filesAdded, filesUpdated, filesDeleted);
|
||||
JobStatus.Complete($"Scanned {workingPath}: {filesAdded:N0} added, {filesUpdated:N0} updated, {filesDeleted:N0} removed, {dirsSkipped:N0} dirs unchanged.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles the direct children of a directory against the database: inserts new assets,
|
||||
/// updates changed ones, and soft-deletes previously tracked files that no longer exist.
|
||||
/// All writes are batched into a single <see cref="DbContext.SaveChangesAsync(CancellationToken)"/> call.
|
||||
/// </summary>
|
||||
/// <param name="assetDb">A dedicated context for asset writes; cleared after each directory.</param>
|
||||
/// <param name="dirPath">The directory being reconciled.</param>
|
||||
/// <param name="files">The current direct files of the directory.</param>
|
||||
/// <param name="uploaderId">The system uploader id to backfill, if any.</param>
|
||||
/// <param name="token">Cancellation token.</param>
|
||||
/// <param name="report">Callback receiving (added, updated, deleted) counts.</param>
|
||||
/// <returns>True when the batch committed; false when it was dropped due to a duplicate-key race.</returns>
|
||||
async Task<bool> ReconcileDirectory(LactoseDbContext assetDb, string dirPath, FileInfo[] files, Guid? uploaderId,
|
||||
CancellationToken token, Action<int, int, int> report) {
|
||||
var prefix = dirPath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var lo = prefix;
|
||||
var hi = prefix + "\uFFFF";
|
||||
|
||||
// Load known assets that are direct children of this directory. Soft-deleted assets are included
|
||||
// so a file that still exists on disk is reconciled (or left deleted) instead of re-inserted,
|
||||
// which would violate the unique index on OriginalPath. The path prefix (not FolderId) is what
|
||||
// scopes the lookup — OriginalPath is globally unique, so a row can exist under any folder.
|
||||
var known = (await assetDb.Assets.AsNoTracking()
|
||||
.Where(a => a.OriginalPath.CompareTo(lo) >= 0 && a.OriginalPath.CompareTo(hi) < 0)
|
||||
.ToListAsync(token))
|
||||
.Where(a => !a.OriginalPath.AsSpan(prefix.Length).Contains(Path.DirectorySeparatorChar))
|
||||
.ToDictionary(a => a.OriginalPath, StringComparer.Ordinal);
|
||||
|
||||
// Exact-path fallback: OriginalPath is globally unique, so a row can exist under a different
|
||||
// folder (e.g. a recreated folder with a new ID) or fall outside the range query above.
|
||||
// Such files must be reconciled, never re-inserted.
|
||||
var unmatched = files
|
||||
.Select(f => f.FullName)
|
||||
.Where(p => !known.ContainsKey(p))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
foreach (var chunk in unmatched.Chunk(1000)) {
|
||||
var existing = await assetDb.Assets.AsNoTracking()
|
||||
.Where(a => chunk.Contains(a.OriginalPath))
|
||||
.ToListAsync(token);
|
||||
foreach (var a in existing)
|
||||
known.TryAdd(a.OriginalPath, a);
|
||||
}
|
||||
|
||||
int added = 0, updated = 0, deleted = 0;
|
||||
|
||||
foreach (var file in files) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (known.TryGetValue(file.FullName, out var asset)) {
|
||||
known.Remove(file.FullName);
|
||||
if (file.LastWriteTimeUtc > (asset.UpdatedAt ?? asset.CreatedAt)) {
|
||||
assetDb.Attach(asset);
|
||||
asset.FileSize = file.Length;
|
||||
asset.UpdatedAt = file.LastWriteTimeUtc;
|
||||
if (asset.UploadedBy == null && uploaderId.HasValue) asset.UploadedBy = uploaderId;
|
||||
updated++;
|
||||
} else if (asset.UploadedBy == null && uploaderId.HasValue) {
|
||||
assetDb.Attach(asset);
|
||||
asset.UploadedBy = uploaderId;
|
||||
updated++;
|
||||
}
|
||||
} else {
|
||||
var newAsset = AssetFromPath(file.FullName, uploaderId);
|
||||
if (newAsset != null) {
|
||||
assetDb.Assets.Add(newAsset);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var gone in known.Values) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (gone.DeletedAt != null) continue; // already soft-deleted
|
||||
assetDb.Attach(gone);
|
||||
gone.DeletedAt = DateTime.UtcNow;
|
||||
deleted++;
|
||||
}
|
||||
|
||||
if (added + updated + deleted > 0) {
|
||||
try {
|
||||
await assetDb.SaveChangesAsync(token);
|
||||
} catch (DbUpdateException ex) when (IsDuplicateKey(ex)) {
|
||||
// Last-resort safety net for genuine races (e.g. two overlapping folders inserting the
|
||||
// same new path concurrently). The batch is dropped; the file is found next run.
|
||||
logger.LogDebug(ex, "Dropped {Count} asset change(s) in {Path} due to a duplicate asset path.",
|
||||
added + updated + deleted, dirPath);
|
||||
report(0, 0, 0);
|
||||
return false;
|
||||
} finally {
|
||||
assetDb.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
report(added, updated, deleted);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool IsDuplicateKey(DbUpdateException ex) =>
|
||||
ex.InnerException is PostgresException { SqlState: "23505" }
|
||||
|| ex.InnerException?.InnerException is PostgresException { SqlState: "23505" };
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="Asset"/> from a file path, or null when the extension is not a supported media type.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The full path of the file.</param>
|
||||
/// <param name="uploaderId">The uploader id to assign, if any.</param>
|
||||
/// <returns>A new asset, or null if the file type is not supported.</returns>
|
||||
Asset? AssetFromPath(string filePath, Guid? uploaderId) {
|
||||
logger.LogTrace("Loading asset from file path {filePath}", filePath);
|
||||
// Get the file info
|
||||
var finfo = new FileInfo(filePath);
|
||||
|
||||
// Determine the type of the file
|
||||
string ext = finfo.Extension.ToLower();
|
||||
string ext = finfo.Extension.ToLower();
|
||||
EAssetType? type;
|
||||
|
||||
// ReSharper disable ArrangeObjectCreationWhenTypeNotEvident
|
||||
var mimeImg = MimeTypes.Image.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
|
||||
var mimeVid = MimeTypes.Video.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
|
||||
|
||||
if (mimeImg.MimeType != Empty) type = EAssetType.Image;
|
||||
else if (mimeVid.MimeType != Empty) type = EAssetType.Video;
|
||||
if (mimeVid.MimeType != Empty) type = EAssetType.Video;
|
||||
else if (mimeImg.MimeType != Empty) type = EAssetType.Image;
|
||||
else return null;
|
||||
|
||||
// Create a new asset
|
||||
var asset = new Asset() {
|
||||
return new Asset {
|
||||
FolderId = folderId,
|
||||
OriginalPath = finfo.FullName,
|
||||
OriginalFilename = finfo.Name,
|
||||
Type = type.Value,
|
||||
Visibility = EVisibility.Private,
|
||||
Visibility = EVisibility.Private,
|
||||
UploadedBy = uploaderId,
|
||||
CreatedAt = finfo.CreationTimeUtc,
|
||||
UpdatedAt = finfo.LastWriteTimeUtc,
|
||||
MimeType = type.Value switch {
|
||||
IngestedAt = DateTime.UtcNow,
|
||||
MimeType = type.Value switch {
|
||||
EAssetType.Image => mimeImg.MimeType,
|
||||
EAssetType.Video => mimeVid.MimeType,
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
@@ -206,18 +318,5 @@ public sealed class FileSystemCrawlJob : Job {
|
||||
FileSize = finfo.Length,
|
||||
Hash = new BitArray(64)
|
||||
};
|
||||
|
||||
logger.LogDebug(
|
||||
$"""
|
||||
Asset created from path: {asset.OriginalPath}
|
||||
Data:
|
||||
- Filename: {asset.OriginalFilename}
|
||||
- Type: {asset.Type}
|
||||
- MimeType: {asset.MimeType}
|
||||
- CreatedAt: {asset.CreatedAt}
|
||||
- UpdatedAt: {asset.UpdatedAt}
|
||||
- FileSize: {asset.FileSize} bytes
|
||||
""");
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
+292
-106
@@ -1,7 +1,8 @@
|
||||
using Butter.Settings;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Utils;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
@@ -9,129 +10,188 @@ namespace Lactose.Jobs;
|
||||
/// Performs a comprehensive integrity check of the system — database, assets, albums, people, thumbnails, and previews.
|
||||
/// </summary>
|
||||
public class IntegrityCheckJob (ILogger<IntegrityCheckJob> logger, IServiceProvider serviceProvider): Job{
|
||||
const float totalSteps = 7;
|
||||
const float totalSteps = 8;
|
||||
const int LiveProgressEvery = 50_000;
|
||||
const int BatchSize = 1000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Integrity Check Job";
|
||||
|
||||
void IncrementProgress(float f) => JobStatus.UpdateProgress(f/totalSteps, "Database connection successful.");
|
||||
/// <summary>
|
||||
/// Advances overall progress to the given step and surfaces a meaningful status message in the web UI.
|
||||
/// </summary>
|
||||
/// <param name="step">The completed step number (1-based).</param>
|
||||
/// <param name="message">A human-readable status message.</param>
|
||||
void ReportStep(int step, string message) => JobStatus.UpdateProgress(step / totalSteps, message);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task TaskJob(CancellationToken token) {
|
||||
try {
|
||||
|
||||
float progress = 0;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
logger.LogInformation("Starting integrity check job...");
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
|
||||
|
||||
//Step 1
|
||||
// Check database connection
|
||||
// Step 1 — Database connection
|
||||
logger.LogInformation("[1/8] Checking database connection...");
|
||||
if (!dbContext.Database.CanConnect()) {
|
||||
logger.LogError("Cannot connect to the database.");
|
||||
JobStatus.Fail("Cannot connect to the database.");
|
||||
return;
|
||||
}
|
||||
IncrementProgress(++progress);
|
||||
logger.LogInformation("[1/8] Database connection successful.");
|
||||
ReportStep(1, "Database connection successful.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Step 2
|
||||
// Check for pending migrations
|
||||
// Step 2 — Pending migrations
|
||||
logger.LogInformation("[2/8] Checking for pending migrations...");
|
||||
var pendingMigrations = dbContext.Database.GetPendingMigrations().ToList();
|
||||
|
||||
if (pendingMigrations.Any()) {
|
||||
logger.LogWarning("There are pending migrations:");
|
||||
logger.LogWarning("Found {Count} pending migrations:", pendingMigrations.Count);
|
||||
|
||||
foreach (var migration in pendingMigrations) logger.LogWarning($"- {migration}");
|
||||
|
||||
// Apply migrations
|
||||
try {
|
||||
dbContext.Database.Migrate();
|
||||
logger.LogInformation("Applying {Count} pending migrations...", pendingMigrations.Count);
|
||||
await dbContext.Database.MigrateAsync(token);
|
||||
logger.LogInformation("Applied pending migrations successfully.");
|
||||
} catch (OperationCanceledException) {
|
||||
throw;
|
||||
} catch (Exception ex) {
|
||||
logger.LogError(ex, "Error applying migrations.");
|
||||
JobStatus.Fail("Error applying migrations.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
logger.LogInformation("[2/8] No pending migrations.");
|
||||
}
|
||||
IncrementProgress(++progress);
|
||||
ReportStep(2, "Migrations up to date.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// 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()) {
|
||||
// Step 3 — Folder existence
|
||||
logger.LogInformation("[3/8] Checking folder existence...");
|
||||
await dbContext.Folders.LoadAsync(token);
|
||||
var folders = await dbContext.Folders.ToListAsync(token);
|
||||
logger.LogInformation("[3/8] Loaded {Count} folders.", folders.Count);
|
||||
int missingFolders = 0;
|
||||
int missingFolderAssets = 0;
|
||||
var lastFolderReport = DateTime.UtcNow;
|
||||
for (int i = 0; i < folders.Count; i++) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!Directory.Exists(folder.BasePath)) {
|
||||
logger.LogWarning($"Folder {folder.Id} does not exist. Deactivating.");
|
||||
folder.Active = false;
|
||||
dbContext.Folders.Update(folder);
|
||||
|
||||
var affectedAssets = dbContext.Assets.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.ToList();
|
||||
foreach (var asset in affectedAssets) {
|
||||
asset.DeletedAt = DateTime.UtcNow;
|
||||
dbContext.Assets.Update(asset);
|
||||
var folder = folders[i];
|
||||
if (Directory.Exists(folder.BasePath)) {
|
||||
if ((i + 1) % LiveProgressEvery == 0 || DateTime.UtcNow - lastFolderReport > TimeSpan.FromSeconds(5)) {
|
||||
lastFolderReport = DateTime.UtcNow;
|
||||
logger.LogInformation("[3/8] Verified {Processed}/{Total} folders ({Missing} missing so far).", i + 1, folders.Count, missingFolders);
|
||||
JobStatus.UpdateProgress(
|
||||
(2f + (float)(i + 1) / folders.Count) / totalSteps,
|
||||
$"Checking folders: {i + 1:N0}/{folders.Count:N0} (missing {missingFolders:N0})");
|
||||
}
|
||||
dbContext.SaveChanges();
|
||||
logger.LogWarning("Soft-deleted {Count} assets associated with missing folder {FolderId}.", affectedAssets.Count, folder.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
missingFolders++;
|
||||
logger.LogWarning("Folder {FolderId} ({Path}) does not exist on disk. Deactivating.", folder.Id, folder.BasePath);
|
||||
folder.Active = false;
|
||||
dbContext.Folders.Update(folder);
|
||||
|
||||
var affectedAssets = await dbContext.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.ToListAsync(token);
|
||||
foreach (var asset in affectedAssets) {
|
||||
asset.DeletedAt = DateTime.UtcNow;
|
||||
dbContext.Assets.Update(asset);
|
||||
}
|
||||
missingFolderAssets += affectedAssets.Count;
|
||||
await dbContext.SaveChangesAsync(token);
|
||||
logger.LogWarning("Soft-deleted {Count} assets associated with missing folder {FolderId}.", affectedAssets.Count, folder.Id);
|
||||
}
|
||||
logger.LogInformation("[3/8] Folders checked: {Missing} missing, {Assets} assets soft-deleted.", missingFolders, missingFolderAssets);
|
||||
ReportStep(3, $"Folders checked: {missingFolders} missing.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Step 4 — Asset existence (streamed via keyset pagination, never loading the whole table)
|
||||
logger.LogInformation("[4/8] Checking asset existence...");
|
||||
var totalLive = await dbContext.Assets.CountAsync(a => a.DeletedAt == null, token);
|
||||
logger.LogInformation("[4/8] {Count} live assets to verify.", totalLive);
|
||||
|
||||
Guid lastId = Guid.Empty;
|
||||
long processed = 0;
|
||||
int deletedAssets = 0;
|
||||
var lastReport = DateTime.UtcNow;
|
||||
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var batch = await dbContext.Assets.AsNoTracking()
|
||||
.Where(a => a.DeletedAt == null && a.Id > lastId)
|
||||
.OrderBy(a => a.Id)
|
||||
.Take(BatchSize)
|
||||
.Select(a => new { a.Id, a.OriginalPath })
|
||||
.ToListAsync(token);
|
||||
|
||||
if (batch.Count == 0) break;
|
||||
|
||||
var missingIds = new List<Guid>(batch.Count);
|
||||
foreach (var item in batch) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!File.Exists(item.OriginalPath)) {
|
||||
missingIds.Add(item.Id);
|
||||
logger.LogWarning("Asset {AssetId} ({Path}) does not exist. Soft-deleting.", item.Id, item.OriginalPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingIds.Count > 0) {
|
||||
await dbContext.Assets
|
||||
.Where(a => missingIds.Contains(a.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.DeletedAt, DateTime.UtcNow), token);
|
||||
deletedAssets += missingIds.Count;
|
||||
}
|
||||
|
||||
lastId = batch[^1].Id;
|
||||
processed += batch.Count;
|
||||
|
||||
if (processed % LiveProgressEvery == 0 || DateTime.UtcNow - lastReport > TimeSpan.FromSeconds(5)) {
|
||||
lastReport = DateTime.UtcNow;
|
||||
logger.LogInformation("[4/8] Verified {Processed}/{Total} assets ({Missing} missing so far).", processed, totalLive, deletedAssets);
|
||||
JobStatus.UpdateProgress(
|
||||
(3f + (float)processed / Math.Max(totalLive, 1)) / totalSteps,
|
||||
$"Checking assets: {processed:N0}/{totalLive:N0} (missing {deletedAssets:N0})");
|
||||
}
|
||||
}
|
||||
IncrementProgress(++progress);
|
||||
|
||||
logger.LogInformation("[4/8] Assets checked: {Deleted} soft-deleted.", deletedAssets);
|
||||
ReportStep(4, $"Assets checked: {deletedAssets:N0} missing.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Step 4
|
||||
// Check for assets existence
|
||||
dbContext.Assets.Load();
|
||||
foreach (Asset asset in dbContext.Assets.Where(a => a.DeletedAt == null)) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (File.Exists(asset.OriginalPath)) continue;
|
||||
asset.DeletedAt = DateTime.UtcNow;
|
||||
dbContext.Assets.Update(asset);
|
||||
logger.LogWarning($"Asset {asset.Id} does not exist. Soft-deleting.");
|
||||
}
|
||||
|
||||
int changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Soft-deleted {Changes} non-existent assets.", changes);
|
||||
IncrementProgress(++progress);
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Step 5
|
||||
// Check for empty albums
|
||||
dbContext.Albums.Load();
|
||||
dbContext.Albums.Include(a => a.Assets)
|
||||
// Step 5 — Empty albums
|
||||
logger.LogInformation("[5/8] Checking for empty albums...");
|
||||
var emptyAlbums = await 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);
|
||||
.ToListAsync(token);
|
||||
foreach (var album in emptyAlbums) {
|
||||
logger.LogWarning("Album {AlbumId} ({Title}) is empty. Removing from database.", album.Id, album.Title);
|
||||
dbContext.Albums.Remove(album);
|
||||
}
|
||||
await dbContext.SaveChangesAsync(token);
|
||||
logger.LogInformation("[5/8] Removed {Count} empty albums.", emptyAlbums.Count);
|
||||
ReportStep(5, $"Empty albums removed: {emptyAlbums.Count:N0}.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Step 6
|
||||
// Check for orphaned thumbnails
|
||||
dbContext.Settings.Load();
|
||||
dbContext.Assets.Load();
|
||||
// Step 6 — Orphaned thumbnails (streamed via keyset pagination)
|
||||
logger.LogInformation("[6/8] Checking thumbnails...");
|
||||
await dbContext.Settings.LoadAsync(token);
|
||||
var thumbnailPath = dbContext.Settings.First(s => s.Name == Settings.ThumbnailPath.AsString()).Value;
|
||||
|
||||
int missingThumbnails;
|
||||
if (!Directory.Exists(thumbnailPath)) {
|
||||
logger.LogWarning("Thumbnail path does not exist. Creating...");
|
||||
logger.LogWarning("Thumbnail path does not exist at {Path}. Creating...", thumbnailPath);
|
||||
|
||||
try {
|
||||
if (thumbnailPath != null)
|
||||
@@ -141,31 +201,68 @@ public class IntegrityCheckJob (ILogger<IntegrityCheckJob> logger, IServiceProvi
|
||||
}
|
||||
|
||||
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);
|
||||
missingThumbnails = await dbContext.Assets
|
||||
.Where(a => !string.IsNullOrEmpty(a.ThumbnailPath))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ThumbnailPath, ""), token);
|
||||
} 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);
|
||||
}
|
||||
var totalWithThumb = await dbContext.Assets.CountAsync(a => !string.IsNullOrEmpty(a.ThumbnailPath), token);
|
||||
missingThumbnails = 0;
|
||||
Guid lastThumbId = Guid.Empty;
|
||||
long thumbProcessed = 0;
|
||||
var lastThumbReport = DateTime.UtcNow;
|
||||
|
||||
IncrementProgress(++progress);
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var batch = await dbContext.Assets.AsNoTracking()
|
||||
.Where(a => !string.IsNullOrEmpty(a.ThumbnailPath) && a.Id > lastThumbId)
|
||||
.OrderBy(a => a.Id)
|
||||
.Take(BatchSize)
|
||||
.Select(a => new { a.Id, a.ThumbnailPath })
|
||||
.ToListAsync(token);
|
||||
|
||||
if (batch.Count == 0) break;
|
||||
|
||||
var missingIds = new List<Guid>(batch.Count);
|
||||
foreach (var item in batch) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!File.Exists(item.ThumbnailPath)) {
|
||||
missingIds.Add(item.Id);
|
||||
logger.LogWarning("Asset {AssetId} has a missing thumbnail at {Path}. Marking as missing.", item.Id, item.ThumbnailPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingIds.Count > 0) {
|
||||
await dbContext.Assets
|
||||
.Where(a => missingIds.Contains(a.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ThumbnailPath, ""), token);
|
||||
missingThumbnails += missingIds.Count;
|
||||
}
|
||||
|
||||
lastThumbId = batch[^1].Id;
|
||||
thumbProcessed += batch.Count;
|
||||
|
||||
if (thumbProcessed % LiveProgressEvery == 0 || DateTime.UtcNow - lastThumbReport > TimeSpan.FromSeconds(5)) {
|
||||
lastThumbReport = DateTime.UtcNow;
|
||||
logger.LogInformation("[6/8] Verified {Processed}/{Total} thumbnails ({Missing} missing so far).", thumbProcessed, totalWithThumb, missingThumbnails);
|
||||
JobStatus.UpdateProgress(
|
||||
(5f + (float)thumbProcessed / Math.Max(totalWithThumb, 1)) / totalSteps,
|
||||
$"Checking thumbnails: {thumbProcessed:N0}/{totalWithThumb:N0} (missing {missingThumbnails:N0})");
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.LogInformation("[6/8] Thumbnails checked: {Count} marked as missing.", missingThumbnails);
|
||||
ReportStep(6, $"Thumbnails: {missingThumbnails:N0} missing.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Step 7
|
||||
// Check for orphaned previews
|
||||
|
||||
// Step 7 — Orphaned previews (streamed via keyset pagination)
|
||||
logger.LogInformation("[7/8] Checking previews...");
|
||||
var previewPath = dbContext.Settings.First(s => s.Name == Settings.PreviewPath.AsString()).Value;
|
||||
|
||||
int missingPreviews;
|
||||
if (!Directory.Exists(previewPath)) {
|
||||
logger.LogWarning("Preview path does not exist. Creating...");
|
||||
logger.LogWarning("Preview path does not exist at {Path}. Creating...", previewPath);
|
||||
|
||||
try {
|
||||
if (previewPath != null)
|
||||
@@ -175,25 +272,114 @@ public class IntegrityCheckJob (ILogger<IntegrityCheckJob> logger, IServiceProvi
|
||||
}
|
||||
|
||||
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);
|
||||
missingPreviews = await dbContext.Assets
|
||||
.Where(a => !string.IsNullOrEmpty(a.PreviewPath))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.PreviewPath, ""), token);
|
||||
} 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);
|
||||
});
|
||||
var totalWithPreview = await dbContext.Assets.CountAsync(a => !string.IsNullOrEmpty(a.PreviewPath), token);
|
||||
missingPreviews = 0;
|
||||
Guid lastPreviewId = Guid.Empty;
|
||||
long previewProcessed = 0;
|
||||
var lastPreviewReport = DateTime.UtcNow;
|
||||
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var batch = await dbContext.Assets.AsNoTracking()
|
||||
.Where(a => !string.IsNullOrEmpty(a.PreviewPath) && a.Id > lastPreviewId)
|
||||
.OrderBy(a => a.Id)
|
||||
.Take(BatchSize)
|
||||
.Select(a => new { a.Id, a.PreviewPath })
|
||||
.ToListAsync(token);
|
||||
|
||||
if (batch.Count == 0) break;
|
||||
|
||||
var missingIds = new List<Guid>(batch.Count);
|
||||
foreach (var item in batch) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!File.Exists(item.PreviewPath)) {
|
||||
missingIds.Add(item.Id);
|
||||
logger.LogWarning("Asset {AssetId} has a missing preview at {Path}. Marking as missing.", item.Id, item.PreviewPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingIds.Count > 0) {
|
||||
await dbContext.Assets
|
||||
.Where(a => missingIds.Contains(a.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.PreviewPath, ""), token);
|
||||
missingPreviews += missingIds.Count;
|
||||
}
|
||||
|
||||
lastPreviewId = batch[^1].Id;
|
||||
previewProcessed += batch.Count;
|
||||
|
||||
if (previewProcessed % LiveProgressEvery == 0 || DateTime.UtcNow - lastPreviewReport > TimeSpan.FromSeconds(5)) {
|
||||
lastPreviewReport = DateTime.UtcNow;
|
||||
logger.LogInformation("[7/8] Verified {Processed}/{Total} previews ({Missing} missing so far).", previewProcessed, totalWithPreview, missingPreviews);
|
||||
JobStatus.UpdateProgress(
|
||||
(6f + (float)previewProcessed / Math.Max(totalWithPreview, 1)) / totalSteps,
|
||||
$"Checking previews: {previewProcessed:N0}/{totalWithPreview:N0} (missing {missingPreviews:N0})");
|
||||
}
|
||||
}
|
||||
}
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Marked {Changes} assets as missing previews.", changes);
|
||||
logger.LogInformation("[7/8] Previews checked: {Count} marked as missing.", missingPreviews);
|
||||
ReportStep(7, $"Previews: {missingPreviews:N0} missing.");
|
||||
|
||||
IncrementProgress(++progress);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Complete
|
||||
logger.LogInformation("Integrity check completed successfully.");
|
||||
// Step 8 — Orphaned converted videos (streamed via keyset pagination)
|
||||
logger.LogInformation("[8/8] Checking converted videos...");
|
||||
var totalWithConverted = await dbContext.Assets.CountAsync(a => !string.IsNullOrEmpty(a.ConvertedPath), token);
|
||||
int missingConverted = 0;
|
||||
Guid lastConvertedId = Guid.Empty;
|
||||
long convertedProcessed = 0;
|
||||
var lastConvertedReport = DateTime.UtcNow;
|
||||
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var batch = await dbContext.Assets.AsNoTracking()
|
||||
.Where(a => !string.IsNullOrEmpty(a.ConvertedPath) && a.Id > lastConvertedId)
|
||||
.OrderBy(a => a.Id)
|
||||
.Take(BatchSize)
|
||||
.Select(a => new { a.Id, a.ConvertedPath })
|
||||
.ToListAsync(token);
|
||||
|
||||
if (batch.Count == 0) break;
|
||||
|
||||
var missingIds = new List<Guid>(batch.Count);
|
||||
foreach (var item in batch) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!File.Exists(item.ConvertedPath)) {
|
||||
missingIds.Add(item.Id);
|
||||
logger.LogWarning("Asset {AssetId} has a missing converted video at {Path}. Marking as missing.", item.Id, item.ConvertedPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingIds.Count > 0) {
|
||||
await dbContext.Assets
|
||||
.Where(a => missingIds.Contains(a.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ConvertedPath, ""), token);
|
||||
missingConverted += missingIds.Count;
|
||||
}
|
||||
|
||||
lastConvertedId = batch[^1].Id;
|
||||
convertedProcessed += batch.Count;
|
||||
|
||||
if (convertedProcessed % LiveProgressEvery == 0 || DateTime.UtcNow - lastConvertedReport > TimeSpan.FromSeconds(5)) {
|
||||
lastConvertedReport = DateTime.UtcNow;
|
||||
logger.LogInformation("[8/8] Verified {Processed}/{Total} converted videos ({Missing} missing so far).", convertedProcessed, totalWithConverted, missingConverted);
|
||||
JobStatus.UpdateProgress(
|
||||
(7f + (float)convertedProcessed / Math.Max(totalWithConverted, 1)) / totalSteps,
|
||||
$"Checking converted videos: {convertedProcessed:N0}/{totalWithConverted:N0} (missing {missingConverted:N0})");
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("[8/8] Converted videos checked: {Count} marked as missing.", missingConverted);
|
||||
|
||||
stopwatch.Stop();
|
||||
logger.LogInformation("Integrity check completed successfully in {Elapsed}.",
|
||||
stopwatch.Elapsed.ToString(@"h\:mm\:ss"));
|
||||
JobStatus.Complete("Integrity check completed successfully.");
|
||||
} catch (OperationCanceledException) {
|
||||
JobStatus.Cancel();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for background processing jobs.
|
||||
/// </summary>
|
||||
internal static class JobHelpers {
|
||||
/// <summary>
|
||||
/// Marks an asset as broken (processing failed) so it is excluded from re-processing
|
||||
/// until an admin explicitly retries it.
|
||||
/// </summary>
|
||||
/// <param name="asset">The asset to mark broken.</param>
|
||||
/// <param name="message">A human-readable error message describing the failure.</param>
|
||||
internal static void MarkBroken(Asset asset, string message) {
|
||||
asset.ProcessFailedAt = DateTime.UtcNow;
|
||||
asset.ProcessErrorMessage = message;
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -35,10 +35,10 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
/// <summary>
|
||||
/// Returns a page of past root jobs from the database.
|
||||
/// </summary>
|
||||
public (List<JobStatusDto> Jobs, int TotalCount) GetPastRootJobs(int page, int pageSize) {
|
||||
public async Task<(List<JobStatusDto> Jobs, int TotalCount)> GetPastRootJobsAsync(int page, int pageSize, CancellationToken cancellationToken) {
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
|
||||
var records = repo.GetPastRootJobs(page, pageSize, out var total);
|
||||
var (records, total) = await repo.GetPastRootJobsAsync(page, pageSize, cancellationToken);
|
||||
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);
|
||||
}
|
||||
@@ -49,7 +49,8 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
/// </summary>
|
||||
/// <param name="parentId">The parent job ID.</param>
|
||||
/// <param name="since">If set, only children changed after this timestamp are returned. Null returns all children.</param>
|
||||
public List<JobStatusDto> GetChildren(Guid parentId, DateTime? since) {
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
public async Task<List<JobStatusDto>> GetChildrenAsync(Guid parentId, DateTime? since, CancellationToken cancellationToken) {
|
||||
var seenIds = new HashSet<Guid>();
|
||||
|
||||
// Active children still in memory
|
||||
@@ -71,8 +72,8 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
|
||||
var records = since is null
|
||||
? repo.GetChildren(parentId)
|
||||
: repo.GetChildrenModifiedSince(parentId, since.Value);
|
||||
? await repo.GetChildrenAsync(parentId, cancellationToken)
|
||||
: await repo.GetChildrenModifiedSinceAsync(parentId, since.Value, cancellationToken);
|
||||
results.AddRange(records.Where(r => !seenIds.Contains(r.Id)).Select(r => r.ToJobStatusDto()));
|
||||
} catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Could not load children for parent {ParentId}", parentId);
|
||||
@@ -178,14 +179,14 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
}
|
||||
|
||||
///<inheritdoc />
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||
logger.LogInformation("Preparing JobManager...");
|
||||
|
||||
// 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()) {
|
||||
foreach (var record in await repo.GetAllAsync(stoppingToken)) {
|
||||
pastJobs.TryAdd(record.Id, record.ToJobStatusDto());
|
||||
}
|
||||
logger.LogInformation($"Loaded {pastJobs.Count} past job(s) from database.");
|
||||
@@ -197,7 +198,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
try {
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var settingsRepo = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
|
||||
var maxJobsSetting = settingsRepo.Get(Settings.MaxConcurrentJobs.AsString());
|
||||
var maxJobsSetting = await settingsRepo.GetAsync(Settings.MaxConcurrentJobs.AsString(), stoppingToken);
|
||||
if (maxJobsSetting != null && int.TryParse(maxJobsSetting.Value, out var maxJobs) && maxJobs > 0) {
|
||||
MaxConcurrentJobs = maxJobs;
|
||||
logger.LogInformation("Loaded MaxConcurrentJobs from settings: {Value}", MaxConcurrentJobs);
|
||||
@@ -227,7 +228,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
}
|
||||
|
||||
foreach (var job in queued) {
|
||||
job.Done += (sender, status) => {
|
||||
job.Done += async (sender, status) => {
|
||||
var j = (Job)sender!;
|
||||
var dto = j.ToJobStatusDto();
|
||||
|
||||
@@ -247,7 +248,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
Progress = dto.Progress
|
||||
};
|
||||
repo.Insert(record);
|
||||
repo.Save();
|
||||
await repo.SaveAsync(CancellationToken.None);
|
||||
} catch (Exception ex) {
|
||||
logger.LogError(ex, "Failed to persist job {JobId}", status.Id);
|
||||
}
|
||||
@@ -266,6 +267,6 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
);
|
||||
|
||||
lifetime.ApplicationStarted.Register(() => service.Start());
|
||||
return service;
|
||||
await service;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +55,11 @@ public sealed class MetadataJob : Job {
|
||||
if (ParentJob == null) {
|
||||
await MasterJob(token);
|
||||
} else {
|
||||
SlaveJob(token);
|
||||
await SlaveJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
void SlaveJob(CancellationToken token) {
|
||||
async Task SlaveJob(CancellationToken token) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
@@ -84,7 +84,9 @@ public sealed class MetadataJob : Job {
|
||||
|
||||
try {
|
||||
if (string.IsNullOrEmpty(asset.OriginalPath) || !File.Exists(asset.OriginalPath)) {
|
||||
Logger.LogWarning($"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}");
|
||||
var errorMsg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
|
||||
Logger.LogWarning(errorMsg);
|
||||
JobHelpers.MarkBroken(asset, errorMsg);
|
||||
failedAssets++;
|
||||
continue;
|
||||
}
|
||||
@@ -95,12 +97,13 @@ public sealed class MetadataJob : Job {
|
||||
processedAssets++;
|
||||
} catch (Exception ex) {
|
||||
Logger.LogError(ex, $"Failed to extract metadata for asset ID {asset.Id}");
|
||||
JobHelpers.MarkBroken(asset, $"Metadata extraction failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
}
|
||||
}
|
||||
|
||||
AssetRepository.UpdateBulk(Batch);
|
||||
AssetRepository.Save();
|
||||
await AssetRepository.SaveAsync(token);
|
||||
|
||||
var msg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
||||
Logger.LogInformation(msg);
|
||||
@@ -123,11 +126,11 @@ public sealed class MetadataJob : Job {
|
||||
JobStatus.Start();
|
||||
|
||||
batchSize = 200;
|
||||
var batchSizeSetting = SettingsRepository?.Get(Settings.JobBatchSize.AsString());
|
||||
var batchSizeSetting = SettingsRepository is null ? null : await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
|
||||
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
|
||||
batchSize = Math.Max(1, parsed);
|
||||
|
||||
totalAssets = AssetRepository.CountAssetsMissingMetadata();
|
||||
totalAssets = await AssetRepository.CountAssetsMissingMetadataAsync(token);
|
||||
Logger.LogInformation("Found {Count} image assets missing metadata.", totalAssets);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
@@ -139,7 +142,7 @@ public sealed class MetadataJob : Job {
|
||||
int pending = 0;
|
||||
|
||||
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
||||
var batch = AssetRepository.GetAssetsMissingMetadata(batchSize, offset);
|
||||
var batch = await AssetRepository.GetAssetsMissingMetadataAsync(batchSize, offset, token);
|
||||
var batchList = batch.ToList();
|
||||
|
||||
if (batchList.Count == 0) continue;
|
||||
|
||||
@@ -58,11 +58,11 @@ public sealed class PHashJob : Job {
|
||||
if (ParentJob == null) {
|
||||
await MasterJob(token);
|
||||
} else {
|
||||
SlaveJob(token);
|
||||
await SlaveJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
void SlaveJob(CancellationToken token) {
|
||||
async Task SlaveJob(CancellationToken token) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
@@ -93,24 +93,28 @@ public sealed class PHashJob : Job {
|
||||
} catch (ArgumentNullException ex) {
|
||||
Logger.LogError(ex, $"Image at path {asset.OriginalPath} could not be found. Setting hash to 0.");
|
||||
asset.Hash = new BitArray(64);
|
||||
JobHelpers.MarkBroken(asset, $"pHash failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
} catch (UnknownImageFormatException ex) {
|
||||
Logger.LogError(ex, $"Image at path {asset.OriginalPath} is in an unknown or unsupported format.");
|
||||
asset.Hash = new BitArray(64);
|
||||
JobHelpers.MarkBroken(asset, $"pHash failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
} catch (InvalidImageContentException ex) {
|
||||
Logger.LogError(ex, $"Image at path {asset.OriginalPath} is corrupted or unreadable.");
|
||||
asset.Hash = new BitArray(64);
|
||||
JobHelpers.MarkBroken(asset, $"pHash failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
} catch (Exception ex) {
|
||||
Logger.LogError(ex, $"Failed to calculate pHash for asset {asset.Id} | {asset.OriginalPath}.");
|
||||
asset.Hash = new BitArray(64);
|
||||
JobHelpers.MarkBroken(asset, $"pHash failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
}
|
||||
}
|
||||
|
||||
AssetRepository.UpdateBulk(Batch);
|
||||
AssetRepository.Save();
|
||||
await AssetRepository.SaveAsync(token);
|
||||
|
||||
var msg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
||||
Logger.LogInformation(msg);
|
||||
@@ -133,11 +137,11 @@ public sealed class PHashJob : Job {
|
||||
JobStatus.Start();
|
||||
|
||||
batchSize = 200;
|
||||
var batchSizeSetting = SettingsRepository?.Get(Settings.JobBatchSize.AsString());
|
||||
var batchSizeSetting = SettingsRepository is null ? null : await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
|
||||
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
|
||||
batchSize = Math.Max(1, parsed);
|
||||
|
||||
totalAssets = AssetRepository.CountAssetsMissingPHash();
|
||||
totalAssets = await AssetRepository.CountAssetsMissingPHashAsync(token);
|
||||
Logger.LogInformation("Found {Count} assets missing pHash.", totalAssets);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
@@ -149,7 +153,7 @@ public sealed class PHashJob : Job {
|
||||
int pending = 0;
|
||||
|
||||
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
||||
var batch = AssetRepository.GetAssetsMissingPHash(batchSize, offset);
|
||||
var batch = await AssetRepository.GetAssetsMissingPHashAsync(batchSize, offset, token);
|
||||
var batchList = batch.ToList();
|
||||
|
||||
if (batchList.Count == 0) continue;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
static class PathUtils {
|
||||
internal static string PathFromGuid(Guid id, string root, string extension = "webp") {
|
||||
var s = id.ToString("N");
|
||||
|
||||
return Path.Combine(
|
||||
root,
|
||||
s[..2],
|
||||
s.Substring(2, 2),
|
||||
s.Substring(4, 2),
|
||||
$"{s}.{extension}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
-22
@@ -82,11 +82,11 @@ public class PreviewJob : Job {
|
||||
if (ParentJob == null) {
|
||||
await MasterJob(token);
|
||||
} else {
|
||||
SlaveJob(token);
|
||||
await SlaveJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
void SlaveJob(CancellationToken token) {
|
||||
async Task SlaveJob(CancellationToken token) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
@@ -119,6 +119,7 @@ public class PreviewJob : Job {
|
||||
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);
|
||||
JobHelpers.MarkBroken(asset, msg);
|
||||
failedAssets++;
|
||||
continue;
|
||||
}
|
||||
@@ -140,7 +141,7 @@ public class PreviewJob : Job {
|
||||
)
|
||||
);
|
||||
|
||||
var path = PathFromGuid(asset.Id, PreviewPath!);
|
||||
var path = PathUtils.PathFromGuid(asset.Id, PreviewPath!);
|
||||
|
||||
if (!Directory.Exists(Path.GetDirectoryName(path)))
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
@@ -159,12 +160,13 @@ public class PreviewJob : Job {
|
||||
processedAssets++;
|
||||
} catch (Exception ex) {
|
||||
Logger.LogError(ex, $"Failed to generate preview for asset ID {asset.Id}");
|
||||
JobHelpers.MarkBroken(asset, $"Preview generation failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
}
|
||||
}
|
||||
|
||||
AssetRepository.UpdateBulk(Batch);
|
||||
AssetRepository.Save();
|
||||
await AssetRepository.SaveAsync(token);
|
||||
|
||||
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
||||
Logger.LogInformation(resultMsg);
|
||||
@@ -187,10 +189,10 @@ public class PreviewJob : Job {
|
||||
JobStatus.Start();
|
||||
Logger.LogInformation("Starting master Preview job.");
|
||||
|
||||
var previewPathSetting = SettingsRepository.Get(Settings.PreviewPath.AsString());
|
||||
var previewSizeSetting = SettingsRepository.Get(Settings.PreviewSize.AsString());
|
||||
var previewQualitySetting = SettingsRepository.Get(Settings.PreviewQuality.AsString());
|
||||
var batchSizeSetting = SettingsRepository.Get(Settings.JobBatchSize.AsString());
|
||||
var previewPathSetting = await SettingsRepository.GetAsync(Settings.PreviewPath.AsString(), token);
|
||||
var previewSizeSetting = await SettingsRepository.GetAsync(Settings.PreviewSize.AsString(), token);
|
||||
var previewQualitySetting = await SettingsRepository.GetAsync(Settings.PreviewQuality.AsString(), token);
|
||||
var batchSizeSetting = await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
|
||||
|
||||
if (previewPathSetting == null) {
|
||||
Logger.LogError("Preview path not found. Cannot proceed.");
|
||||
@@ -214,7 +216,7 @@ public class PreviewJob : Job {
|
||||
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
|
||||
batchSize = Math.Max(1, parsed);
|
||||
|
||||
totalAssets = AssetRepository.CountAssetsMissingOrWrongPreview(previewSize, ExpectedFormat);
|
||||
totalAssets = await AssetRepository.CountAssetsMissingOrWrongPreviewAsync(previewSize, ExpectedFormat, token);
|
||||
Logger.LogInformation("Found {Count} assets needing previews.", totalAssets);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
@@ -226,7 +228,7 @@ public class PreviewJob : Job {
|
||||
int pending = 0;
|
||||
|
||||
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
||||
var batch = AssetRepository.GetAssetsMissingOrWrongPreview(previewSize, ExpectedFormat, batchSize, offset);
|
||||
var batch = await AssetRepository.GetAssetsMissingOrWrongPreviewAsync(previewSize, ExpectedFormat, batchSize, offset, token);
|
||||
var batchList = batch.ToList();
|
||||
|
||||
if (batchList.Count == 0) continue;
|
||||
@@ -279,16 +281,4 @@ public class PreviewJob : Job {
|
||||
else
|
||||
JobStatus.Complete("All previews generated successfully.");
|
||||
}
|
||||
|
||||
static string PathFromGuid(Guid id, string root) {
|
||||
var s = id.ToString("N");
|
||||
|
||||
return Path.Combine(
|
||||
root,
|
||||
s[..2],
|
||||
s.Substring(2, 2),
|
||||
s.Substring(4, 2),
|
||||
$"{s}.webp"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,11 +82,11 @@ public class ThumbnailJob : Job {
|
||||
if (ParentJob == null) {
|
||||
await MasterJob(token);
|
||||
} else {
|
||||
SlaveJob(token);
|
||||
await SlaveJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
void SlaveJob(CancellationToken token) {
|
||||
async Task SlaveJob(CancellationToken token) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
@@ -119,6 +119,7 @@ public class ThumbnailJob : Job {
|
||||
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);
|
||||
JobHelpers.MarkBroken(asset, msg);
|
||||
failedAssets++;
|
||||
continue;
|
||||
}
|
||||
@@ -140,7 +141,7 @@ public class ThumbnailJob : Job {
|
||||
)
|
||||
);
|
||||
|
||||
var path = PathFromGuid(asset.Id, ThumbnailPath!);
|
||||
var path = PathUtils.PathFromGuid(asset.Id, ThumbnailPath!);
|
||||
|
||||
if (!Directory.Exists(Path.GetDirectoryName(path)))
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
@@ -159,12 +160,13 @@ public class ThumbnailJob : Job {
|
||||
processedAssets++;
|
||||
} catch (Exception ex) {
|
||||
Logger.LogError(ex, $"Failed to generate thumbnail for asset ID {asset.Id}");
|
||||
JobHelpers.MarkBroken(asset, $"Thumbnail generation failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
}
|
||||
}
|
||||
|
||||
AssetRepository.UpdateBulk(Batch);
|
||||
AssetRepository.Save();
|
||||
await AssetRepository.SaveAsync(token);
|
||||
|
||||
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
||||
Logger.LogInformation(resultMsg);
|
||||
@@ -187,10 +189,10 @@ public class ThumbnailJob : Job {
|
||||
JobStatus.Start();
|
||||
Logger.LogInformation("Starting master Thumbnail job.");
|
||||
|
||||
var thumbPathSetting = SettingsRepository.Get(Settings.ThumbnailPath.AsString());
|
||||
var thumbSizeSetting = SettingsRepository.Get(Settings.ThumbnailSize.AsString());
|
||||
var thumbQualitySetting = SettingsRepository.Get(Settings.ThumbnailQuality.AsString());
|
||||
var batchSizeSetting = SettingsRepository.Get(Settings.JobBatchSize.AsString());
|
||||
var thumbPathSetting = await SettingsRepository.GetAsync(Settings.ThumbnailPath.AsString(), token);
|
||||
var thumbSizeSetting = await SettingsRepository.GetAsync(Settings.ThumbnailSize.AsString(), token);
|
||||
var thumbQualitySetting = await SettingsRepository.GetAsync(Settings.ThumbnailQuality.AsString(), token);
|
||||
var batchSizeSetting = await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
|
||||
|
||||
if (thumbPathSetting == null) {
|
||||
Logger.LogError("Thumbnail path not found. Cannot proceed.");
|
||||
@@ -214,7 +216,7 @@ public class ThumbnailJob : Job {
|
||||
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
|
||||
batchSize = Math.Max(1, parsed);
|
||||
|
||||
totalAssets = AssetRepository.CountAssetsMissingOrWrongThumbnail(thumbnailSize, ExpectedFormat);
|
||||
totalAssets = await AssetRepository.CountAssetsMissingOrWrongThumbnailAsync(thumbnailSize, ExpectedFormat, token);
|
||||
Logger.LogInformation("Found {Count} assets needing thumbnails.", totalAssets);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
@@ -226,7 +228,7 @@ public class ThumbnailJob : Job {
|
||||
int pending = 0;
|
||||
|
||||
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
||||
var batch = AssetRepository.GetAssetsMissingOrWrongThumbnail(thumbnailSize, ExpectedFormat, batchSize, offset);
|
||||
var batch = await AssetRepository.GetAssetsMissingOrWrongThumbnailAsync(thumbnailSize, ExpectedFormat, batchSize, offset, token);
|
||||
var batchList = batch.ToList();
|
||||
|
||||
if (batchList.Count == 0) continue;
|
||||
@@ -279,16 +281,4 @@ public class ThumbnailJob : Job {
|
||||
else
|
||||
JobStatus.Complete("All thumbnails generated successfully.");
|
||||
}
|
||||
|
||||
static string PathFromGuid(Guid id, string root) {
|
||||
var s = id.ToString("N");
|
||||
|
||||
return Path.Combine(
|
||||
root,
|
||||
s[..2],
|
||||
s.Substring(2, 2),
|
||||
s.Substring(4, 2),
|
||||
$"{s}.webp"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ public static class AlbumMapper {
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
CoverWidth = album.CoverAsset?.ResolutionWidth,
|
||||
CoverHeight = album.CoverAsset?.ResolutionHeight,
|
||||
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||
DeletedAt = null
|
||||
@@ -28,31 +30,39 @@ public static class AlbumMapper {
|
||||
|
||||
/// <summary>
|
||||
/// Maps an Album object to an AlbumFullDto object.
|
||||
/// Uses pre-projected <see cref="Album.VisibleAssetPreviews"/> when available; otherwise falls back to mapping loaded assets.
|
||||
/// </summary>
|
||||
/// <param name="album">The album to map.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>An AlbumFullDto object.</returns>
|
||||
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel, Guid? viewerId) => new AlbumFullDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||
DeletedAt = null,
|
||||
Images = album.Assets?.Select(asset => asset.Id).ToList() ?? [],
|
||||
AssetPreviews = album.Assets?.Select(asset => new AlbumAssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
|
||||
}).ToList() ?? []
|
||||
};
|
||||
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel, Guid? viewerId) {
|
||||
var previews = album.VisibleAssetPreviews
|
||||
?? album.Assets?.Select(asset => new AlbumAssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||
MimeType = string.IsNullOrEmpty(asset.ConvertedMimeType) ? asset.MimeType : asset.ConvertedMimeType,
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
|
||||
}).ToList()
|
||||
?? [];
|
||||
|
||||
return new AlbumFullDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = previews.Count(p => p.DeletedAt == null),
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||
DeletedAt = null,
|
||||
Images = previews.Select(preview => preview.Id).ToList(),
|
||||
AssetPreviews = previews
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ public static class AssetsMapper {
|
||||
/// <returns>A AssetPreviewDto object.</returns>
|
||||
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
MimeType = asset.MimeType,
|
||||
MimeType = string.IsNullOrEmpty(asset.ConvertedMimeType) ? asset.MimeType : asset.ConvertedMimeType,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
@@ -62,7 +62,10 @@ public static class AssetsMapper {
|
||||
.Distinct()
|
||||
.ToList(),
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null,
|
||||
ProcessFailedAt = accessLevel >= EAccessLevel.Curator ? asset.ProcessFailedAt : null,
|
||||
ProcessErrorMessage = accessLevel >= EAccessLevel.Curator ? asset.ProcessErrorMessage : null,
|
||||
OriginalPath = accessLevel >= EAccessLevel.Curator ? asset.OriginalPath : null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -30,8 +30,8 @@ public static class JobMapper {
|
||||
IntegrityCheckJob => EJobType.IntegrityCheck,
|
||||
PHashJob => EJobType.PHashGeneration,
|
||||
MetadataJob => EJobType.MetadataExtraction,
|
||||
CreatePersonsJob => EJobType.CreatePersons,
|
||||
CreateAlbumsJob => EJobType.CreateAlbums,
|
||||
ConvertAnimatedJob => EJobType.AnimatedConversion,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(job))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,10 +27,14 @@ public static class PersonMapper {
|
||||
ProfileCropX = person.ProfileCropX,
|
||||
ProfileCropY = person.ProfileCropY,
|
||||
ProfileCropZoom = person.ProfileCropZoom,
|
||||
TotalAlbums = albums.Count,
|
||||
TotalAssets = albums.Sum(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0),
|
||||
TotalAlbums = person.AlbumTotalCount,
|
||||
TotalAssets = person.TotalAssetCount,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null,
|
||||
Albums = albums.Select(a => a.ToAlbumPreviewDto(accessLevel, viewerId)).ToList(),
|
||||
Albums = albums.Select(a => {
|
||||
var dto = a.ToAlbumPreviewDto(accessLevel, viewerId);
|
||||
dto.AssetCount = person.AlbumAssetCounts?.GetValueOrDefault(a.Id, 0) ?? 0;
|
||||
return dto;
|
||||
}).ToList(),
|
||||
MaintainerUserIds = maintainers.Select(u => u.Id).ToList(),
|
||||
MaintainerUsernames = maintainers.Select(u => u.Username).ToList()
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
+601
@@ -0,0 +1,601 @@
|
||||
// <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("20260811104813_AddAssetVisibleCreatedAtIndex")]
|
||||
partial class AddAssetVisibleCreatedAtIndex
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
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<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with 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<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
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.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(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 with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with 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 with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
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.PrimitiveCollection<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<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
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.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
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.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
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("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAssetVisibleCreatedAtIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_VisibleCreatedAt",
|
||||
table: "Assets",
|
||||
columns: new[] { "Visibility", "CreatedAt" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_VisibleCreatedAt",
|
||||
table: "Assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
// <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("20260817205017_OptimizeStatsIndexes")]
|
||||
partial class OptimizeStatsIndexes
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
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<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with 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<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PreviewPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ThumbnailPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
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.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(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 with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with 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 with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
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.PrimitiveCollection<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<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
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.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
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.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
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("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OptimizeStatsIndexes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_Stats_CreatedAt",
|
||||
table: "Assets",
|
||||
column: "CreatedAt",
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_Stats_MissingPhash",
|
||||
table: "Assets",
|
||||
column: "Hash",
|
||||
filter: "\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_Stats_MissingPreview",
|
||||
table: "Assets",
|
||||
column: "PreviewPath",
|
||||
filter: "\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_Stats_MissingThumbnail",
|
||||
table: "Assets",
|
||||
column: "ThumbnailPath",
|
||||
filter: "\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_Stats_Resolution",
|
||||
table: "Assets",
|
||||
columns: new[] { "ResolutionWidth", "ResolutionHeight", "Type" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_Stats_Type",
|
||||
table: "Assets",
|
||||
column: "Type",
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_Stats_CreatedAt",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_Stats_MissingPhash",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_Stats_MissingPreview",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_Stats_MissingThumbnail",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_Stats_Resolution",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_Stats_Type",
|
||||
table: "Assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
+647
@@ -0,0 +1,647 @@
|
||||
// <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("20260818090402_AddAssetProcessFailureAndConverted")]
|
||||
partial class AddAssetProcessFailureAndConverted
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
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<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConvertedFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ConvertedMimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("ConvertedPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with 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<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ProcessErrorMessage")
|
||||
.HasColumnType("VARCHAR(1024)");
|
||||
|
||||
b.Property<DateTime?>("ProcessFailedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PreviewPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ProcessFailedAt")
|
||||
.HasDatabaseName("IX_Assets_ProcessFailedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ThumbnailPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
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.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(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 with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with 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 with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
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.PrimitiveCollection<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<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
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.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
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.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
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("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAssetProcessFailureAndConverted : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ConvertedFormat",
|
||||
table: "Assets",
|
||||
type: "VARCHAR(16)",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ConvertedMimeType",
|
||||
table: "Assets",
|
||||
type: "VARCHAR(255)",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ConvertedPath",
|
||||
table: "Assets",
|
||||
type: "VARCHAR(2048)",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ProcessErrorMessage",
|
||||
table: "Assets",
|
||||
type: "VARCHAR(1024)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ProcessFailedAt",
|
||||
table: "Assets",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_ProcessFailedAt",
|
||||
table: "Assets",
|
||||
column: "ProcessFailedAt",
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_ProcessFailedAt",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ConvertedFormat",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ConvertedMimeType",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ConvertedPath",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProcessErrorMessage",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProcessFailedAt",
|
||||
table: "Assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
// <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("20260819004150_AddIncrementalScanState")]
|
||||
partial class AddIncrementalScanState
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
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<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with 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<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PreviewPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ThumbnailPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("FolderId", "Id")
|
||||
.HasDatabaseName("IX_Assets_FolderId_Id")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AlbumName")
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PersonName")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
|
||||
|
||||
b.HasIndex("PersonName")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
|
||||
|
||||
b.ToTable("AssetAlbumStaging");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("LastMtime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId", "Path")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
|
||||
|
||||
b.ToTable("DirectoryScanStates");
|
||||
});
|
||||
|
||||
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.Property<DateTime?>("LastDiscoveryScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastDiscoveryScannedRegex")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("LastFileScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(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 with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with 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 with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
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.PrimitiveCollection<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<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
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.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Asset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany()
|
||||
.HasForeignKey("FolderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Folder");
|
||||
});
|
||||
|
||||
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.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
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("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIncrementalScanState : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_FolderId",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastDiscoveryScanAt",
|
||||
table: "Folders",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LastDiscoveryScannedRegex",
|
||||
table: "Folders",
|
||||
type: "VARCHAR(2048)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastFileScanAt",
|
||||
table: "Folders",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AssetAlbumStaging",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PersonName = table.Column<string>(type: "VARCHAR(255)", nullable: false),
|
||||
AlbumName = table.Column<string>(type: "VARCHAR(255)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AssetAlbumStaging", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AssetAlbumStaging_Assets_AssetId",
|
||||
column: x => x.AssetId,
|
||||
principalTable: "Assets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DirectoryScanStates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FolderId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Path = table.Column<string>(type: "VARCHAR(2048)", nullable: false),
|
||||
LastMtime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DirectoryScanStates", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DirectoryScanStates_Folders_FolderId",
|
||||
column: x => x.FolderId,
|
||||
principalTable: "Folders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_FolderId_Id",
|
||||
table: "Assets",
|
||||
columns: new[] { "FolderId", "Id" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AssetAlbumStaging_AssetId",
|
||||
table: "AssetAlbumStaging",
|
||||
column: "AssetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AssetAlbumStaging_PersonName",
|
||||
table: "AssetAlbumStaging",
|
||||
column: "PersonName");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DirectoryScanStates_FolderId_Path",
|
||||
table: "DirectoryScanStates",
|
||||
columns: new[] { "FolderId", "Path" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AssetAlbumStaging");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DirectoryScanStates");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_FolderId_Id",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastDiscoveryScanAt",
|
||||
table: "Folders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastDiscoveryScannedRegex",
|
||||
table: "Folders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastFileScanAt",
|
||||
table: "Folders");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_FolderId",
|
||||
table: "Assets",
|
||||
column: "FolderId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
// <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("20260819224929_AddAssetIngestedAt")]
|
||||
partial class AddAssetIngestedAt
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
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<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with 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<DateTime>("IngestedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
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<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PreviewPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ThumbnailPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("FolderId", "IngestedAt", "Id")
|
||||
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AlbumName")
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PersonName")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
|
||||
|
||||
b.HasIndex("PersonName")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
|
||||
|
||||
b.ToTable("AssetAlbumStaging");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("LastMtime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId", "Path")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
|
||||
|
||||
b.ToTable("DirectoryScanStates");
|
||||
});
|
||||
|
||||
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.Property<DateTime?>("LastDiscoveryScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastDiscoveryScannedRegex")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("LastFileScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(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 with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with 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 with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
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.PrimitiveCollection<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<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
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.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Asset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany()
|
||||
.HasForeignKey("FolderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Folder");
|
||||
});
|
||||
|
||||
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.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
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("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAssetIngestedAt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_FolderId_Id",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "IngestedAt",
|
||||
table: "Assets",
|
||||
type: "timestamp with time zone",
|
||||
nullable: false,
|
||||
defaultValueSql: "now()");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_FolderId_IngestedAt_Id",
|
||||
table: "Assets",
|
||||
columns: new[] { "FolderId", "IngestedAt", "Id" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_FolderId_IngestedAt_Id",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IngestedAt",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_FolderId_Id",
|
||||
table: "Assets",
|
||||
columns: new[] { "FolderId", "Id" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
// <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("20260819235054_BackfillAssetIngestedAt")]
|
||||
partial class BackfillAssetIngestedAt
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
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<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with 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<DateTime>("IngestedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
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<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PreviewPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ThumbnailPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("FolderId", "IngestedAt", "Id")
|
||||
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AlbumName")
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PersonName")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
|
||||
|
||||
b.HasIndex("PersonName")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
|
||||
|
||||
b.ToTable("AssetAlbumStaging");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("LastMtime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId", "Path")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
|
||||
|
||||
b.ToTable("DirectoryScanStates");
|
||||
});
|
||||
|
||||
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.Property<DateTime?>("LastDiscoveryScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastDiscoveryScannedRegex")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("LastFileScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(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 with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with 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 with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
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.PrimitiveCollection<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<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
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.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Asset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany()
|
||||
.HasForeignKey("FolderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Folder");
|
||||
});
|
||||
|
||||
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.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
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("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BackfillAssetIngestedAt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Intentionally a no-op. The IngestedAt backfill was removed because a single UPDATE on the
|
||||
// large Assets table exceeds the connection command timeout and crashes the app on startup.
|
||||
// IngestedAt remains populated (set to now() when the column was added); the next discovery
|
||||
// run will process the library once, after which incremental scans resume normally.
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,18 @@ namespace Lactose.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConvertedFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ConvertedMimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("ConvertedPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -123,6 +135,9 @@ namespace Lactose.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<DateTime>("IngestedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
@@ -146,6 +161,12 @@ namespace Lactose.Migrations
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ProcessErrorMessage")
|
||||
.HasColumnType("VARCHAR(1024)");
|
||||
|
||||
b.Property<DateTime?>("ProcessFailedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -177,7 +198,13 @@ namespace Lactose.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
@@ -188,15 +215,95 @@ namespace Lactose.Migrations
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PreviewPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ProcessFailedAt")
|
||||
.HasDatabaseName("IX_Assets_ProcessFailedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ThumbnailPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("FolderId", "IngestedAt", "Id")
|
||||
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AlbumName")
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PersonName")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
|
||||
|
||||
b.HasIndex("PersonName")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
|
||||
|
||||
b.ToTable("AssetAlbumStaging");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("LastMtime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId", "Path")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
|
||||
|
||||
b.ToTable("DirectoryScanStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -249,6 +356,15 @@ namespace Lactose.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("LastDiscoveryScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastDiscoveryScannedRegex")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("LastFileScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
@@ -515,6 +631,28 @@ namespace Lactose.Migrations
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Asset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany()
|
||||
.HasForeignKey("FolderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Folder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
@@ -66,4 +67,11 @@ public class Album {
|
||||
public Asset? CoverAsset { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility-filtered asset previews for the album detail view.
|
||||
/// Populated by <c>FindVisible</c>; ignored by EF Core.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public List<AlbumAssetPreviewDto>? VisibleAssetPreviews { get; set; }
|
||||
}
|
||||
|
||||
@@ -69,6 +69,26 @@ public class Asset {
|
||||
[Column(TypeName = "VARCHAR(16)")]
|
||||
public string PreviewFormat { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path to the converted video for animated assets (e.g. GIF → mp4).
|
||||
/// Empty means no converted video has been generated.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(2048)")]
|
||||
public string ConvertedPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the converted video (e.g. "video/mp4").
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(255)")]
|
||||
public string ConvertedMimeType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container format of the converted video (e.g. "mp4").
|
||||
/// Used to detect when conversion needs to be redone due to format changes.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(16)")]
|
||||
public string ConvertedFormat { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the type of the asset.
|
||||
/// </summary>
|
||||
@@ -101,6 +121,26 @@ public class Asset {
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when processing of this asset last failed.
|
||||
/// Non-null means the asset is marked broken and excluded from re-processing until manually retried.
|
||||
/// </summary>
|
||||
public DateTime? ProcessFailedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error message recorded when the asset was last marked broken.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(1024)")]
|
||||
public string? ProcessErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when this asset was ingested into the system.
|
||||
/// Unlike <see cref="CreatedAt"/> (the file's on-disk creation time), this reflects when the
|
||||
/// asset was first inserted into the database, which is what incremental discovery scans use as their cursor.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required DateTime IngestedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Lactose.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Temporary staging row produced by the person/album discovery job.
|
||||
/// Records a matched asset together with the person and (optional) album name extracted from its path.
|
||||
/// The table is truncated at the start and end of each discovery run.
|
||||
/// </summary>
|
||||
public class AssetAlbumStaging {
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for the staging row.
|
||||
/// </summary>
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the matched asset.
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(Asset))]
|
||||
public Guid AssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the normalized person name extracted from the asset path.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(255)")]
|
||||
public string PersonName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the normalized album name extracted from the asset path.
|
||||
/// Null when the folder's regex has no <c>group</c> group or the group did not match.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(255)")]
|
||||
public string? AlbumName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the matched asset.
|
||||
/// </summary>
|
||||
public Asset? Asset { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Lactose.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the last-known filesystem modification timestamp of a scanned directory.
|
||||
/// Used by the file-system crawl job to skip directories whose mtime is unchanged since the last scan.
|
||||
/// </summary>
|
||||
public class DirectoryScanState {
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for the scan-state entry.
|
||||
/// </summary>
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the folder this directory belongs to.
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(Folder))]
|
||||
public Guid FolderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the full filesystem path of the scanned directory.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(2048)")]
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last-known <c>LastWriteTimeUtc</c> of the directory.
|
||||
/// </summary>
|
||||
public DateTime LastMtime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the folder this directory belongs to.
|
||||
/// </summary>
|
||||
public Folder? Folder { get; set; }
|
||||
}
|
||||
@@ -32,6 +32,25 @@ public class Folder {
|
||||
[MaxLength(2048)]
|
||||
public string? RegexPattern { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp of the last file-system scan of this folder.
|
||||
/// Used by incremental scans to skip directories whose mtime is unchanged.
|
||||
/// </summary>
|
||||
public DateTime? LastFileScanAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp of the last person/album discovery scan of this folder.
|
||||
/// Incremental discovery only processes assets created at or after this timestamp.
|
||||
/// </summary>
|
||||
public DateTime? LastDiscoveryScanAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the regex pattern used during the last discovery scan.
|
||||
/// When this differs from <see cref="RegexPattern"/>, discovery automatically re-runs as a full (deep) scan.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(2048)")]
|
||||
public string? LastDiscoveryScannedRegex { get; set; }
|
||||
|
||||
#region Navigation Properties
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -79,4 +79,25 @@ public class Person {
|
||||
public List<User>? Maintainers { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of albums (before pagination).
|
||||
/// Populated by <c>FindVisible</c>; ignored by EF Core.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public int AlbumTotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of non-deleted assets across all visible albums.
|
||||
/// Populated by <c>FindVisible</c>; ignored by EF Core.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public int TotalAssetCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-album visible asset counts for the paged album list.
|
||||
/// Populated by <c>FindVisible</c>; ignored by EF Core.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public Dictionary<Guid, int>? AlbumAssetCounts { get; set; }
|
||||
}
|
||||
+22
-5
@@ -117,11 +117,12 @@ string host = builder.Configuration["DatabaseAddress:Host"] ?? "localhost";
|
||||
string port = builder.Configuration["DatabaseAddress:Port"] ?? "3306";
|
||||
|
||||
var strBuilder = new NpgsqlConnectionStringBuilder() {
|
||||
Host = host,
|
||||
Port = int.Parse(port),
|
||||
Username = user,
|
||||
Password = password,
|
||||
Database = database
|
||||
Host = host,
|
||||
Port = int.Parse(port),
|
||||
Username = user,
|
||||
Password = password,
|
||||
Database = database,
|
||||
CommandTimeout = 120
|
||||
};
|
||||
|
||||
builder.Services.AddDbContext<LactoseDbContext>(
|
||||
@@ -161,12 +162,14 @@ builder.Services.AddTransient<IPersonRepository, PersonRepository>();
|
||||
builder.Services.AddTransient<IMediaRepository, MediaRepository>();
|
||||
builder.Services.AddTransient<IJobRecordRepository, JobRecordRepository>();
|
||||
builder.Services.AddTransient<IStatsRepository, StatsRepository>();
|
||||
builder.Services.AddTransient<IFilesystemBrowseService, FilesystemBrowseService>();
|
||||
builder.Services.AddTransient<ITokenService, TokenService>();
|
||||
builder.Services.AddTransient<LactoseAuthService>();
|
||||
builder.Services.AddSingleton<JobManager>();
|
||||
builder.Services.AddSingleton<JobScheduler>();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
builder.Services.AddSwaggerGen(
|
||||
options => {
|
||||
@@ -245,4 +248,18 @@ app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
//app.UseHttpsRedirection();
|
||||
app.MapControllers();
|
||||
|
||||
// Auto-save middleware: ensures pending changes are persisted at the end of every
|
||||
// non-GET request even if the caller forgets to call Save() explicitly.
|
||||
// This is a safety net — explicit Save() calls in controllers and jobs still work
|
||||
// as before and take effect immediately. The middleware's SaveChanges is a no-op
|
||||
// when no tracked changes remain.
|
||||
app.Use(async (context, next) => {
|
||||
await next();
|
||||
if (context.Request.Method != "GET") {
|
||||
var db = context.RequestServices.GetRequiredService<LactoseDbContext>();
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user