Compare commits
87
Commits
v1.0.2
...
9eb333093a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb333093a | ||
|
|
30ad404efd | ||
|
|
7823d9f4d4 | ||
|
|
82ab09adcb | ||
|
|
4d068216df | ||
|
|
24d6109d33 | ||
|
|
68a583d584 | ||
|
|
1c7ede30af | ||
|
|
a5dda805fd | ||
|
|
87259a02a5 | ||
|
|
e2bdff9277 | ||
|
|
9beeb92ff0 | ||
|
|
a798a2cf30 | ||
|
|
2fd03c4d14 | ||
|
|
6026c9ec43 | ||
|
|
d96473eca5 | ||
|
|
34080648d6 | ||
|
|
f9385e2d9d | ||
|
|
e995388af0 | ||
|
|
3fbdd56c10 | ||
|
|
026b5d8fe8 | ||
|
|
7ded2b068b | ||
|
|
99117347d7 | ||
|
|
42154e9e5c | ||
|
|
230202337a | ||
|
|
2ded40d2a0 | ||
|
|
965caadc93 | ||
|
|
8a1b39347d | ||
|
|
d3124c55bd | ||
|
|
c97b16ada9 | ||
|
|
5e7c053218 | ||
|
|
e1bb7d3330 | ||
|
|
0c8e653a5f | ||
|
|
84d3f63a97 | ||
|
|
9e9d091f60 | ||
|
|
556c4a15e0 | ||
|
|
3374fe6ad4 | ||
|
|
e1839d4e8f | ||
|
|
ee73c8ad90 | ||
|
|
a9d58606a7 | ||
|
|
588095f370 | ||
|
|
9f5d9c0c11 | ||
|
|
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/
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -38,4 +38,9 @@ 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 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;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,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>
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -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,37 @@ 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");
|
||||
// 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 +147,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.");
|
||||
}
|
||||
@@ -67,7 +67,8 @@ public class AlbumController(
|
||||
pagingOptions.Unassigned,
|
||||
uid ?? default,
|
||||
accessLevel,
|
||||
pagingOptions.AssetUploadedBy
|
||||
pagingOptions.AssetUploadedBy,
|
||||
pagingOptions.PersonOwnerId
|
||||
).ToList();
|
||||
|
||||
return Ok(albums);
|
||||
@@ -85,7 +86,7 @@ public class AlbumController(
|
||||
Guid? uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var album = albumRepository.Find(id);
|
||||
var album = albumRepository.FindWithAssets(id);
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
|
||||
@@ -99,7 +99,8 @@ public class AssetController(
|
||||
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
|
||||
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
|
||||
uid, accessLevel,
|
||||
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search
|
||||
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search,
|
||||
searchOptionsDto.IncludeCount
|
||||
);
|
||||
|
||||
logger.LogTrace(
|
||||
@@ -114,11 +115,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>
|
||||
@@ -358,7 +357,8 @@ 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);
|
||||
|
||||
@@ -97,6 +97,16 @@ public class AuthController(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether user registration is currently enabled.
|
||||
/// </summary>
|
||||
/// <returns>200 if enabled, 403 if disabled.</returns>
|
||||
[HttpGet("register")]
|
||||
public ActionResult CheckRegistrationEnabled() {
|
||||
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
|
||||
return regSetting?.Value == "true" ? Ok() : StatusCode(403);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new user account.
|
||||
/// </summary>
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace Lactose.Controllers;
|
||||
Active = false
|
||||
};
|
||||
|
||||
folderRepository.Create(newFolder);
|
||||
folderRepository.Insert(newFolder);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ namespace Lactose.Controllers;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
folderRepository.Delete(id);
|
||||
folderRepository.Remove(id);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,10 +106,9 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
||||
|
||||
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);
|
||||
jobScheduler.QueueFileSystemCrawl(scanDeep, scanFolders);
|
||||
return Ok();
|
||||
case EJobType.ThumbnailGeneration:
|
||||
if (jobRequest.Parameters.Count != 0)
|
||||
@@ -135,15 +134,10 @@ 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)
|
||||
@@ -153,4 +147,38 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ public class MediaController(
|
||||
|
||||
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");
|
||||
@@ -88,6 +91,9 @@ public class MediaController(
|
||||
|
||||
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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+239
-286
@@ -2,399 +2,330 @@ 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 = folderRepo.GetAll()
|
||||
.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);
|
||||
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);
|
||||
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) 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, DateTime.UtcNow, 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 personRepo.FindByNames(chunk))
|
||||
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();
|
||||
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();
|
||||
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 +336,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.Database.ExecuteSqlRawAsync("TRUNCATE TABLE \"AssetAlbumStaging\"", 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());
|
||||
}
|
||||
+230
-139
@@ -1,186 +1,290 @@
|
||||
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 = settingsRepo.Get(Settings.SystemUploaderId.AsString())?.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++;
|
||||
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);
|
||||
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>
|
||||
async Task 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);
|
||||
} finally {
|
||||
assetDb.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
report(added, updated, deleted);
|
||||
}
|
||||
|
||||
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, []));
|
||||
|
||||
@@ -188,17 +292,17 @@ public sealed class FileSystemCrawlJob : Job {
|
||||
else if (mimeVid.MimeType != Empty) type = EAssetType.Video;
|
||||
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 +310,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
static class PathUtils {
|
||||
internal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,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)!);
|
||||
@@ -279,16 +279,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,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)!);
|
||||
@@ -279,16 +279,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,38 @@ 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),
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ public static class JobMapper {
|
||||
IntegrityCheckJob => EJobType.IntegrityCheck,
|
||||
PHashJob => EJobType.PHashGeneration,
|
||||
MetadataJob => EJobType.MetadataExtraction,
|
||||
CreatePersonsJob => EJobType.CreatePersons,
|
||||
CreateAlbumsJob => EJobType.CreateAlbums,
|
||||
_ => 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,57 @@
|
||||
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()");
|
||||
|
||||
// Backfill existing rows so an incremental discovery scan does not reprocess the whole library:
|
||||
// for assets already in the database, ingestion time is taken to be their creation time.
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE "Assets"
|
||||
SET "IngestedAt" = "CreatedAt";
|
||||
""");
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,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)");
|
||||
@@ -177,7 +180,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 +197,91 @@ namespace Lactose.Migrations
|
||||
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")
|
||||
@@ -249,6 +334,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 +609,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; }
|
||||
}
|
||||
|
||||
@@ -101,6 +101,14 @@ public class Asset {
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { 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; }
|
||||
}
|
||||
+21
-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>(
|
||||
@@ -167,6 +168,7 @@ builder.Services.AddSingleton<JobManager>();
|
||||
builder.Services.AddSingleton<JobScheduler>();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
builder.Services.AddSwaggerGen(
|
||||
options => {
|
||||
@@ -245,4 +247,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();
|
||||
|
||||
@@ -27,8 +27,8 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null) {
|
||||
IQueryable<Album> albumsQuery = context.Albums;
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null, Guid? personOwnerId = null) {
|
||||
IQueryable<Album> albumsQuery = context.Albums.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
albumsQuery = albumsQuery.Where(x => EF.Functions.ILike(x.Title, $"%{query}%"));
|
||||
@@ -39,6 +39,9 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
if (uploadedBy.HasValue)
|
||||
albumsQuery = albumsQuery.Where(a => a.Assets!.Any(asset => asset.UploadedBy == uploadedBy.Value));
|
||||
|
||||
if (personOwnerId.HasValue)
|
||||
albumsQuery = albumsQuery.Where(a => a.PersonOwnerId == personOwnerId.Value);
|
||||
|
||||
// Apply visibility filter scoped to access level
|
||||
albumsQuery = accessLevel switch {
|
||||
< EAccessLevel.Maintainer => albumsQuery.Where(a => a.Visibility <= EVisibility.Protected),
|
||||
@@ -69,7 +72,9 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
return [];
|
||||
|
||||
var pagedAlbums = context.Albums
|
||||
.AsNoTracking()
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.CoverAsset)
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.ToList();
|
||||
|
||||
@@ -108,20 +113,19 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
public void Save() => context.SaveChanges();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? Find(Guid id) => context.Albums
|
||||
public Album? Find(Guid id) => context.Albums.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? FindWithAssets(Guid id) => context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.CoverAsset)
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.Assets)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||
var album = context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.CoverAsset)
|
||||
.AsNoTracking()
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.Assets)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
if (album == null) return null;
|
||||
@@ -138,33 +142,40 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
|
||||
if (!canSeeAlbum) return null;
|
||||
|
||||
if (album.Assets == null) return album;
|
||||
// R2: Filter assets at the database level and project only the DTO fields
|
||||
IQueryable<Asset> assetsQuery = context.Assets
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Albums!.Any(al => al.Id == id));
|
||||
|
||||
album.Assets = accessLevel switch {
|
||||
EAccessLevel.Admin => album.Assets,
|
||||
EAccessLevel.Curator => album.Assets.Where(a => a.DeletedAt == null || a.UploadedBy == userId).ToList(),
|
||||
EAccessLevel.Maintainer when userId.HasValue => FilterAssetsForMaintainer(album, userId.Value),
|
||||
_ => album.Assets.Where(a => a.DeletedAt == null && (
|
||||
assetsQuery = accessLevel switch {
|
||||
EAccessLevel.Admin => assetsQuery,
|
||||
EAccessLevel.Curator => assetsQuery.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
|
||||
EAccessLevel.Maintainer when userId.HasValue && album.PersonOwnerId.HasValue
|
||||
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value)
|
||||
=> assetsQuery.Where(a => a.DeletedAt == null),
|
||||
_ => assetsQuery.Where(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(a.Visibility == EVisibility.Protected && (userId.HasValue || accessLevel >= EAccessLevel.Maintainer)) ||
|
||||
(a.Visibility == EVisibility.Private && a.UploadedBy == userId)
|
||||
)).ToList()
|
||||
))
|
||||
};
|
||||
|
||||
album.VisibleAssetPreviews = assetsQuery
|
||||
.OrderBy(a => a.OriginalFilename)
|
||||
.Select(a => new AlbumAssetPreviewDto {
|
||||
Id = a.Id,
|
||||
ResolutionWidth = a.ResolutionWidth,
|
||||
ResolutionHeight = a.ResolutionHeight,
|
||||
HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "",
|
||||
HasPreview = a.PreviewPath != null && a.PreviewPath != "",
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? a.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? a.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || a.UploadedBy == userId ? a.DeletedAt : null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return album;
|
||||
}
|
||||
|
||||
private List<Asset> FilterAssetsForMaintainer(Album album, Guid userId) {
|
||||
if (album.PersonOwnerId.HasValue && context.PersonMaintainers.Any(pm =>
|
||||
pm.UserId == userId && pm.PersonId == album.PersonOwnerId.Value))
|
||||
return album.Assets!.Where(a => a.DeletedAt == null).ToList();
|
||||
|
||||
return album.Assets!.Where(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
a.Visibility == EVisibility.Protected ||
|
||||
a.UploadedBy == userId
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Album> FindByPerson(Guid personId) => context.Albums.Where(a => a.PersonOwnerId == personId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Dtos.Asset;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Mapper;
|
||||
@@ -177,8 +178,8 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
.Take(limit);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null) {
|
||||
var query = context.Assets.AsQueryable();
|
||||
public IEnumerable<AssetPreviewDto> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true) {
|
||||
var query = context.Assets.AsNoTracking().AsQueryable();
|
||||
if (type.HasValue)
|
||||
query = query.Where(a => a.Type == type.Value);
|
||||
if (from.HasValue)
|
||||
@@ -213,45 +214,61 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
))
|
||||
};
|
||||
|
||||
total = query.Count();
|
||||
total = includeCount ? query.Count() : 0;
|
||||
|
||||
if (orderRandomly && seed.HasValue) {
|
||||
var allIds = query.Select(a => a.Id).ToList();
|
||||
var seedHash = seed.Value.GetHashCode();
|
||||
var shuffledIds = allIds
|
||||
.OrderBy(id => DeterministicHash(id, seedHash))
|
||||
.Skip(pageNumber * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
// Deterministic page of IDs first: random order is a seeded md5 shuffle on the server,
|
||||
// so the full ID set is never materialized in memory.
|
||||
IQueryable<Asset> ordered = orderRandomly switch {
|
||||
true when seed.HasValue => query.OrderBy(a => PgFunctions.Md5(a.Id.ToString() + seed.Value.ToString())),
|
||||
true => query.OrderBy(a => a.Id),
|
||||
false => query.OrderByDescending(a => a.CreatedAt)
|
||||
};
|
||||
|
||||
var assets = context.Assets
|
||||
.AsSplitQuery()
|
||||
.Where(a => shuffledIds.Contains(a.Id))
|
||||
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner)
|
||||
.ToList();
|
||||
|
||||
return shuffledIds.Select(id => assets.First(a => a.Id == id)).ToList();
|
||||
}
|
||||
|
||||
IQueryable<Asset> dataQuery = query
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner);
|
||||
dataQuery = orderRandomly ? dataQuery.OrderBy(a => a.Id) : dataQuery.OrderByDescending(a => a.CreatedAt);
|
||||
|
||||
return dataQuery
|
||||
var pageIds = ordered
|
||||
.Skip(pageNumber * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static int DeterministicHash(Guid id, int seed) {
|
||||
var bytes = id.ToByteArray();
|
||||
unchecked {
|
||||
var hash = seed;
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
hash = hash * 31 + bytes[i];
|
||||
return hash & 0x7FFFFFFF;
|
||||
if (pageIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var assets = query
|
||||
.Where(a => pageIds.Contains(a.Id))
|
||||
.Select(a => new AssetPreviewDto {
|
||||
Id = a.Id,
|
||||
MimeType = a.MimeType,
|
||||
ResolutionWidth = a.ResolutionWidth,
|
||||
ResolutionHeight = a.ResolutionHeight,
|
||||
HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "",
|
||||
HasPreview = a.PreviewPath != null && a.PreviewPath != "",
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? a.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? a.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || a.UploadedBy == userId ? a.DeletedAt : null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Album and cosplayer names per asset via a single grouped query
|
||||
var albumLinks = (from a in context.Assets
|
||||
from al in a.Albums!
|
||||
where pageIds.Contains(a.Id)
|
||||
select new { AssetId = a.Id, al.Id, al.Title, al.PersonOwnerId, PersonName = al.PersonOwner!.Name })
|
||||
.ToList();
|
||||
|
||||
var linksByAsset = albumLinks
|
||||
.GroupBy(x => x.AssetId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var dto in assets) {
|
||||
if (!linksByAsset.TryGetValue(dto.Id, out var links)) continue;
|
||||
dto.AlbumNames = links.Select(x => x.Title).Distinct().ToList();
|
||||
dto.AlbumIds = links.Select(x => x.Id).Distinct().ToList();
|
||||
dto.CosplayerNames = links.Where(x => x.PersonOwnerId.HasValue).Select(x => x.PersonName).Distinct().ToList();
|
||||
dto.CosplayerIds = links.Where(x => x.PersonOwnerId.HasValue).Select(x => x.PersonOwnerId!.Value).Distinct().ToList();
|
||||
}
|
||||
|
||||
var orderMap = pageIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
return [.. assets.OrderBy(a => orderMap.GetValueOrDefault(a.Id))];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -17,7 +17,7 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
|
||||
static void OnFolderRemoved(Folder e) => FolderRemoved?.Invoke(null, e);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Create(Folder folder) {
|
||||
public void Insert(Folder folder) {
|
||||
context.Folders.Add(folder);
|
||||
context.SaveChanges();
|
||||
if (folder.Active) OnFolderAdded(folder);
|
||||
@@ -48,7 +48,7 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Delete(Guid id) {
|
||||
public void Remove(Guid id) {
|
||||
var folder = context.Folders.Find(id);
|
||||
|
||||
if (folder != null) {
|
||||
|
||||
@@ -39,6 +39,13 @@ public interface IAlbumRepository : IDisposable {
|
||||
/// <returns>The album if found; otherwise, null.</returns>
|
||||
public Album? Find(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an album by its ID with its assets loaded for collection replacement.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the album.</param>
|
||||
/// <returns>The album with its asset collection loaded if found; otherwise, null.</returns>
|
||||
public Album? FindWithAssets(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an album by its ID with assets filtered by the requesting user's access level.
|
||||
/// </summary>
|
||||
@@ -96,7 +103,8 @@ public interface IAlbumRepository : IDisposable {
|
||||
/// <param name="userId">The current user's ID for access-level filtering.</param>
|
||||
/// <param name="accessLevel">The current user's access level. Regular users only see albums with visible assets.</param>
|
||||
/// <param name="uploadedBy">Optional filter for albums containing assets uploaded by a specific user.</param>
|
||||
/// <param name="personOwnerId">Optional filter for albums owned by a specific person.</param>
|
||||
/// <returns>A list of album previews matching the search query.</returns>
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null);
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null, Guid? personOwnerId = null);
|
||||
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Dtos.Asset;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
@@ -193,8 +194,9 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <param name="folderId">Optional folder ID to filter assets by their scan folder.</param>
|
||||
/// <param name="uploadedBy">Optional uploader user ID to filter assets by their uploader.</param>
|
||||
/// <param name="search">Optional search term for ILike matching against OriginalFilename.</param>
|
||||
/// <returns>A paginated collection of assets matching the filters.</returns>
|
||||
IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null);
|
||||
/// <param name="includeCount">If true (default), computes the total matching count. When false, <paramref name="total"/> is set to zero and the count query is skipped.</param>
|
||||
/// <returns>A paginated collection of asset previews matching the filters.</returns>
|
||||
IEnumerable<AssetPreviewDto> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true);
|
||||
|
||||
/// <summary>
|
||||
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
|
||||
|
||||
@@ -10,7 +10,7 @@ public interface IFolderRepository : IDisposable {
|
||||
/// Creates a new folder.
|
||||
/// </summary>
|
||||
/// <param name="folder">The folder to create.</param>
|
||||
void Create(Folder folder);
|
||||
void Insert(Folder folder);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing folder.
|
||||
@@ -20,10 +20,10 @@ public interface IFolderRepository : IDisposable {
|
||||
void Update(Guid id, Folder folder);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a folder by ID.
|
||||
/// Removes a folder by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the folder to delete.</param>
|
||||
void Delete(Guid id);
|
||||
/// <param name="id">The ID of the folder to remove.</param>
|
||||
void Remove(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a folder by ID.
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Lactose.Repositories;
|
||||
/// <summary>
|
||||
/// Interface for media repository to handle media data retrieval.
|
||||
/// </summary>
|
||||
public interface IMediaRepository {
|
||||
public interface IMediaRepository : IDisposable {
|
||||
/// <summary>
|
||||
/// Retrieves the original media data.
|
||||
/// </summary>
|
||||
|
||||
@@ -3,6 +3,8 @@ using Butter.Dtos.Person;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for person repository operations.
|
||||
/// </summary>
|
||||
|
||||
@@ -5,10 +5,11 @@ namespace Lactose.Repositories;
|
||||
/// <summary>
|
||||
/// Repository for gathering aggregate statistics from the database.
|
||||
/// </summary>
|
||||
public interface IStatsRepository : IDisposable {
|
||||
public interface IStatsRepository {
|
||||
/// <summary>
|
||||
/// Gathers all aggregate statistics into a single DTO.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A fully populated <see cref="StatsDto"/>.</returns>
|
||||
StatsDto GetStats();
|
||||
}
|
||||
Task<StatsDto> GetStatsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -49,10 +49,10 @@ public interface ITagRepository : IDisposable {
|
||||
void Save();
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a tag by the given <paramref name="id"/>
|
||||
/// Deletes the given tag from the repository.
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
void Delete(Tag id);
|
||||
/// <param name="tag"></param>
|
||||
void Delete(Tag tag);
|
||||
|
||||
/// <summary>
|
||||
/// Insert a new Tag
|
||||
|
||||
@@ -92,4 +92,10 @@ public class MediaRepository(LactoseDbContext context, IPersonRepository personR
|
||||
Path = media.PreviewPath
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() {
|
||||
context.Dispose();
|
||||
personRepository.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -16,23 +16,14 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
public void Save() => context.SaveChanges();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person? Find(Guid id) => context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.ProfileAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
|
||||
.Include(p => p.Maintainers)
|
||||
.FirstOrDefault(p => p.Id == id);
|
||||
public Person? Find(Guid id) => context.People.FirstOrDefault(p => p.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person? FindVisible(Guid id, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User,
|
||||
string? albumSearch = null, string? albumSortBy = null, bool albumSortAsc = true,
|
||||
int albumPage = 0, int albumPageSize = PagedParametersDto.MaxPageSize) {
|
||||
var person = context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.ProfileAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Maintainers)
|
||||
.FirstOrDefault(p => p.Id == id);
|
||||
|
||||
@@ -49,77 +40,118 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
|
||||
if (!canSeePerson) return null;
|
||||
|
||||
if (person.Albums == null) return person;
|
||||
bool isMaintainerOfPerson = accessLevel == EAccessLevel.Maintainer && userId.HasValue
|
||||
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id);
|
||||
|
||||
// Filter albums and assets by visibility
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Admin:
|
||||
case EAccessLevel.Curator:
|
||||
IQueryable<Album> albumsQuery = context.Albums
|
||||
.AsNoTracking()
|
||||
.Where(a => a.PersonOwnerId == id);
|
||||
|
||||
// Album search — ILike so the trgm GIN index on Title can be used
|
||||
if (!string.IsNullOrEmpty(albumSearch))
|
||||
albumsQuery = albumsQuery.Where(a => EF.Functions.ILike(a.Title, $"%{albumSearch}%"));
|
||||
|
||||
// Album visibility filter. Admin, curators, and maintainers of this person see all albums;
|
||||
// everyone else sees Protected-or-below albums plus albums that contain at least one visible asset.
|
||||
bool seesAllAlbums = accessLevel >= EAccessLevel.Curator || isMaintainerOfPerson;
|
||||
if (!seesAllAlbums) {
|
||||
albumsQuery = albumsQuery.Where(a => a.Visibility <= EVisibility.Protected || a.Assets!.Any(
|
||||
asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
)));
|
||||
}
|
||||
|
||||
IOrderedQueryable<Album> ordered;
|
||||
switch (albumSortBy?.ToLowerInvariant()) {
|
||||
case "name":
|
||||
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.Title) : albumsQuery.OrderByDescending(a => a.Title);
|
||||
break;
|
||||
|
||||
case EAccessLevel.Maintainer when userId.HasValue
|
||||
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id):
|
||||
case "created":
|
||||
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt);
|
||||
break;
|
||||
|
||||
default:
|
||||
person.Albums = person.Albums
|
||||
.Where(a => a.Visibility <= EVisibility.Protected || (
|
||||
a.Assets != null && a.Assets.Any(asset =>
|
||||
asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
)
|
||||
)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
foreach (var album in person.Albums) {
|
||||
if (album.Assets != null) {
|
||||
album.Assets = album.Assets
|
||||
.Where(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
case "updated":
|
||||
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.UpdatedAt) : albumsQuery.OrderByDescending(a => a.UpdatedAt);
|
||||
break;
|
||||
case "assets":
|
||||
if (seesAllAlbums) {
|
||||
ordered = albumSortAsc
|
||||
? albumsQuery.OrderBy(a => a.Assets!.Count(asset => asset.DeletedAt == null))
|
||||
: albumsQuery.OrderByDescending(a => a.Assets!.Count(asset => asset.DeletedAt == null));
|
||||
} else {
|
||||
ordered = albumSortAsc
|
||||
? albumsQuery.OrderBy(a => a.Assets!.Count(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId))))
|
||||
: albumsQuery.OrderByDescending(a => a.Assets!.Count(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId))));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt);
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply album search
|
||||
if (!string.IsNullOrEmpty(albumSearch)) {
|
||||
person.Albums = person.Albums
|
||||
.Where(a => a.Title.Contains(albumSearch, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
// Total album count (after search and visibility filters, before pagination)
|
||||
person.AlbumTotalCount = ordered.Count();
|
||||
|
||||
// Total visible assets across all visible albums of the person (before search and pagination)
|
||||
if (seesAllAlbums) {
|
||||
person.TotalAssetCount = context.Assets
|
||||
.Where(a => a.Albums!.Any(al => al.PersonOwnerId == id))
|
||||
.Count(asset => asset.DeletedAt == null);
|
||||
} else {
|
||||
person.TotalAssetCount = context.Assets
|
||||
.Where(a => a.Albums!.Any(al => al.PersonOwnerId == id))
|
||||
.Count(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)));
|
||||
}
|
||||
|
||||
// Apply album sorting
|
||||
if (!string.IsNullOrEmpty(albumSortBy)) {
|
||||
person.Albums = albumSortBy.ToLowerInvariant() switch {
|
||||
"name" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.Title)]
|
||||
: [..person.Albums.OrderByDescending(a => a.Title)],
|
||||
"created" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.CreatedAt)]
|
||||
: [..person.Albums.OrderByDescending(a => a.CreatedAt)],
|
||||
"updated" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.UpdatedAt)]
|
||||
: [..person.Albums.OrderByDescending(a => a.UpdatedAt)],
|
||||
"assets" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)]
|
||||
: [..person.Albums.OrderByDescending(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)],
|
||||
_ => person.Albums
|
||||
};
|
||||
}
|
||||
|
||||
// Apply album pagination
|
||||
person.Albums = person.Albums
|
||||
var albumIds = ordered
|
||||
.Skip(albumPage * albumPageSize)
|
||||
.Take(albumPageSize)
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
|
||||
if (albumIds.Count == 0)
|
||||
return person;
|
||||
|
||||
person.Albums = context.Albums
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.CoverAsset)
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.ToList();
|
||||
|
||||
// Per-album visible asset counts at the database level
|
||||
if (seesAllAlbums) {
|
||||
person.AlbumAssetCounts = context.Albums
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, Count = a.Assets!.Count(asset => asset.DeletedAt == null) })
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
} else {
|
||||
person.AlbumAssetCounts = context.Albums
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.Select(a => new {
|
||||
a.Id,
|
||||
Count = a.Assets!.Count(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)))
|
||||
})
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
}
|
||||
|
||||
var orderMap = albumIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
person.Albums = [.. person.Albums.OrderBy(a => orderMap.GetValueOrDefault(a.Id))];
|
||||
|
||||
return person;
|
||||
}
|
||||
|
||||
@@ -202,12 +234,21 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
return [];
|
||||
|
||||
var pagedPeople = context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.Albums)
|
||||
.AsNoTracking()
|
||||
.Where(p => personIds.Contains(p.Id))
|
||||
.ToList();
|
||||
|
||||
var dtos = pagedPeople.Select(p => p.ToPersonPreviewDto(accessLevel)).ToList();
|
||||
// Album counts via a single grouped query instead of loading every album row
|
||||
var albumCounts = context.People
|
||||
.Where(p => personIds.Contains(p.Id))
|
||||
.Select(p => new { p.Id, Count = p.Albums!.Count })
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
|
||||
var dtos = pagedPeople.Select(p => {
|
||||
var dto = p.ToPersonPreviewDto(accessLevel);
|
||||
dto.TotalAlbums = albumCounts.GetValueOrDefault(p.Id, 0);
|
||||
return dto;
|
||||
}).ToList();
|
||||
|
||||
var orderMap = personIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
|
||||
|
||||
@@ -3,150 +3,176 @@ using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Collections;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class StatsRepository(LactoseDbContext context, ISettingsRepository settingsRepo) : IStatsRepository {
|
||||
public class StatsRepository(
|
||||
LactoseDbContext context,
|
||||
ISettingsRepository settingsRepo,
|
||||
IMemoryCache cache
|
||||
) : IStatsRepository {
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <inheritdoc />
|
||||
public StatsDto GetStats() {
|
||||
var dto = new StatsDto();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
dto.TotalAssets = context.Assets.Count(a => a.DeletedAt == null);
|
||||
dto.TotalUsers = context.Users.Count(u => u.DeletedAt == null);
|
||||
dto.TotalAlbums = context.Albums.Count();
|
||||
dto.TotalTags = context.Tags.Count();
|
||||
dto.TotalPeople = context.People.Count();
|
||||
dto.TotalFaces = context.Faces.Count();
|
||||
dto.TotalFolders = context.Folders.Count();
|
||||
|
||||
dto.AssetsByType = context.Assets
|
||||
.Where(a => a.DeletedAt == null)
|
||||
.GroupBy(a => a.Type)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.Key, x => x.Count);
|
||||
|
||||
dto.TotalStorageBytes = context.Assets
|
||||
.Where(a => a.DeletedAt == null)
|
||||
.Sum(a => (long?)a.FileSize) ?? 0;
|
||||
|
||||
dto.StorageByType = context.Assets
|
||||
.Where(a => a.DeletedAt == null)
|
||||
.GroupBy(a => a.Type)
|
||||
.Select(g => new { g.Key, Size = g.Sum(a => (long?)a.FileSize) ?? 0 })
|
||||
.ToDictionary(x => x.Key, x => x.Size);
|
||||
|
||||
dto.UsersByAccessLevel = context.Users
|
||||
.Where(u => u.DeletedAt == null)
|
||||
.GroupBy(u => u.AccessLevel)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.Key, x => x.Count);
|
||||
|
||||
dto.AssetsAddedLast7Days = context.Assets
|
||||
.Count(a => a.DeletedAt == null && a.CreatedAt >= now.AddDays(-7));
|
||||
dto.AssetsAddedLast30Days = context.Assets
|
||||
.Count(a => a.DeletedAt == null && a.CreatedAt >= now.AddDays(-30));
|
||||
dto.UsersRegisteredLast30Days = context.Users
|
||||
.Count(u => u.DeletedAt == null && u.CreatedAt >= now.AddDays(-30));
|
||||
|
||||
dto.OrphanAssets = context.Assets.Count(a => a.DeletedAt == null && a.FolderId == null);
|
||||
dto.PublicAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Public);
|
||||
dto.ProtectedAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Protected);
|
||||
dto.PrivateAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Private);
|
||||
|
||||
dto.AssetsMissingMetadata = context.Assets.Count(a =>
|
||||
a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null);
|
||||
|
||||
public async Task<StatsDto> GetStatsAsync(CancellationToken cancellationToken) {
|
||||
var thumbnailSizeSetting = settingsRepo.Get(Settings.ThumbnailSize.AsString());
|
||||
var previewSizeSetting = settingsRepo.Get(Settings.PreviewSize.AsString());
|
||||
|
||||
var cacheKey = $"stats:{thumbnailSizeSetting?.Value}:{previewSizeSetting?.Value}";
|
||||
if (cache.TryGetValue(cacheKey, out StatsDto? cached) && cached is not null)
|
||||
return cached;
|
||||
|
||||
_ = int.TryParse(thumbnailSizeSetting?.Value, out var expectedThumbnailSize);
|
||||
_ = int.TryParse(previewSizeSetting?.Value, out var expectedPreviewSize);
|
||||
|
||||
dto.AssetsMissingThumbnail = context.Assets.Count(a =>
|
||||
(a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null);
|
||||
var stats = await ComputeStatsAsync(cancellationToken, expectedThumbnailSize, expectedPreviewSize);
|
||||
cache.Set(cacheKey, stats, CacheTtl);
|
||||
return stats;
|
||||
}
|
||||
|
||||
dto.AssetsMissingThumbnailStale = expectedThumbnailSize > 0
|
||||
? context.Assets.Count(a =>
|
||||
a.ThumbnailPath != null && a.ThumbnailPath != "" && a.ThumbnailSize != expectedThumbnailSize && a.DeletedAt == null)
|
||||
: 0;
|
||||
|
||||
dto.AssetsMissingPreviews = context.Assets.Count(a =>
|
||||
(a.PreviewPath == null || a.PreviewPath == "") && a.DeletedAt == null);
|
||||
|
||||
dto.AssetsMissingPreviewsStale = expectedPreviewSize > 0
|
||||
? context.Assets.Count(a =>
|
||||
a.PreviewPath != null && a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize && a.DeletedAt == null)
|
||||
: 0;
|
||||
private async Task<StatsDto> ComputeStatsAsync(
|
||||
CancellationToken cancellationToken,
|
||||
int expectedThumbnailSize,
|
||||
int expectedPreviewSize) {
|
||||
var dto = new StatsDto();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var emptyHash = new BitArray(64);
|
||||
dto.AssetsMissingPhash = context.Assets.Count(a =>
|
||||
a.Hash == emptyHash && a.DeletedAt == null);
|
||||
|
||||
var albumAssetSet = context.Set<Dictionary<string, object>>("AlbumAsset");
|
||||
|
||||
dto.AssetsWithNoAlbum = context.Assets.Count(a =>
|
||||
a.DeletedAt == null && !albumAssetSet.Any(aa => EF.Property<Guid?>(aa, "AssetsId") == a.Id));
|
||||
|
||||
dto.AssetsWithNoPerson = context.Assets.Count(a =>
|
||||
a.DeletedAt == null && !albumAssetSet.Any(aa =>
|
||||
EF.Property<Guid?>(aa, "AssetsId") == a.Id
|
||||
&& context.Albums.Any(al => al.Id == EF.Property<Guid?>(aa, "AlbumsId") && al.PersonOwnerId != null)));
|
||||
|
||||
dto.AlbumsMissingCover = context.Albums.Count(a => a.CoverAssetId == null);
|
||||
|
||||
dto.CosplayersMissingProfile = context.People.Count(p => p.ProfileAssetId == null);
|
||||
|
||||
dto.TopTags = (
|
||||
from at in context.Set<Dictionary<string, object>>("AssetTag")
|
||||
join a in context.Assets.Where(a => a.DeletedAt == null)
|
||||
on EF.Property<Guid?>(at, "AssetsId") equals (Guid?)a.Id
|
||||
join t in context.Tags
|
||||
on EF.Property<Guid?>(at, "TagsId") equals (Guid?)t.Id
|
||||
group t by new { t.Id, t.Name } into g
|
||||
select new TagStatDto {
|
||||
Id = g.Key.Id,
|
||||
Name = g.Key.Name,
|
||||
AssetCount = g.Count()
|
||||
}
|
||||
).OrderByDescending(t => t.AssetCount).Take(10).ToList();
|
||||
|
||||
var resolutions = context.Assets
|
||||
.Where(a => a.DeletedAt == null && a.ResolutionWidth > 0 && a.ResolutionHeight > 0)
|
||||
.Select(a => new { a.ResolutionWidth, a.ResolutionHeight })
|
||||
.ToList();
|
||||
|
||||
dto.ResolutionDistribution = resolutions
|
||||
.GroupBy(r => ResolveBucket(r.ResolutionWidth, r.ResolutionHeight))
|
||||
.Select(g => new ResolutionBucketDto { Label = g.Key, Count = g.Count() })
|
||||
.OrderBy(r => r.Count)
|
||||
.ToList();
|
||||
|
||||
var mimeTypes = context.Assets
|
||||
var assetStats = await context.Assets
|
||||
.Where(a => a.DeletedAt == null)
|
||||
.Select(a => a.MimeType)
|
||||
.ToList();
|
||||
.GroupBy(a => 1)
|
||||
.Select(g => new {
|
||||
Total = g.Count(),
|
||||
TotalStorage = g.Sum(a => (long?)a.FileSize) ?? 0,
|
||||
Public = g.Count(a => a.Visibility == EVisibility.Public),
|
||||
Protected = g.Count(a => a.Visibility == EVisibility.Protected),
|
||||
Private = g.Count(a => a.Visibility == EVisibility.Private),
|
||||
Orphan = g.Count(a => a.FolderId == null),
|
||||
Added7d = g.Count(a => a.CreatedAt >= now.AddDays(-7)),
|
||||
Added30d = g.Count(a => a.CreatedAt >= now.AddDays(-30)),
|
||||
MissingMetadata = g.Count(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0),
|
||||
MissingThumbnail = g.Count(a => a.ThumbnailPath == null || a.ThumbnailPath == ""),
|
||||
MissingThumbnailStale = expectedThumbnailSize > 0
|
||||
? g.Count(a => a.ThumbnailPath != null && a.ThumbnailPath != "" && a.ThumbnailSize != expectedThumbnailSize)
|
||||
: 0,
|
||||
MissingPreview = g.Count(a => a.PreviewPath == null || a.PreviewPath == ""),
|
||||
MissingPreviewStale = expectedPreviewSize > 0
|
||||
? g.Count(a => a.PreviewPath != null && a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize)
|
||||
: 0,
|
||||
MissingPhash = g.Count(a => a.Hash == emptyHash)
|
||||
})
|
||||
.OrderBy(x => 1)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
dto.FileFormatBreakdown = mimeTypes
|
||||
.GroupBy(m => m)
|
||||
if (assetStats is not null) {
|
||||
dto.TotalAssets = assetStats.Total;
|
||||
dto.TotalStorageBytes = assetStats.TotalStorage;
|
||||
dto.PublicAssets = assetStats.Public;
|
||||
dto.ProtectedAssets = assetStats.Protected;
|
||||
dto.PrivateAssets = assetStats.Private;
|
||||
dto.OrphanAssets = assetStats.Orphan;
|
||||
dto.AssetsAddedLast7Days = assetStats.Added7d;
|
||||
dto.AssetsAddedLast30Days = assetStats.Added30d;
|
||||
dto.AssetsMissingMetadata = assetStats.MissingMetadata;
|
||||
dto.AssetsMissingThumbnail = assetStats.MissingThumbnail;
|
||||
dto.AssetsMissingThumbnailStale = assetStats.MissingThumbnailStale;
|
||||
dto.AssetsMissingPreviews = assetStats.MissingPreview;
|
||||
dto.AssetsMissingPreviewsStale = assetStats.MissingPreviewStale;
|
||||
dto.AssetsMissingPhash = assetStats.MissingPhash;
|
||||
}
|
||||
|
||||
var byType = await context.Assets
|
||||
.Where(a => a.DeletedAt == null)
|
||||
.GroupBy(a => a.Type)
|
||||
.Select(g => new { g.Key, Count = g.Count(), Size = g.Sum(a => (long?)a.FileSize) ?? 0 })
|
||||
.ToListAsync(cancellationToken);
|
||||
dto.AssetsByType = byType.ToDictionary(x => x.Key, x => x.Count);
|
||||
dto.StorageByType = byType.ToDictionary(x => x.Key, x => x.Size);
|
||||
|
||||
var userStats = await context.Users
|
||||
.Where(u => u.DeletedAt == null)
|
||||
.GroupBy(u => u.AccessLevel)
|
||||
.Select(g => new { g.Key, Count = g.Count(), Registered30 = g.Count(u => u.CreatedAt >= now.AddDays(-30)) })
|
||||
.ToListAsync(cancellationToken);
|
||||
dto.UsersByAccessLevel = userStats.ToDictionary(x => x.Key, x => x.Count);
|
||||
dto.TotalUsers = userStats.Sum(x => x.Count);
|
||||
dto.UsersRegisteredLast30Days = userStats.Sum(x => x.Registered30);
|
||||
|
||||
dto.TotalAlbums = await context.Albums.CountAsync(cancellationToken);
|
||||
dto.TotalTags = await context.Tags.CountAsync(cancellationToken);
|
||||
dto.TotalPeople = await context.People.CountAsync(cancellationToken);
|
||||
dto.TotalFaces = await context.Faces.CountAsync(cancellationToken);
|
||||
dto.TotalFolders = await context.Folders.CountAsync(cancellationToken);
|
||||
dto.AlbumsMissingCover = await context.Albums.CountAsync(a => a.CoverAssetId == null, cancellationToken);
|
||||
dto.CosplayersMissingProfile = await context.People.CountAsync(p => p.ProfileAssetId == null, cancellationToken);
|
||||
|
||||
var albumAsset = context.Set<Dictionary<string, object>>("AlbumAsset");
|
||||
var liveAssetIds = context.Assets.Where(a => a.DeletedAt == null).Select(a => a.Id);
|
||||
|
||||
var assetsInAnyAlbum = await albumAsset
|
||||
.Select(aa => EF.Property<Guid>(aa, "AssetsId"))
|
||||
.Where(id => liveAssetIds.Contains(id))
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
dto.AssetsWithNoAlbum = Math.Max(0, dto.TotalAssets - assetsInAnyAlbum);
|
||||
|
||||
var personAlbumIds = context.Albums
|
||||
.Where(al => al.PersonOwnerId != null)
|
||||
.Select(al => al.Id);
|
||||
var assetsInPersonAlbum = await albumAsset
|
||||
.Where(aa => personAlbumIds.Contains(EF.Property<Guid>(aa, "AlbumsId")))
|
||||
.Select(aa => EF.Property<Guid>(aa, "AssetsId"))
|
||||
.Where(id => liveAssetIds.Contains(id))
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
dto.AssetsWithNoPerson = Math.Max(0, dto.TotalAssets - assetsInPersonAlbum);
|
||||
|
||||
dto.TopTags = await context.Tags
|
||||
.Select(t => new TagStatDto {
|
||||
Id = t.Id,
|
||||
Name = t.Name,
|
||||
AssetCount = t.Assets!.Count(a => a.DeletedAt == null)
|
||||
})
|
||||
.OrderByDescending(t => t.AssetCount)
|
||||
.Take(10)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
dto.FileFormatBreakdown = await context.Assets
|
||||
.Where(a => a.DeletedAt == null)
|
||||
.GroupBy(a => a.MimeType)
|
||||
.Select(g => new MimeTypeStatDto { MimeType = g.Key, Count = g.Count() })
|
||||
.OrderByDescending(m => m.Count)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var maxDims = await context.Assets
|
||||
.Where(a => a.DeletedAt == null && a.ResolutionWidth > 0 && a.ResolutionHeight > 0)
|
||||
.GroupBy(a => a.ResolutionWidth > a.ResolutionHeight ? a.ResolutionWidth : a.ResolutionHeight)
|
||||
.Select(g => new { MaxDim = g.Key, Count = g.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
dto.ResolutionDistribution = maxDims
|
||||
.GroupBy(x => ResolveBucket(x.MaxDim))
|
||||
.Select(g => new ResolutionBucketDto { Label = g.Key, Count = g.Sum(x => x.Count) })
|
||||
.OrderBy(r => r.Count)
|
||||
.ToList();
|
||||
|
||||
var twelveMonthsAgo = now.AddMonths(-12);
|
||||
|
||||
var monthlyAssets = context.Assets
|
||||
var monthlyAssets = await context.Assets
|
||||
.Where(a => a.DeletedAt == null && a.CreatedAt >= twelveMonthsAgo)
|
||||
.GroupBy(a => new { a.CreatedAt.Year, a.CreatedAt.Month })
|
||||
.Select(g => new { g.Key.Year, g.Key.Month, Count = g.Count() })
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var monthlyUsers = context.Users
|
||||
var monthlyUsers = await context.Users
|
||||
.Where(u => u.CreatedAt >= twelveMonthsAgo)
|
||||
.GroupBy(u => new { u.CreatedAt.Year, u.CreatedAt.Month })
|
||||
.Select(g => new { g.Key.Year, g.Key.Month, Count = g.Count() })
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var months = Enumerable.Range(0, 12)
|
||||
.Select(i => twelveMonthsAgo.AddMonths(i + 1))
|
||||
@@ -163,17 +189,11 @@ public class StatsRepository(LactoseDbContext context, ISettingsRepository setti
|
||||
return dto;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => context.Dispose();
|
||||
|
||||
private static string ResolveBucket(int width, int height) {
|
||||
int maxDim = Math.Max(width, height);
|
||||
return maxDim switch {
|
||||
<= 480 => "SD (≤480p)",
|
||||
<= 720 => "HD (≤720p)",
|
||||
<= 1080 => "Full HD (≤1080p)",
|
||||
<= 2160 => "4K (≤2160p)",
|
||||
_ => "4K+"
|
||||
};
|
||||
}
|
||||
}
|
||||
private static string ResolveBucket(int maxDim) => maxDim switch {
|
||||
<= 480 => "SD (≤480p)",
|
||||
<= 720 => "HD (≤720p)",
|
||||
<= 1080 => "Full HD (≤1080p)",
|
||||
<= 2160 => "4K (≤2160p)",
|
||||
_ => "4K+"
|
||||
};
|
||||
}
|
||||
@@ -50,7 +50,7 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
||||
FolderRepository.FolderRemoved += OnFolderRemoved;
|
||||
// defaults to 30 minutes
|
||||
int.TryParse(settingsRepository.Get(Settings.FolderScanInterval.AsString())?.Value ?? "30", out var interval);
|
||||
fileSystemCrawl = new Timer(QueueFileSystemCrawl, null, TimeSpan.Zero, TimeSpan.FromMinutes(interval));
|
||||
fileSystemCrawl = new Timer(QueueFileSystemCrawlTimer, null, TimeSpan.Zero, TimeSpan.FromMinutes(interval));
|
||||
logger?.LogInformation($"Periodic folder scanning is now enabled every {interval} minutes.");
|
||||
|
||||
var folderRepository = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
|
||||
@@ -145,10 +145,11 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a file system crawl job for each active folder.
|
||||
/// Queues a file system crawl job for each selected (or active) folder.
|
||||
/// </summary>
|
||||
/// <param name="state">Unused state parameter (compatible with Timer callback).</param>
|
||||
internal void QueueFileSystemCrawl(object? state) {
|
||||
/// <param name="deep">When true, ignores recorded scan state and reconciles every asset.</param>
|
||||
/// <param name="folderIds">Optional folders to scan. When null/empty, all active folders are scanned.</param>
|
||||
internal void QueueFileSystemCrawl(bool deep, IReadOnlyList<Guid>? folderIds = null) {
|
||||
if (jobManager == null) {
|
||||
logger?.LogWarning("JobManager is not available, cannot queue file system crawl.");
|
||||
return;
|
||||
@@ -158,10 +159,37 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
||||
logger?.LogWarning("A file system crawl job is already in progress, cannot queue another at this moment.");
|
||||
return;
|
||||
}
|
||||
// queue a crawl job for each active folder
|
||||
folders?.ForEach(f => { jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath)); });
|
||||
logger?.LogInformation("Queued file system crawl jobs.");
|
||||
|
||||
List<Folder>? targetFolders;
|
||||
if (folderIds is { Count: > 0 }) {
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
|
||||
targetFolders = folderRepo.GetAll().Where(f => folderIds.Contains(f.Id)).ToList();
|
||||
} else {
|
||||
// if the scheduled scan list isn't populated, fetch active folders directly from DB
|
||||
targetFolders = folders;
|
||||
if (targetFolders is not { Count: > 0 }) {
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
|
||||
targetFolders = folderRepo.GetAll().Where(f => f.Active).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
// queue a crawl job for each target folder
|
||||
if (targetFolders is { Count: > 0 }) {
|
||||
foreach (var f in targetFolders)
|
||||
jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath, deep));
|
||||
logger?.LogInformation("Queued {Count} file system crawl job(s) (deep={Deep}).", targetFolders.Count, deep);
|
||||
} else {
|
||||
logger?.LogWarning("No folders to scan.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timer callback for periodic incremental file-system scans.
|
||||
/// </summary>
|
||||
/// <param name="state">Unused state parameter.</param>
|
||||
void QueueFileSystemCrawlTimer(object? state) => QueueFileSystemCrawl(false);
|
||||
|
||||
/// <summary>
|
||||
/// Queues a thumbnail generation job.
|
||||
@@ -236,27 +264,11 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a create persons job.
|
||||
/// Queues a create persons & albums (discovery) job.
|
||||
/// </summary>
|
||||
/// <param name="o">Unused state parameter.</param>
|
||||
internal void QueueCreatePersons(object? o) {
|
||||
if (jobManager == null) {
|
||||
logger?.LogWarning("JobManager is not available, cannot queue create persons.");
|
||||
return;
|
||||
}
|
||||
if (jobManager.Jobs.Any(j => j is CreatePersonsJob && j.JobStatus.Status is EJobStatus.Running or EJobStatus.Queued)) {
|
||||
logger?.LogInformation("Create persons job is already in progress, cannot queue another at this moment.");
|
||||
return;
|
||||
}
|
||||
jobManager.EnqueueJob(jobManager.CreateJob<CreatePersonsJob>());
|
||||
logger?.LogInformation("Queued create persons job.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a create albums job.
|
||||
/// </summary>
|
||||
/// <param name="o">Unused state parameter.</param>
|
||||
internal void QueueCreateAlbums(object? o) {
|
||||
/// <param name="deep">When true, ignores the per-folder discovery cursor and scans all assets.</param>
|
||||
/// <param name="folderIds">Optional folders to process. When null/empty, all active folders are processed.</param>
|
||||
internal void QueueCreateAlbums(bool deep, IReadOnlyList<Guid>? folderIds = null) {
|
||||
if (jobManager == null) {
|
||||
logger?.LogWarning("JobManager is not available, cannot queue create albums.");
|
||||
return;
|
||||
@@ -265,8 +277,8 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
||||
logger?.LogInformation("Create albums job is already in progress, cannot queue another at this moment.");
|
||||
return;
|
||||
}
|
||||
jobManager.EnqueueJob(jobManager.CreateJob<CreateAlbumsJob>());
|
||||
logger?.LogInformation("Queued create albums job.");
|
||||
jobManager.EnqueueJob(jobManager.CreateJob<CreateAlbumsJob>(deep, folderIds!));
|
||||
logger?.LogInformation("Queued create albums job (deep={Deep}).", deep);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
"UserID": "root",
|
||||
"Password": "testOnlyDb",
|
||||
"Database": "TestDb",
|
||||
"connString":"Server=127.0.0.1;Port=3306;Database=TestDb;User Id=root;Password=testOnlyDb;"
|
||||
"connString":"Server=127.0.0.1;Port=5432;Database=TestDb;User Id=root;Password=testOnlyDb;"
|
||||
},
|
||||
"DatabaseAddress": {
|
||||
"Host": "localhost",
|
||||
"Port": 3306
|
||||
"Port": 5432
|
||||
},
|
||||
"SignKey": {
|
||||
"Key": "32_CHARACTERS_KEY_IS_REQUIRED_TO_WORK"
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
@* Generic collapsible section with clickable header and chevron toggle *@
|
||||
<div class="@Class">
|
||||
<div class="d-flex align-items-center gap-2 cursor-pointer" @onclick="Toggle" role="button">
|
||||
@if (TitleContent is not null) {
|
||||
@TitleContent
|
||||
} else if (!string.IsNullOrEmpty(Title)) {
|
||||
<span>@Title</span>
|
||||
}
|
||||
<span class="ms-auto small @(Expanded ? "bi-chevron-up" : "bi-chevron-down")"></span>
|
||||
</div>
|
||||
@if (Expanded) {
|
||||
<div class="@ContentClass">
|
||||
@ChildContent
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Title { get; set; }
|
||||
[Parameter] public RenderFragment? TitleContent { get; set; }
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
[Parameter] public bool InitiallyExpanded { get; set; }
|
||||
[Parameter] public string? Class { get; set; }
|
||||
[Parameter] public string? ContentClass { get; set; }
|
||||
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
|
||||
|
||||
bool Expanded { get; set; }
|
||||
|
||||
protected override void OnInitialized() {
|
||||
Expanded = InitiallyExpanded;
|
||||
}
|
||||
|
||||
async Task Toggle() {
|
||||
Expanded = !Expanded;
|
||||
await ExpandedChanged.InvokeAsync(Expanded);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
@using Butter.Dtos.Folder
|
||||
@using Butter.Dtos.Jobs
|
||||
@using System.Text.RegularExpressions
|
||||
@inject FoldersService FoldersService
|
||||
@inject JobsService JobsService
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
@@ -16,6 +18,18 @@
|
||||
<i class="bi bi-regex"></i> @(string.IsNullOrEmpty(Folder.RegexPattern) ? "" : "on")
|
||||
</button>
|
||||
|
||||
@* Scan dropdown *@
|
||||
<div class="btn-group btn-group-sm @Display">
|
||||
<button class="btn btn-job-scan" @onclick="() => OnScan(false)" title="Incremental scan">
|
||||
<i class="bi bi-folder2-open"></i>
|
||||
</button>
|
||||
<button class="btn btn-job-scan dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false" title="Scan options"></button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" @onclick="() => OnScan(false)"><i class="bi bi-folder2-open me-1"></i>Scan (incremental)</a></li>
|
||||
<li><a class="dropdown-item" @onclick="() => OnScan(true)"><i class="bi bi-arrow-repeat me-1"></i>Deep scan</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="ms-auto form-switch">
|
||||
<input class="form-check-input m-1" type="checkbox" id="@Folder.Id" @bind="@Folder.Active" @bind:after="() => OnToggleActive()"/>
|
||||
</div>
|
||||
@@ -23,6 +37,13 @@
|
||||
<button class="btn btn-danger btn-sm mx-1" @onclick="OnDelete"><i class="bi bi-folder-minus"></i></button>
|
||||
</div>
|
||||
|
||||
@if (_scanError != null) {
|
||||
<div class="alert alert-danger py-1 px-2 mb-0 mt-1 small">@_scanError</div>
|
||||
}
|
||||
@if (_scanBusy) {
|
||||
<div class="small text-muted py-1"><span class="spinner-border spinner-border-sm me-1"></span>Enqueuing scan...</div>
|
||||
}
|
||||
|
||||
@* Pattern section (collapsible) *@
|
||||
@if (_patternExpanded)
|
||||
{
|
||||
@@ -103,7 +124,9 @@
|
||||
@code {
|
||||
bool _editing;
|
||||
bool _patternExpanded;
|
||||
bool _scanBusy;
|
||||
string _localPattern = string.Empty;
|
||||
string? _scanError;
|
||||
List<string>? _samplePaths;
|
||||
string Display => _editing ? "d-none" : "";
|
||||
string Edit => _editing ? "" : "d-none";
|
||||
@@ -113,8 +136,6 @@
|
||||
|
||||
[Parameter]
|
||||
public FolderFullDto Folder { get; set; } = new FolderFullDto();
|
||||
[Parameter]
|
||||
public EventCallback<Guid> OnDeleted { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> OnDeleteRequested { get; set; }
|
||||
@@ -159,6 +180,27 @@
|
||||
await OnDeleteRequested.InvokeAsync(Folder.Id);
|
||||
}
|
||||
|
||||
async Task OnScan(bool deep)
|
||||
{
|
||||
if (_scanBusy) return;
|
||||
_scanBusy = true;
|
||||
_scanError = null;
|
||||
try
|
||||
{
|
||||
var parameters = new List<string> { $"folder={Folder.Id}" };
|
||||
if (deep) parameters.Add("deep");
|
||||
await JobsService.StartJob(EJobType.FileSystemScan, parameters);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_scanError = $"Could not start scan: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scanBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnToggleActive()
|
||||
{
|
||||
await FoldersService.UpdateFolderAsync(
|
||||
|
||||
@@ -106,13 +106,13 @@
|
||||
[Parameter] public IReadOnlyList<JobStatusDto>? Children { get; set; }
|
||||
|
||||
static readonly Dictionary<EJobStatus, string> _statusColors = new() {
|
||||
[EJobStatus.Queued] = "secondary",
|
||||
[EJobStatus.Running] = "primary",
|
||||
[EJobStatus.Waiting] = "info",
|
||||
[EJobStatus.Completed] = "success",
|
||||
[EJobStatus.CompletedWithErrors] = "warning",
|
||||
[EJobStatus.Failed] = "danger",
|
||||
[EJobStatus.Canceled] = "secondary",
|
||||
[EJobStatus.Queued] = "var(--status-queued)",
|
||||
[EJobStatus.Running] = "var(--status-running)",
|
||||
[EJobStatus.Waiting] = "var(--status-waiting)",
|
||||
[EJobStatus.Completed] = "var(--status-completed)",
|
||||
[EJobStatus.CompletedWithErrors] = "var(--status-completed-with-errors)",
|
||||
[EJobStatus.Failed] = "var(--status-failed)",
|
||||
[EJobStatus.Canceled] = "var(--status-canceled)",
|
||||
};
|
||||
|
||||
static readonly EJobStatus[] _segmentOrder = [
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
@* Section wrapper for a group of jobs with title and count *@
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<h6 class="mb-0 text-muted text-uppercase small">@Title @(Count is not null ? $"({Count})" : "")</h6>
|
||||
@if (Extra is not null) {
|
||||
<span class="ms-auto">@Extra</span>
|
||||
}
|
||||
</div>
|
||||
<div class="border rounded">
|
||||
@ChildContent
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Title { get; set; }
|
||||
[Parameter] public int? Count { get; set; }
|
||||
[Parameter] public RenderFragment? Extra { get; set; }
|
||||
[Parameter] public required RenderFragment ChildContent { get; set; }
|
||||
}
|
||||
@@ -151,7 +151,7 @@
|
||||
job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting
|
||||
);
|
||||
if (shouldFetch) {
|
||||
var (children, since) = await FetchChildren(job.Id, null);
|
||||
var (children, since) = await FetchChildren!(job.Id, null);
|
||||
_childrenCache[job.Id] = children;
|
||||
_sinceTimestamps[job.Id] = since ?? DateTime.UtcNow;
|
||||
}
|
||||
@@ -200,7 +200,7 @@
|
||||
|
||||
// Level 0: poll children for all active roots (segment progress update)
|
||||
if (Level == 0 && FetchChildren is not null) {
|
||||
foreach (var root in Roots) {
|
||||
foreach (var root in Roots!) {
|
||||
if (root.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting) {
|
||||
hasActive = true;
|
||||
visited.Add(root.Id);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@implements IDisposable
|
||||
@using Butter.Dtos.User
|
||||
@using Microsoft.IdentityModel.JsonWebTokens
|
||||
@using System.Reflection
|
||||
|
||||
<NavMenu/>
|
||||
<main class="container-xxl flex-grow-1">
|
||||
@@ -12,7 +13,7 @@
|
||||
</div>
|
||||
</main>
|
||||
<footer class="container-xxl footer d-none d-sm-flex justify-content-center">
|
||||
<span class="text-primary m-auto">Footer (duh!)</span>
|
||||
<span class="text-primary m-auto">MilkyShots - © 2024 - @DateTime.Now.Year | @AppVersion</span>
|
||||
</footer>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
@@ -23,6 +24,9 @@
|
||||
|
||||
@code {
|
||||
CancellationTokenSource? _keepaliveCts;
|
||||
string AppVersion => System.Reflection.Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion ?? "unknown";
|
||||
|
||||
protected override void OnInitialized() {
|
||||
LoginService.ForceLogout += OnForceLogout;
|
||||
|
||||
@@ -43,15 +43,15 @@
|
||||
</button>
|
||||
<button class="btn btn-light dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" @onclick="OnProfileClick"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
|
||||
<li><a class="dropdown-item" href="@($"/User/{loginService.AuthInfo?.UserId}")"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
|
||||
@if(AdminOrCurator) {
|
||||
<li><a class="dropdown-item" @onclick="OnStatsClick"><i class="bi bi-bar-chart-fill"></i> Statistics</a></li>
|
||||
<li><a class="dropdown-item" href="/stats"><i class="bi bi-bar-chart-fill"></i> Statistics</a></li>
|
||||
}
|
||||
@if(Admin) {
|
||||
<li><a class="dropdown-item" @onclick="OnMaintenanceClick"><i class="bi bi-tools"></i> Maintenance</a></li>
|
||||
<li><a class="dropdown-item" @onclick="OnUsersClick"><i class="bi bi-people-fill"></i> Users</a></li>
|
||||
<li><a class="dropdown-item" @onclick="OnSettingsClick"><i class="bi bi-gear-wide-connected"></i> Settings</a></li>
|
||||
<li><a class="dropdown-item" @onclick="OnJobsClick"><i class="bi bi-kanban-fill"></i> Jobs</a></li>
|
||||
<li><a class="dropdown-item" href="/maintenance"><i class="bi bi-tools"></i> Maintenance</a></li>
|
||||
<li><a class="dropdown-item" href="/users"><i class="bi bi-people-fill"></i> Users</a></li>
|
||||
<li><a class="dropdown-item" href="/settings"><i class="bi bi-gear-wide-connected"></i> Settings</a></li>
|
||||
<li><a class="dropdown-item" href="/jobs"><i class="bi bi-kanban-fill"></i> Jobs</a></li>
|
||||
}
|
||||
<li><a class="dropdown-item" @onclick="OnLogout"><i class="bi bi-door-closed"></i> Logout</a></li>
|
||||
</ul>
|
||||
@@ -78,15 +78,6 @@
|
||||
|
||||
void OnRegisterClick() => navigation.NavigateTo("/register");
|
||||
|
||||
void OnSettingsClick() => navigation.NavigateTo("/settings");
|
||||
|
||||
void OnJobsClick() => navigation.NavigateTo("/jobs");
|
||||
|
||||
void OnMaintenanceClick() => navigation.NavigateTo("/maintenance");
|
||||
void OnStatsClick() => navigation.NavigateTo("/stats");
|
||||
|
||||
void OnUsersClick() => navigation.NavigateTo("/users");
|
||||
|
||||
void OnProfileClick() => navigation.NavigateTo($"/User/{loginService.AuthInfo?.UserId}", true);
|
||||
|
||||
async Task OnLogout() {
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
@using Butter.Dtos.Album
|
||||
@using Butter.Dtos.Asset
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@inject AlbumService albumService
|
||||
@inject AssetService assetService
|
||||
@inject LoginService loginService
|
||||
@inject IJSRuntime jsRuntime
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject MediaService mediaService
|
||||
@inject NavigationManager navigationManager
|
||||
|
||||
<PageTitle>@(album?.Name ?? "Album")</PageTitle>
|
||||
@@ -19,7 +18,7 @@
|
||||
} else if (album == null) {
|
||||
<EmptyState Variant="danger" Title="Album not found">
|
||||
<p>The album you are looking for does not exist or you do not have access to it.</p>
|
||||
<button class="btn btn-danger" @onclick="GoBack">Back to Albums</button>
|
||||
<a class="btn btn-danger" href="/albums">Back to Albums</a>
|
||||
</EmptyState>
|
||||
} else {
|
||||
<div class="album-detail">
|
||||
@@ -37,7 +36,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 mt-1">
|
||||
@if (!string.IsNullOrEmpty(album.PersonName) && album.Person.HasValue) {
|
||||
@if (!string.IsNullOrEmpty(album!.PersonName) && album.Person.HasValue) {
|
||||
<a class="album-cosplayer-link" href="/cosplayer/@album.Person.Value">@album.PersonName</a>
|
||||
}
|
||||
<span class="text-muted small">@album.AssetCount assets</span>
|
||||
@@ -143,7 +142,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (album.Images.Count == 0) {
|
||||
@if (album!.Images.Count == 0) {
|
||||
<EmptyState Variant="info" Title="No assets in this album" />
|
||||
} else {
|
||||
<div class="@("album-assets-grid " + viewMode)">
|
||||
@@ -157,7 +156,7 @@
|
||||
style="aspect-ratio:@GetAspectRatio(preview)"
|
||||
@onclick="() => HandleTileClick(index, assetId)">
|
||||
@if (preview == null || preview.HasThumbnail) {
|
||||
<img src="@ThumbUrl(assetId)"
|
||||
<img src="@mediaService.ThumbUrl(assetId)"
|
||||
loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none">
|
||||
@@ -249,11 +248,8 @@
|
||||
HashSet<Guid> selectedImageIds = [];
|
||||
Guid? selectedAssetId;
|
||||
int selectedIndex = -1;
|
||||
string token = string.Empty;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
string viewMode = "masonry";
|
||||
bool _masonrySetup;
|
||||
|
||||
void SetMasonry() { viewMode = "masonry"; StateHasChanged(); }
|
||||
void SetGrid() { viewMode = "grid"; StateHasChanged(); }
|
||||
@@ -292,21 +288,21 @@
|
||||
|
||||
protected override async Task OnParametersSetAsync() {
|
||||
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
|
||||
token = loginService.AuthInfo?.Token ?? string.Empty;
|
||||
await LoadAlbum();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (album != null && album.Images.Count > 0 && viewMode == "masonry") {
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.apply", ".album-assets-grid.masonry");
|
||||
if (viewMode == "masonry" && !_masonrySetup && album != null && album.Images.Count > 0) {
|
||||
_masonrySetup = true;
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.setup", ".album-assets-grid.masonry");
|
||||
} else if (viewMode != "masonry" && _masonrySetup) {
|
||||
_masonrySetup = false;
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-assets-grid.masonry");
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnInitialized() {
|
||||
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
|
||||
loginService.AuthInfoChanged += (_, info) => {
|
||||
token = info?.Token ?? string.Empty;
|
||||
};
|
||||
}
|
||||
|
||||
async Task LoadAlbum() {
|
||||
@@ -499,10 +495,8 @@
|
||||
return "4 / 3";
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.dispose", ".album-assets-grid.masonry");
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-assets-grid.masonry");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
|
||||
.album-assets-grid.masonry .masonry-tile {
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
.album-assets-grid.grid .masonry-tile {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
@using Butter.Dtos.Asset
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@using System.Globalization
|
||||
@inject PersonService personService
|
||||
@inject AlbumService albumService
|
||||
@inject AssetService assetService
|
||||
@inject LoginService loginService
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject MediaService mediaService
|
||||
@inject NavigationManager navigationManager
|
||||
|
||||
<PageTitle>@(person?.Name ?? "Cosplayer")</PageTitle>
|
||||
@@ -40,7 +39,7 @@
|
||||
var cy = person.ProfileCropY ?? 50;
|
||||
var z = person.ProfileCropZoom ?? 0.6f;
|
||||
<div class="cosplayer-avatar-clip">
|
||||
<img src="@ThumbUrl(person.ProfileAssetId.Value)" alt=""
|
||||
<img src="@mediaService.ThumbUrl(person.ProfileAssetId.Value)" alt=""
|
||||
class="cosplayer-avatar-img"
|
||||
style="width: calc(100% / @z.ToString(CultureInfo.InvariantCulture)); transform: translate(calc(-@cx.ToString(CultureInfo.InvariantCulture) * 1%), calc(-@cy.ToString(CultureInfo.InvariantCulture) * 1%))" />
|
||||
</div>
|
||||
@@ -60,7 +59,7 @@
|
||||
<span class="text-muted small">@pub public, @prot protected, @priv private</span>
|
||||
}
|
||||
<span class="mx-1">·</span>
|
||||
<span><i class="bi bi-image"></i> @person.TotalAssets assets</span>
|
||||
<span><i class="bi bi-image"></i> @person!.TotalAssets assets</span>
|
||||
@if (person.MaintainerUserIds?.Count > 0)
|
||||
{
|
||||
<span class="mx-1">·</span>
|
||||
@@ -222,7 +221,8 @@
|
||||
SelectedIds="@selectedAlbumIds"
|
||||
OnToggleSelection="@ToggleSelectAlbum"
|
||||
LoadVersion="@albumLoadVersion"
|
||||
FetchAlbums="@FetchPersonAlbums" />
|
||||
FetchAlbums="@FetchPersonAlbums"
|
||||
OnDataLoaded="@OnGridAlbumsLoaded" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -316,8 +316,6 @@
|
||||
Func<Task>? _pendingDeleteAction;
|
||||
bool cascadeToAssets;
|
||||
EVisibility selectedAlbumVisibility = EVisibility.Public;
|
||||
string token = string.Empty;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
string viewMode = "masonry";
|
||||
bool showAlbumAssigner;
|
||||
List<AlbumPreviewDto>? unassignedAlbums;
|
||||
@@ -340,8 +338,6 @@
|
||||
|
||||
int albumLoadVersion;
|
||||
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
|
||||
List<Guid>? allAssetIds => person?.Albums?
|
||||
.SelectMany(a => Enumerable.Repeat(a.CoverAssetId ?? Guid.Empty, 1))
|
||||
.Where(id => id != Guid.Empty)
|
||||
@@ -349,32 +345,30 @@
|
||||
.ToList();
|
||||
|
||||
protected override async Task OnParametersSetAsync() {
|
||||
token = loginService.AuthInfo?.Token ?? string.Empty;
|
||||
await LoadPerson();
|
||||
}
|
||||
|
||||
protected override void OnInitialized() {
|
||||
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
|
||||
loginService.AuthInfoChanged += (_, info) => {
|
||||
token = info?.Token ?? string.Empty;
|
||||
};
|
||||
}
|
||||
|
||||
async Task LoadPerson() {
|
||||
isLoading = true;
|
||||
person = await personService.GetByIdAsync(Id, 0, 30);
|
||||
bannerCoverUrls = person?.Albums?
|
||||
.Where(a => a.CoverAssetId.HasValue)
|
||||
.OrderBy(_ => Random.Shared.Next())
|
||||
.Take(3)
|
||||
.Select(a => ThumbUrl(a.CoverAssetId!.Value))
|
||||
.ToList() ?? [];
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async Task OnGridAlbumsLoaded(List<AlbumPreviewDto> albums) {
|
||||
bannerCoverUrls = albums
|
||||
.Where(a => a.CoverAssetId.HasValue)
|
||||
.OrderBy(_ => Random.Shared.Next())
|
||||
.Take(3)
|
||||
.Select(a => mediaService.ThumbUrl(a.CoverAssetId!.Value))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
async Task<List<AlbumPreviewDto>?> FetchPersonAlbums(int page, int pageSize, string? search, string? sortBy, bool sortAsc) {
|
||||
var p = await personService.GetByIdAsync(Id, page, pageSize, search, sortBy, sortAsc);
|
||||
return p?.Albums;
|
||||
return await albumService.GetAlbumsAsync(page, pageSize, search, sortBy, sortAsc, personOwnerId: Id);
|
||||
}
|
||||
|
||||
void OpenEditForm() => showForm = true;
|
||||
@@ -519,6 +513,4 @@
|
||||
if (_pendingDeleteAction != null)
|
||||
await _pendingDeleteAction();
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
@page "/"
|
||||
@using Butter.Dtos.Asset
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@inject AssetService assetService
|
||||
@inject LoginService loginService
|
||||
@inject IJSRuntime jsRuntime
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject IJSRuntime jsRuntime
|
||||
@inject MediaService mediaService
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
@@ -36,7 +35,7 @@
|
||||
style="aspect-ratio:@GetAspectRatio(asset)"
|
||||
@onclick="() => OpenPreview(asset, capturedIndex)">
|
||||
@if (asset.HasThumbnail) {
|
||||
<img src="@ThumbUrl(asset.Id)"
|
||||
<img src="@mediaService.ThumbUrl(asset.Id)"
|
||||
loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none">
|
||||
@@ -136,18 +135,10 @@
|
||||
ElementReference topSentinelRef;
|
||||
DotNetObjectReference<Home>? dotNetRef;
|
||||
Guid _randomSeed;
|
||||
string token = string.Empty;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
|
||||
loginService.AuthInfoChanged += (_, info) => {
|
||||
token = info?.Token ?? string.Empty;
|
||||
StateHasChanged();
|
||||
};
|
||||
token = loginService.AuthInfo?.Token ?? string.Empty;
|
||||
loginService.AuthInfoChanged += (_, _) => StateHasChanged();
|
||||
await LoadFirstPage();
|
||||
}
|
||||
|
||||
@@ -171,7 +162,7 @@
|
||||
async Task LoadFirstPage() {
|
||||
_randomSeed = Guid.NewGuid();
|
||||
isLoading = true;
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, 0, 30);
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, 0, 30, includeCount: false);
|
||||
if (items?.Count > 0) {
|
||||
allAssetPages.Add(items);
|
||||
RebuildFlatList();
|
||||
@@ -190,7 +181,7 @@
|
||||
isLoadingMore = true;
|
||||
StateHasChanged();
|
||||
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, currentPage, 30);
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, currentPage, 30, includeCount: false);
|
||||
if (items?.Count > 0) {
|
||||
allAssetPages.Add(items);
|
||||
loadedMaxPage = currentPage;
|
||||
@@ -221,7 +212,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, pageToLoad, 30);
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, pageToLoad, 30, includeCount: false);
|
||||
if (items?.Count > 0) {
|
||||
allAssetPages.Insert(0, items);
|
||||
loadedMinPage = pageToLoad;
|
||||
@@ -257,6 +248,7 @@
|
||||
}
|
||||
|
||||
void NavigatePreview(int direction) {
|
||||
if (selectedAsset == null) return;
|
||||
var newIndex = selectedIndex + direction;
|
||||
if (newIndex < 0 || newIndex >= flatList.Count) return;
|
||||
var newAsset = flatList[newIndex];
|
||||
@@ -277,8 +269,6 @@
|
||||
return "4 / 3";
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
|
||||
if (dotNetRef != null) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@page "/Jobs"
|
||||
@implements IDisposable
|
||||
@using Butter.Dtos.Jobs
|
||||
@using Butter.Dtos.Folder
|
||||
@using Butter.Types
|
||||
@inject JobsService JobsService
|
||||
@inject FoldersService FoldersService
|
||||
@inject LoginService LoginService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@@ -72,13 +74,12 @@
|
||||
|
||||
<div class="d-none d-sm-flex flex-wrap gap-2 ms-auto">
|
||||
@* Desktop launch buttons *@
|
||||
<button class="btn btn-job-scan btn-sm" @onclick="LaunchScan" title="Scan Files"><i class="bi bi-folder2-open"></i></button>
|
||||
<button class="btn btn-job-scan btn-sm" @onclick="() => OpenLaunchModal(EJobType.FileSystemScan)" title="Scan Files"><i class="bi bi-folder2-open"></i></button>
|
||||
<button class="btn btn-job-meta btn-sm" @onclick="LaunchMeta" title="Extract Metadata"><i class="bi bi-file-earmark-break"></i></button>
|
||||
<button class="btn btn-job-thumb btn-sm" @onclick="LaunchThumb" title="Generate Thumbnails"><i class="bi bi-pip"></i></button>
|
||||
<button class="btn btn-job-preview btn-sm" @onclick="LaunchPreview" title="Generate Previews"><i class="bi bi-images"></i></button>
|
||||
<button class="btn btn-job-phash btn-sm" @onclick="LaunchPHash" title="Calculate pHash"><i class="bi bi-hash"></i></button>
|
||||
<button class="btn btn-job-persons btn-sm" @onclick="LaunchCreatePersons" title="Create Persons"><i class="bi bi-person-plus"></i></button>
|
||||
<button class="btn btn-job-create-albums btn-sm" @onclick="LaunchCreateAlbums" title="Create Albums"><i class="bi bi-collection"></i></button>
|
||||
<button class="btn btn-job-create-albums btn-sm" @onclick="() => OpenLaunchModal(EJobType.CreateAlbums)" title="Create Persons & Albums"><i class="bi bi-collection"></i></button>
|
||||
<button class="btn btn-job-integrity btn-sm" @onclick="LaunchIntegrityCheck" title="Integrity Check"><i class="bi bi-file-earmark-check"></i></button>
|
||||
</div>
|
||||
<div class="d-sm-none dropdown">
|
||||
@@ -86,13 +87,12 @@
|
||||
<i class="bi bi-play-circle me-1"></i>Launch
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" @onclick="LaunchScan"><i class="bi bi-folder2-open me-1" style="color: var(--job-scan)"></i>Scan Files</a></li>
|
||||
<li><a class="dropdown-item" @onclick="() => OpenLaunchModal(EJobType.FileSystemScan)"><i class="bi bi-folder2-open me-1" style="color: var(--job-scan)"></i>Scan Files</a></li>
|
||||
<li><a class="dropdown-item" @onclick="LaunchMeta"><i class="bi bi-file-earmark-break me-1" style="color: var(--job-meta)"></i>Extract Metadata</a></li>
|
||||
<li><a class="dropdown-item" @onclick="LaunchThumb"><i class="bi bi-pip me-1" style="color: var(--job-thumb)"></i>Generate Thumbnails</a></li>
|
||||
<li><a class="dropdown-item" @onclick="LaunchPreview"><i class="bi bi-images me-1" style="color: var(--job-preview)"></i>Generate Previews</a></li>
|
||||
<li><a class="dropdown-item" @onclick="LaunchPHash"><i class="bi bi-hash me-1" style="color: var(--job-phash)"></i>Calculate pHash</a></li>
|
||||
<li><a class="dropdown-item" @onclick="LaunchCreatePersons"><i class="bi bi-person-plus me-1" style="color: var(--job-persons)"></i>Create Persons</a></li>
|
||||
<li><a class="dropdown-item" @onclick="LaunchCreateAlbums"><i class="bi bi-collection me-1" style="color: var(--job-create-albums)"></i>Create Albums</a></li>
|
||||
<li><a class="dropdown-item" @onclick="() => OpenLaunchModal(EJobType.CreateAlbums)"><i class="bi bi-collection me-1" style="color: var(--job-create-albums)"></i>Create Persons & Albums</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" @onclick="LaunchIntegrityCheck"><i class="bi bi-file-earmark-check me-1" style="color: var(--job-integrity)"></i>Integrity Check</a></li>
|
||||
</ul>
|
||||
@@ -131,10 +131,10 @@
|
||||
<JobTree Jobs="_filteredSorted" FetchChildren="FetchChildren" EmptyText="" PollInterval="pollInterval" />
|
||||
@if (pastJobTotal > pastJobPageSize) {
|
||||
<div class="d-flex align-items-center justify-content-center gap-2 py-1 border-top">
|
||||
<button class="btn btn-sm btn-secondary" disabled="@(pastJobPage <= 1)" @onclick="() => LoadPastJobs(pastJobPage - 1)">
|
||||
<button class="btn btn-sm btn-secondary" disabled="@(pastJobPage <= 0)" @onclick="() => LoadPastJobs(pastJobPage - 1)">
|
||||
<i class="bi bi-chevron-left"></i> Prev
|
||||
</button>
|
||||
<span class="small text-muted">@pastJobPage / @pastJobTotalPages</span>
|
||||
<span class="small text-muted">@(pastJobPage + 1) / @pastJobTotalPages</span>
|
||||
<button class="btn btn-sm btn-secondary" disabled="@(pastJobPage >= pastJobTotalPages)" @onclick="() => LoadPastJobs(pastJobPage + 1)">
|
||||
Next <i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
@@ -150,12 +150,48 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<ModalFrame Show="_launchModalOpen" Title="_launchModalTitle" Size="lg" OnClose="() => _launchModalOpen = false">
|
||||
<Body>
|
||||
<div class="mb-3">
|
||||
<label class="form-check d-flex align-items-start gap-2">
|
||||
<input class="form-check-input mt-1" type="checkbox" @bind="_launchDeep" />
|
||||
<span>
|
||||
Deep scan
|
||||
<span class="d-block small text-muted">Ignore previously recorded scan state and reprocess all assets.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="small text-muted mb-1">Folders (none selected = all active folders):</div>
|
||||
@if (_launchFolders.Count == 0) {
|
||||
<div class="text-muted small">No folders configured.</div>
|
||||
} else {
|
||||
<div class="job-folder-list border rounded p-2" style="max-height: 320px; overflow-y: auto;">
|
||||
@foreach (var f in _launchFolders) {
|
||||
<label class="form-check d-flex align-items-center gap-2 mb-1">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
checked="@(_selectedFolderIds.Contains(f.Id))"
|
||||
@onchange="(e) => ToggleFolder(f.Id, (bool)e.Value!)" />
|
||||
<span class="text-truncate" style="max-width: 90%;">@f.BasePath</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</Body>
|
||||
<Footer>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => _launchModalOpen = false">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" disabled="@_launchBusy" @onclick="LaunchFromModal">
|
||||
@if (_launchBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Launch
|
||||
</button>
|
||||
</Footer>
|
||||
</ModalFrame>
|
||||
|
||||
@code {
|
||||
CancellationTokenSource? _pollCts;
|
||||
|
||||
List<JobStatusDto> activeRoots = [];
|
||||
List<JobStatusDto> pastJobRoots = [];
|
||||
int pastJobPage = 1;
|
||||
int pastJobPage = 0;
|
||||
int pastJobPageSize = 15;
|
||||
int pastJobTotal = 0;
|
||||
int pastJobTotalPages => Math.Max(1, (int)Math.Ceiling((double)pastJobTotal / pastJobPageSize));
|
||||
@@ -213,11 +249,11 @@
|
||||
return;
|
||||
}
|
||||
LoginService.LoggedUserChanged += async (_, _) => {
|
||||
await RefreshActiveRoots();
|
||||
await LoadPastJobs(1);
|
||||
await RefreshActiveRoots();
|
||||
await LoadPastJobs(0);
|
||||
};
|
||||
await RefreshActiveRoots();
|
||||
await LoadPastJobs(1);
|
||||
await LoadPastJobs(0);
|
||||
StartPolling();
|
||||
}
|
||||
|
||||
@@ -232,7 +268,7 @@
|
||||
StartPolling();
|
||||
} else {
|
||||
StopPolling();
|
||||
await LoadPastJobs(1);
|
||||
await LoadPastJobs(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,12 +341,68 @@
|
||||
|
||||
// ── Job launch helpers ──
|
||||
|
||||
bool _launchModalOpen;
|
||||
bool _launchDeep;
|
||||
bool _launchBusy;
|
||||
EJobType _launchJobType;
|
||||
List<Guid> _selectedFolderIds = [];
|
||||
List<FolderFullDto> _launchFolders = [];
|
||||
|
||||
string _launchModalTitle => _launchJobType switch {
|
||||
EJobType.FileSystemScan => "Scan Files",
|
||||
EJobType.CreateAlbums => "Create Persons & Albums",
|
||||
_ => "Launch Job"
|
||||
};
|
||||
|
||||
async Task OpenLaunchModal(EJobType jobType) {
|
||||
_launchJobType = jobType;
|
||||
_launchDeep = false;
|
||||
_selectedFolderIds = [];
|
||||
if (_launchFolders.Count == 0) {
|
||||
try {
|
||||
_launchFolders = await FoldersService.GetAllFolders() ?? new List<FolderFullDto>();
|
||||
} catch {
|
||||
_launchFolders = new List<FolderFullDto>();
|
||||
}
|
||||
}
|
||||
_launchModalOpen = true;
|
||||
}
|
||||
|
||||
void ToggleFolder(Guid id, bool selected) {
|
||||
if (selected) {
|
||||
if (!_selectedFolderIds.Contains(id)) _selectedFolderIds.Add(id);
|
||||
} else {
|
||||
_selectedFolderIds.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
async Task LaunchFromModal() {
|
||||
if (_launchBusy) return;
|
||||
_launchBusy = true;
|
||||
try {
|
||||
var parameters = new List<string>();
|
||||
if (_launchDeep) parameters.Add("deep");
|
||||
parameters.AddRange(_selectedFolderIds.Select(id => $"folder={id}"));
|
||||
|
||||
await JobsService.StartJob(_launchJobType, parameters);
|
||||
errorMessage = null;
|
||||
await RefreshActiveRoots();
|
||||
if (pastJobPage != 0) pastJobPage = 0;
|
||||
await LoadPastJobs(pastJobPage);
|
||||
_launchModalOpen = false;
|
||||
} catch (HttpRequestException ex) {
|
||||
errorMessage = $"Failed to start {_launchJobType} job: {ex.Message}";
|
||||
} finally {
|
||||
_launchBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async Task LaunchJob(EJobType jobType) {
|
||||
try {
|
||||
await JobsService.StartJob(jobType);
|
||||
errorMessage = null;
|
||||
await RefreshActiveRoots();
|
||||
if (pastJobPage != 1) pastJobPage = 1;
|
||||
if (pastJobPage != 0) pastJobPage = 0;
|
||||
await LoadPastJobs(pastJobPage);
|
||||
} catch (HttpRequestException ex) when ((int?)ex.StatusCode == 501) {
|
||||
errorMessage = $"{jobType} is not yet implemented on the server.";
|
||||
@@ -320,13 +412,10 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
Task LaunchScan() => LaunchJob(EJobType.FileSystemScan);
|
||||
Task LaunchMeta() => LaunchJob(EJobType.MetadataExtraction);
|
||||
Task LaunchThumb() => LaunchJob(EJobType.ThumbnailGeneration);
|
||||
Task LaunchPreview() => LaunchJob(EJobType.PreviewGeneration);
|
||||
Task LaunchPHash() => LaunchJob(EJobType.PHashGeneration);
|
||||
Task LaunchCreatePersons() => LaunchJob(EJobType.CreatePersons);
|
||||
Task LaunchCreateAlbums() => LaunchJob(EJobType.CreateAlbums);
|
||||
Task LaunchIntegrityCheck() => LaunchJob(EJobType.IntegrityCheck);
|
||||
|
||||
// ── Helpers ──
|
||||
@@ -361,7 +450,7 @@
|
||||
EJobType.PHashGeneration => "Calculate pHash",
|
||||
EJobType.IntegrityCheck => "Integrity Check",
|
||||
EJobType.CreatePersons => "Create Persons",
|
||||
EJobType.CreateAlbums => "Create Albums",
|
||||
EJobType.CreateAlbums => "Create Persons & Albums",
|
||||
_ => t.ToString()
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="align-self-start m-2"><a class="link-primary" href="#">Forgot password?</a></div>
|
||||
<div class="d-flex flex-row justify-content-around w-100">
|
||||
<button class="btn btn-primary m-2" type="button" id="login-button" @onclick="Login_OnClick">Login</button>
|
||||
<button class="btn btn-primary m-2" type="button" id="register-button" @onclick="Register_OnClick">Register</button>
|
||||
<a class="btn btn-primary m-2" id="register-button" href="/register">Register</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,8 +47,6 @@
|
||||
await Login_OnClick();
|
||||
}
|
||||
|
||||
void Register_OnClick() => navigation.NavigateTo("register");
|
||||
|
||||
async Task Login_OnClick() {
|
||||
loginError = null;
|
||||
var result = await loginService.Login(username, password);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@using System.Net
|
||||
@page "/Register"
|
||||
|
||||
@inject LoginService loginService
|
||||
@@ -5,30 +6,38 @@
|
||||
|
||||
<PageTitle>Register</PageTitle>
|
||||
|
||||
<div class="d-flex flex-column mx-sm-auto mx-3">
|
||||
@if (!string.IsNullOrEmpty(_error)) {
|
||||
<div class="m-2 p-1 border-danger border-2 rounded-3 bg-danger-subtle">
|
||||
<div class="text-danger-emphasis m-2">@_error</div>
|
||||
</div>
|
||||
}
|
||||
<form class="d-flex flex-column align-items-center">
|
||||
<input type="text" class="form-control my-1" placeholder="Username" required="required" id="username"
|
||||
@bind="_username" @bind:event="oninput"/>
|
||||
<input type="email" class="form-control my-1" placeholder="Email" required="required" id="email"
|
||||
@bind="_email" @bind:event="oninput"/>
|
||||
@if (_checkingStatus) {
|
||||
<LoadSpinner/>
|
||||
} else if (_registrationDisabled) {
|
||||
<EmptyState Variant="warning" Title="Registration Closed">
|
||||
<p>New user registration is currently disabled on this server.</p>
|
||||
<a href="/login" class="btn btn-primary">Go to Login</a>
|
||||
</EmptyState>
|
||||
} else {
|
||||
<div class="d-flex flex-column mx-sm-auto mx-3">
|
||||
@if (!string.IsNullOrEmpty(_error)) {
|
||||
<div class="m-2 p-1 border-danger border-2 rounded-3 bg-danger-subtle">
|
||||
<div class="text-danger-emphasis m-2">@_error</div>
|
||||
</div>
|
||||
}
|
||||
<form class="d-flex flex-column align-items-center">
|
||||
<input type="text" class="form-control my-1" placeholder="Username" required="required" id="username"
|
||||
@bind="_username" @bind:event="oninput"/>
|
||||
<input type="email" class="form-control my-1" placeholder="Email" required="required" id="email"
|
||||
@bind="_email" @bind:event="oninput"/>
|
||||
|
||||
<PasswordField @ref="_passwordField"
|
||||
@bind-Password="_password"
|
||||
@bind-ConfirmPassword="_confirmPassword" />
|
||||
<PasswordField @ref="_passwordField"
|
||||
@bind-Password="_password"
|
||||
@bind-ConfirmPassword="_confirmPassword" />
|
||||
|
||||
<div class="d-flex flex-row justify-content-around w-100 mt-2">
|
||||
<button class="btn btn-primary m-2" type="button" id="register-button"
|
||||
disabled="@(!CanSubmit)" @onclick="Register_OnClick">Register</button>
|
||||
<button class="btn btn-primary m-2" type="button" id="login-button"
|
||||
@onclick="Login_OnClick">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="d-flex flex-row justify-content-around w-100 mt-2">
|
||||
<button class="btn btn-primary m-2" type="button" id="register-button"
|
||||
disabled="@(!CanSubmit)" @onclick="Register_OnClick">Register</button>
|
||||
<a class="btn btn-primary m-2" id="login-button" href="/login">Login</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
PasswordField? _passwordField;
|
||||
@@ -37,18 +46,27 @@
|
||||
string _password = string.Empty;
|
||||
string _confirmPassword = string.Empty;
|
||||
string _error = string.Empty;
|
||||
bool _checkingStatus = true;
|
||||
bool _registrationDisabled;
|
||||
|
||||
bool CanSubmit => _passwordField?.IsValid == true
|
||||
&& !string.IsNullOrWhiteSpace(_username)
|
||||
&& !string.IsNullOrWhiteSpace(_email);
|
||||
|
||||
void Login_OnClick() => navigation.NavigateTo("login");
|
||||
protected override async Task OnInitializedAsync() {
|
||||
_registrationDisabled = !await loginService.IsRegistrationEnabledAsync();
|
||||
_checkingStatus = false;
|
||||
}
|
||||
|
||||
async Task Register_OnClick() {
|
||||
_error = string.Empty;
|
||||
var ok = await loginService.Register(_username, _email, _password);
|
||||
if (ok) {
|
||||
var status = await loginService.Register(_username, _email, _password);
|
||||
if (status == HttpStatusCode.OK) {
|
||||
navigation.NavigateTo("login");
|
||||
} else if (status == HttpStatusCode.Forbidden) {
|
||||
_error = "Registration is currently disabled.";
|
||||
} else if (status == HttpStatusCode.Conflict) {
|
||||
_error = "Username or email already taken.";
|
||||
} else {
|
||||
_error = "Registration failed. Please try again.";
|
||||
}
|
||||
|
||||
@@ -14,162 +14,181 @@
|
||||
<div class="alert alert-danger mt-3">Failed to load statistics. You may not have the required permissions.</div>
|
||||
} else {
|
||||
<div class="container-fluid p-3">
|
||||
<h3 class="mb-4"><i class="bi bi-bar-chart-fill"></i> Statistics</h3>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h3 class="mb-0"><i class="bi bi-bar-chart-fill"></i> Statistics</h3>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@if (_lastUpdated is not null) {
|
||||
<small class="text-muted">Updated @_lastUpdated.Value.ToLocalTime().ToString("HH:mm:ss")</small>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-primary" disabled="@_refreshing" @onclick="Refresh">
|
||||
@if (_refreshing) {
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
<span>Refreshing…</span>
|
||||
} else {
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||
<span>Refresh</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ---- Top-level counts ---- *@
|
||||
<div class="row row-cols-2 row-cols-md-4 row-cols-lg-7 g-3 mb-4">
|
||||
<div class="col"><div class="card text-bg-primary"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalAssets.ToString("N0")</h5><small>Assets</small></div></div></div>
|
||||
<div class="col"><div class="card text-bg-success"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalUsers.ToString("N0")</h5><small>Users</small></div></div></div>
|
||||
<div class="col"><div class="card text-bg-info"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalAlbums.ToString("N0")</h5><small>Albums</small></div></div></div>
|
||||
<div class="col"><div class="card text-bg-warning"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalTags.ToString("N0")</h5><small>Tags</small></div></div></div>
|
||||
<div class="col"><div class="card text-bg-secondary"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalPeople.ToString("N0")</h5><small>People</small></div></div></div>
|
||||
<div class="col"><div class="card text-bg-dark"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalFaces.ToString("N0")</h5><small>Faces</small></div></div></div>
|
||||
<div class="col"><div class="card text-bg-dark"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalFolders.ToString("N0")</h5><small>Folders</small></div></div></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="primary" Value="@_stats.TotalAssets.ToString("N0")" Label="Assets"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="success" Value="@_stats.TotalUsers.ToString("N0")" Label="Users"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="info" Value="@_stats.TotalAlbums.ToString("N0")" Label="Albums"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="warning" Value="@_stats.TotalTags.ToString("N0")" Label="Tags"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="secondary" Value="@_stats.TotalPeople.ToString("N0")" Label="People"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="dark" Value="@_stats.TotalFaces.ToString("N0")" Label="Faces"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="dark" Value="@_stats.TotalFolders.ToString("N0")" Label="Folders"/></div>
|
||||
</div>
|
||||
|
||||
@* ---- Recent activity ---- *@
|
||||
<div class="row row-cols-1 row-cols-md-3 g-3 mb-4">
|
||||
<div class="col"><div class="card border-primary"><div class="card-body"><h6 class="card-title"><i class="bi bi-calendar-week"></i> Assets (last 7 days)</h6><h4 class="text-primary">@_stats.AssetsAddedLast7Days.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><div class="card border-success"><div class="card-body"><h6 class="card-title"><i class="bi bi-calendar-month"></i> Assets (last 30 days)</h6><h4 class="text-success">@_stats.AssetsAddedLast30Days.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><div class="card border-info"><div class="card-body"><h6 class="card-title"><i class="bi bi-person-plus"></i> New Users (last 30 days)</h6><h4 class="text-info">@_stats.UsersRegisteredLast30Days.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="primary" Icon="bi bi-calendar-week" Label="Assets (last 7 days)" Value="@_stats.AssetsAddedLast7Days.ToString("N0")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="success" Icon="bi bi-calendar-month" Label="Assets (last 30 days)" Value="@_stats.AssetsAddedLast30Days.ToString("N0")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="info" Icon="bi bi-person-plus" Label="New Users (last 30 days)" Value="@_stats.UsersRegisteredLast30Days.ToString("N0")"/></div>
|
||||
</div>
|
||||
|
||||
@* ---- Data completeness indicators ---- *@
|
||||
<div class="row row-cols-2 row-cols-md-4 row-cols-lg-4 g-3 mb-4">
|
||||
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingMetadata.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingMetadata)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing Metadata</small></div></div></div>
|
||||
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingThumbnail.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingThumbnail)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing Thumbnail</small><br/><small class="text-danger">@_stats.AssetsMissingThumbnailStale.ToString("N0") stale</small></div></div></div>
|
||||
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingPreviews.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingPreviews)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing Previews</small><br/><small class="text-danger">@_stats.AssetsMissingPreviewsStale.ToString("N0") stale</small></div></div></div>
|
||||
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingPhash.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingPhash)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing pHash</small></div></div></div>
|
||||
<div class="col"><div class="card border-info h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsWithNoPerson.ToString("N0")</h5><small class="text-info"><i class="bi bi-info-circle"></i> No Person</small></div></div></div>
|
||||
<div class="col"><div class="card border-info h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsWithNoAlbum.ToString("N0")</h5><small class="text-info"><i class="bi bi-info-circle"></i> No Album</small></div></div></div>
|
||||
<div class="col"><div class="card border-danger h-100"><div class="card-body text-center py-3"><h5>@_stats.AlbumsMissingCover.ToString("N0")</h5><small class="text-danger"><i class="bi bi-image"></i> Albums Missing Cover</small></div></div></div>
|
||||
<div class="col"><div class="card border-secondary h-100"><div class="card-body text-center py-3"><h5>@_stats.CosplayersMissingProfile.ToString("N0")</h5><small class="text-muted"><i class="bi bi-person-badge"></i> Cosplayers Missing Profile</small></div></div></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingMetadata.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingMetadata, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing Metadata"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingThumbnail.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingThumbnail, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing Thumbnail" SubLabel="@($"{_stats.AssetsMissingThumbnailStale.ToString("N0")} stale")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingPreviews.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingPreviews, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing Previews" SubLabel="@($"{_stats.AssetsMissingPreviewsStale.ToString("N0")} stale")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingPhash.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingPhash, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing pHash"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="info" EqualHeight="true" Value="@_stats.AssetsWithNoPerson.ToString("N0")" Icon="bi bi-info-circle" Label="No Person"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="info" EqualHeight="true" Value="@_stats.AssetsWithNoAlbum.ToString("N0")" Icon="bi bi-info-circle" Label="No Album"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="danger" EqualHeight="true" Value="@_stats.AlbumsMissingCover.ToString("N0")" Icon="bi bi-image" Label="Albums Missing Cover"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="secondary" EqualHeight="true" Value="@_stats.CosplayersMissingProfile.ToString("N0")" Icon="bi bi-person-badge" Label="Cosplayers Missing Profile"/></div>
|
||||
</div>
|
||||
|
||||
@* ---- Duplicate / Orphan / Visibility ---- *@
|
||||
<div class="row row-cols-1 row-cols-md-4 g-3 mb-4">
|
||||
<div class="col"><div class="card border-secondary"><div class="card-body"><h6 class="card-title"><i class="bi bi-folder-x"></i> Orphan Assets</h6><h4 class="text-secondary">@_stats.OrphanAssets.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><div class="card border-success"><div class="card-body"><h6 class="card-title"><i class="bi bi-globe2"></i> Public Assets</h6><h4 class="text-success">@_stats.PublicAssets.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><div class="card border-warning"><div class="card-body"><h6 class="card-title"><i class="bi bi-shield-lock"></i> Protected Assets</h6><h4 class="text-warning">@_stats.ProtectedAssets.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><div class="card border-danger"><div class="card-body"><h6 class="card-title"><i class="bi bi-lock"></i> Private Assets</h6><h4 class="text-danger">@_stats.PrivateAssets.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="secondary" Icon="bi bi-folder-x" Label="Orphan Assets" Value="@_stats.OrphanAssets.ToString("N0")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="success" Icon="bi bi-globe2" Label="Public Assets" Value="@_stats.PublicAssets.ToString("N0")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="warning" Icon="bi bi-shield-lock" Label="Protected Assets" Value="@_stats.ProtectedAssets.ToString("N0")"/></div>
|
||||
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="danger" Icon="bi bi-lock" Label="Private Assets" Value="@_stats.PrivateAssets.ToString("N0")"/></div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
@* ---- Assets by type + Storage side by side ---- *@
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><i class="bi bi-collection"></i> Assets by Type</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>Type</th><th class="text-end">Count</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var kv in _stats.AssetsByType.OrderByDescending(x => x.Value)) {
|
||||
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-hdd-stack"></i> Storage by Type</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>Type</th><th class="text-end">Size</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var kv in _stats.StorageByType.OrderByDescending(x => x.Value)) {
|
||||
<tr><td>@kv.Key</td><td class="text-end">@FormatBytes(kv.Value)</td></tr>
|
||||
}
|
||||
<tr class="table-active fw-bold"><td>Total</td><td class="text-end">@FormatBytes(_stats.TotalStorageBytes)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<StatTable Title="Assets by Type" Icon="bi bi-collection" MarginBottom="true">
|
||||
<HeaderRow><th>Type</th><th class="text-end">Count</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var kv in _stats.AssetsByType.OrderByDescending(x => x.Value)) {
|
||||
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
|
||||
}
|
||||
</ChildContent>
|
||||
</StatTable>
|
||||
<StatTable Title="Storage by Type" Icon="bi bi-hdd-stack">
|
||||
<HeaderRow><th>Type</th><th class="text-end">Size</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var kv in _stats.StorageByType.OrderByDescending(x => x.Value)) {
|
||||
<tr><td>@kv.Key</td><td class="text-end">@Formatting.FormatBytes(kv.Value)</td></tr>
|
||||
}
|
||||
</ChildContent>
|
||||
<FooterRow>
|
||||
<tr class="table-active fw-bold"><td>Total</td><td class="text-end">@Formatting.FormatBytes(_stats.TotalStorageBytes)</td></tr>
|
||||
</FooterRow>
|
||||
</StatTable>
|
||||
</div>
|
||||
|
||||
@* ---- Users by role + File format breakdown ---- *@
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><i class="bi bi-people"></i> Users by Role</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>Role</th><th class="text-end">Count</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var kv in _stats.UsersByAccessLevel.OrderByDescending(x => x.Value)) {
|
||||
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-file-earmark-code"></i> File Formats</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>MIME Type</th><th class="text-end">Count</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var fmt in _stats.FileFormatBreakdown.Take(20)) {
|
||||
<tr><td><code>@fmt.MimeType</code></td><td class="text-end">@fmt.Count.ToString("N0")</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<StatTable Title="Users by Role" Icon="bi bi-people" MarginBottom="true">
|
||||
<HeaderRow><th>Role</th><th class="text-end">Count</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var kv in _stats.UsersByAccessLevel.OrderByDescending(x => x.Value)) {
|
||||
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
|
||||
}
|
||||
</ChildContent>
|
||||
</StatTable>
|
||||
<StatTable Title="File Formats" Icon="bi bi-file-earmark-code">
|
||||
<HeaderRow><th>MIME Type</th><th class="text-end">Count</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var fmt in _stats.FileFormatBreakdown.Take(20)) {
|
||||
<tr><td><code>@fmt.MimeType</code></td><td class="text-end">@fmt.Count.ToString("N0")</td></tr>
|
||||
}
|
||||
</ChildContent>
|
||||
</StatTable>
|
||||
</div>
|
||||
|
||||
@* ---- Resolution distribution ---- *@
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-bounding-box-circles"></i> Resolution Distribution</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>Bucket</th><th class="text-end">Count</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var bucket in _stats.ResolutionDistribution) {
|
||||
<tr><td>@bucket.Label</td><td class="text-end">@bucket.Count.ToString("N0")</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<StatTable Title="Resolution Distribution" Icon="bi bi-bounding-box-circles">
|
||||
<HeaderRow><th>Bucket</th><th class="text-end">Count</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var bucket in _stats.ResolutionDistribution) {
|
||||
<tr><td>@bucket.Label</td><td class="text-end">@bucket.Count.ToString("N0")</td></tr>
|
||||
}
|
||||
</ChildContent>
|
||||
</StatTable>
|
||||
</div>
|
||||
|
||||
@* ---- Top tags ---- *@
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-tags"></i> Top Tags</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>Tag</th><th class="text-end">Assets</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var tag in _stats.TopTags) {
|
||||
<tr><td>@tag.Name</td><td class="text-end">@tag.AssetCount.ToString("N0")</td></tr>
|
||||
}
|
||||
@if (!_stats.TopTags.Any()) {
|
||||
<tr><td colspan="2" class="text-muted text-center">No tags yet.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<StatTable Title="Top Tags" Icon="bi bi-tags">
|
||||
<HeaderRow><th>Tag</th><th class="text-end">Assets</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var tag in _stats.TopTags) {
|
||||
<tr><td>@tag.Name</td><td class="text-end">@tag.AssetCount.ToString("N0")</td></tr>
|
||||
}
|
||||
@if (!_stats.TopTags.Any()) {
|
||||
<tr><td colspan="2" class="text-muted text-center">No tags yet.</td></tr>
|
||||
}
|
||||
</ChildContent>
|
||||
</StatTable>
|
||||
</div>
|
||||
|
||||
@* ---- Monthly growth ---- *@
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<StatTable Title="Monthly Growth (last 12 months)" Icon="bi bi-graph-up-arrow">
|
||||
<HeaderRow><th>Month</th><th class="text-end">New Assets</th><th class="text-end">New Users</th></HeaderRow>
|
||||
<ChildContent>
|
||||
@foreach (var m in _stats.MonthlyGrowth) {
|
||||
<tr>
|
||||
<td>@(new DateTime(m.Year, m.Month, 1).ToString("yyyy-MM"))</td>
|
||||
<td class="text-end">@m.NewAssets.ToString("N0")</td>
|
||||
<td class="text-end">@m.NewUsers.ToString("N0")</td>
|
||||
</tr>
|
||||
}
|
||||
</ChildContent>
|
||||
</StatTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ---- Charts ---- *@
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-hdd-stack"></i> Storage by Type</div>
|
||||
<div class="card-body">
|
||||
<StatChart Type="bar" ChartId="chart-storage" Labels="@StorageChartLabels" Datasets="@StorageChartDatasets" FormatYAsBytes="true"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-file-earmark-code"></i> File Formats</div>
|
||||
<div class="card-body">
|
||||
<StatChart Type="doughnut" ChartId="chart-formats" Labels="@FormatChartLabels" Datasets="@FormatChartDatasets"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-bounding-box-circles"></i> Resolution Distribution</div>
|
||||
<div class="card-body">
|
||||
<StatChart Type="bar" ChartId="chart-resolution" Labels="@ResolutionChartLabels" Datasets="@ResolutionChartDatasets"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-graph-up-arrow"></i> Monthly Growth (last 12 months)</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>Month</th><th class="text-end">New Assets</th><th class="text-end">New Users</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var m in _stats.MonthlyGrowth) {
|
||||
<tr>
|
||||
<td>@(new DateTime(m.Year, m.Month, 1).ToString("yyyy-MM"))</td>
|
||||
<td class="text-end">@m.NewAssets.ToString("N0")</td>
|
||||
<td class="text-end">@m.NewUsers.ToString("N0")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card-body">
|
||||
<StatChart Type="line" ChartId="chart-growth" Labels="@GrowthChartLabels" Datasets="@GrowthChartDatasets"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,27 +199,76 @@
|
||||
@code {
|
||||
private StatsDto? _stats;
|
||||
private bool _loading = true;
|
||||
private bool _refreshing;
|
||||
private DateTime? _lastUpdated;
|
||||
|
||||
private static readonly string[] Palette = [
|
||||
"#0d6efd", "#198754", "#0dcaf0", "#ffc107", "#fd7e14", "#dc3545", "#6f42c1", "#20c997", "#6610f2", "#d63384"
|
||||
];
|
||||
|
||||
private List<string> StorageChartLabels =>
|
||||
_stats!.StorageByType.OrderByDescending(x => x.Value).Select(x => x.Key.ToString()).ToList();
|
||||
|
||||
private List<StatChart.ChartDataset> StorageChartDatasets => [
|
||||
new() {
|
||||
Label = "Storage",
|
||||
Data = _stats!.StorageByType.OrderByDescending(x => x.Value).Select(x => (double)x.Value).ToList(),
|
||||
BackgroundColor = Palette.Take(_stats!.StorageByType.Count).ToList()
|
||||
}
|
||||
];
|
||||
|
||||
private List<string> FormatChartLabels =>
|
||||
_stats!.FileFormatBreakdown.Take(8).Select(x => x.MimeType).ToList();
|
||||
|
||||
private List<StatChart.ChartDataset> FormatChartDatasets => [
|
||||
new() {
|
||||
Label = "Assets",
|
||||
Data = _stats!.FileFormatBreakdown.Take(8).Select(x => (double)x.Count).ToList(),
|
||||
BackgroundColor = Palette.Take(_stats!.FileFormatBreakdown.Take(8).Count()).ToList()
|
||||
}
|
||||
];
|
||||
|
||||
private List<string> ResolutionChartLabels =>
|
||||
_stats!.ResolutionDistribution.Select(x => x.Label).ToList();
|
||||
|
||||
private List<StatChart.ChartDataset> ResolutionChartDatasets => [
|
||||
new() {
|
||||
Label = "Assets",
|
||||
Data = _stats!.ResolutionDistribution.Select(x => (double)x.Count).ToList(),
|
||||
BackgroundColor = Palette.Take(_stats!.ResolutionDistribution.Count).ToList()
|
||||
}
|
||||
];
|
||||
|
||||
private List<string> GrowthChartLabels =>
|
||||
_stats!.MonthlyGrowth.Select(m => new DateTime(m.Year, m.Month, 1).ToString("yyyy-MM")).ToList();
|
||||
|
||||
private List<StatChart.ChartDataset> GrowthChartDatasets => [
|
||||
new() { Label = "New Assets", Data = _stats!.MonthlyGrowth.Select(m => (double)m.NewAssets).ToList(), BorderColor = "#0d6efd", Fill = false, Tension = 0.2 },
|
||||
new() { Label = "New Users", Data = _stats!.MonthlyGrowth.Select(m => (double)m.NewUsers).ToList(), BorderColor = "#198754", Fill = false, Tension = 0.2 }
|
||||
];
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
await LoadStats();
|
||||
}
|
||||
|
||||
private async Task Refresh() {
|
||||
if (_refreshing)
|
||||
return;
|
||||
|
||||
_refreshing = true;
|
||||
StateHasChanged();
|
||||
await LoadStats();
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task LoadStats() {
|
||||
_loading = true;
|
||||
StateHasChanged();
|
||||
|
||||
_stats = await StatsService.GetStatsAsync();
|
||||
_lastUpdated = DateTime.UtcNow;
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private string PercentOfTotal(int count) =>
|
||||
_stats?.TotalAssets > 0 ? $"{(double)count / _stats.TotalAssets * 100:F1}%" : "0%";
|
||||
|
||||
private static string FormatBytes(long bytes) => bytes switch {
|
||||
>= 1_073_741_824 => $"{bytes / 1_073_741_824.0:F2} GB",
|
||||
>= 1_048_576 => $"{bytes / 1_048_576.0:F2} MB",
|
||||
>= 1_024 => $"{bytes / 1_024.0:F2} KB",
|
||||
_ => $"{bytes} B"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,19 @@
|
||||
<div class="progress-stacked" style="height: @Height">
|
||||
@foreach (var seg in Segments) {
|
||||
<div class="progress" role="progressbar" style="width: @((seg.Width * 100).ToString(CultureInfo.InvariantCulture))%">
|
||||
<div class="progress-bar @(SegmentClass(seg.Color))"></div>
|
||||
<div class="progress-bar @(SegmentClass(seg.Color))" style="@(SegmentStyle(seg.Color))"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="progress" role="progressbar" style="height: @Height">
|
||||
<div class="progress-bar @BgClass" style="width: @((Value * 100).ToString(CultureInfo.InvariantCulture))%">
|
||||
@if (ShowPercent) { @($"{Value * 100:F0}%") }
|
||||
</div>
|
||||
<div class="progress-bar @BgClass" style="width: @((Value * 100).ToString(CultureInfo.InvariantCulture))%"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public float Value { get; set; }
|
||||
[Parameter] public string? Color { get; set; }
|
||||
[Parameter] public bool ShowPercent { get; set; }
|
||||
[Parameter] public string Size { get; set; } = "sm";
|
||||
[Parameter] public IReadOnlyList<(float Width, string Color)>? Segments { get; set; }
|
||||
|
||||
@@ -32,7 +29,9 @@
|
||||
|
||||
string BgClass => ToCssClass(Color ?? "primary");
|
||||
|
||||
string SegmentClass(string color) => ToCssClass(color);
|
||||
string SegmentClass(string color) => color.StartsWith("var(--") ? "" : ToCssClass(color);
|
||||
|
||||
string SegmentStyle(string color) => color.StartsWith("var(--") ? $"background-color: {color}" : "";
|
||||
|
||||
static string ToCssClass(string name) => name switch {
|
||||
"primary" => "text-bg-primary",
|
||||
|
||||
@@ -124,30 +124,8 @@ public static class RegexHighlighter
|
||||
return new MarkupString(sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the set of CSS class names used by the highlighter for embedding in isolated CSS.
|
||||
/// </summary>
|
||||
public static string CssClasses => string.Join(" ",
|
||||
ClassAnchor, ClassQuantifier, ClassCharClass, ClassEscape,
|
||||
ClassNamedGroup, ClassGroupRef, ClassOperator, ClassLiteral);
|
||||
|
||||
private static int FindNamedGroupEnd(ReadOnlySpan<char> span, int start)
|
||||
{
|
||||
int depth = 1;
|
||||
int i = start + 1;
|
||||
while (i < span.Length && depth > 0)
|
||||
{
|
||||
if (span[i] == '\\')
|
||||
{
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (span[i] == '(') depth++;
|
||||
if (span[i] == ')') depth--;
|
||||
i++;
|
||||
}
|
||||
return Math.Min(i - 1, span.Length - 1);
|
||||
}
|
||||
private static int FindNamedGroupEnd(ReadOnlySpan<char> span, int start) =>
|
||||
FindMatchingParen(span, start);
|
||||
|
||||
private static int FindMatchingParen(ReadOnlySpan<char> span, int start)
|
||||
{
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
@using Butter.Dtos.Asset
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@implements IDisposable
|
||||
|
||||
@inject AssetService assetService
|
||||
@inject AlbumService albumService
|
||||
@inject PersonService personService
|
||||
@inject LoginService loginService
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject LoginService loginService
|
||||
@inject MediaService mediaService
|
||||
|
||||
@if (Embedded) {
|
||||
<div class="picker-embedded">
|
||||
@@ -86,7 +85,7 @@
|
||||
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
|
||||
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
|
||||
@if (asset.HasThumbnail) {
|
||||
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
|
||||
} else {
|
||||
@@ -137,7 +136,7 @@
|
||||
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
|
||||
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
|
||||
@if (asset.HasThumbnail) {
|
||||
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
|
||||
} else {
|
||||
@@ -254,7 +253,7 @@
|
||||
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
|
||||
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
|
||||
@if (asset.HasThumbnail) {
|
||||
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
|
||||
} else {
|
||||
@@ -305,7 +304,7 @@
|
||||
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
|
||||
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
|
||||
@if (asset.HasThumbnail) {
|
||||
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
|
||||
} else {
|
||||
@@ -429,7 +428,6 @@
|
||||
string currentPath = "";
|
||||
HashSet<Guid> selectedIds = [];
|
||||
string searchQuery = string.Empty;
|
||||
string token = string.Empty;
|
||||
int currentPage;
|
||||
bool isLoading;
|
||||
bool hasMore = true;
|
||||
@@ -455,15 +453,12 @@
|
||||
const int PageSize = 150;
|
||||
|
||||
bool isAdminOrCurator => loginService.IsAdminOrCurator;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
if (Embedded) await InitializeAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync() {
|
||||
token = loginService.AuthInfo?.Token ?? string.Empty;
|
||||
if (!Embedded && Show && !_initializing) {
|
||||
_initializing = true;
|
||||
await InitializeAsync();
|
||||
@@ -613,8 +608,6 @@
|
||||
_personSearchTimer?.Dispose();
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
|
||||
static string TruncateName(string name, int maxLen = 40) {
|
||||
if (name.Length <= maxLen) return name;
|
||||
var lastSlash = name.LastIndexOf('/');
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
@using Butter.Dtos.Album
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject LoginService loginService
|
||||
@inject LoginService loginService
|
||||
@inject MediaService mediaService
|
||||
|
||||
<div class="@GetCardClass()" @key="Album.Id">
|
||||
<div class="@GetCardClass()" @key="Album.Id" style="@GetCardStyle()">
|
||||
<a href="/albums/@Album.Id" class="album-card-overlay" aria-label="View album @Album.Name"
|
||||
@onclick="HandleOverlayClick" @onclick:preventDefault="SelectionMode"></a>
|
||||
@if (Album.CoverAssetId.HasValue) {
|
||||
<img src="@ThumbUrl(Album.CoverAssetId.Value)"
|
||||
<img src="@mediaService.ThumbUrl(Album.CoverAssetId.Value)"
|
||||
loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="album-card-fallback" style="display:none">
|
||||
@@ -51,11 +50,8 @@
|
||||
[Parameter]
|
||||
public EventCallback<Guid> OnToggleSelection { get; set; }
|
||||
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
string token => loginService.AuthInfo?.Token ?? string.Empty;
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
[Parameter]
|
||||
public string ViewMode { get; set; } = "masonry";
|
||||
|
||||
string GetCardClass() {
|
||||
var classes = new List<string> { "album-card" };
|
||||
@@ -71,6 +67,15 @@
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
string GetCardStyle() {
|
||||
if (ViewMode == "masonry") {
|
||||
var w = Album.CoverWidth ?? 3;
|
||||
var h = Album.CoverHeight ?? 4;
|
||||
return $"aspect-ratio:{w}/{h}";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async Task HandleOverlayClick() {
|
||||
if (SelectionMode)
|
||||
await OnToggleSelection.InvokeAsync(Album.Id);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
@if (isLoading) {
|
||||
<LoadSpinner/>
|
||||
} else if (albums.Count == 0) {
|
||||
} else if (buffer.Count == 0 && !hasMore) {
|
||||
<EmptyState Variant="warning" Title="No albums available">
|
||||
@if (loginService.IsLoggedIn) {
|
||||
<p>There are no albums yet.</p>
|
||||
@@ -16,20 +16,24 @@
|
||||
}
|
||||
</EmptyState>
|
||||
} else {
|
||||
@if (isLoadingUp) {
|
||||
<LoadSpinner/>
|
||||
}
|
||||
|
||||
<div class="@("album-grid " + ViewMode)">
|
||||
@foreach (var album in albums) {
|
||||
<AlbumCard Album="album"
|
||||
@foreach (var album in buffer.FlatList) {
|
||||
<AlbumCard @key="album.Id"
|
||||
Album="album"
|
||||
SelectionMode="SelectionMode"
|
||||
Selected="SelectedIds.Contains(album.Id)"
|
||||
OnToggleSelection="ToggleAlbumSelection"/>
|
||||
OnToggleSelection="ToggleAlbumSelection"
|
||||
ViewMode="@ViewMode"/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isLoadingMore) {
|
||||
<LoadSpinner/>
|
||||
}
|
||||
|
||||
<div @ref="sentinelRef" class="masonry-sentinel"></div>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -42,19 +46,22 @@
|
||||
[Parameter] public EventCallback<Guid> OnToggleSelection { get; set; }
|
||||
[Parameter] public int LoadVersion { get; set; }
|
||||
[Parameter] public Func<int, int, string?, string?, bool, Task<List<AlbumPreviewDto>?>>? FetchAlbums { get; set; }
|
||||
[Parameter] public EventCallback<List<AlbumPreviewDto>> OnDataLoaded { get; set; }
|
||||
|
||||
List<AlbumPreviewDto> albums = [];
|
||||
int currentPage = 0;
|
||||
PageBuffer<AlbumPreviewDto> buffer = new(a => a.Id) { MaxPages = 10 };
|
||||
bool hasMore = true;
|
||||
bool hasMoreUp => buffer.MinPage > 0;
|
||||
bool isLoading = true;
|
||||
bool isLoadingMore;
|
||||
ElementReference sentinelRef;
|
||||
bool isLoadingUp;
|
||||
DotNetObjectReference<AlbumGrid>? dotNetRef;
|
||||
|
||||
int? _prevLoadVersion;
|
||||
string? _prevSearch;
|
||||
string? _prevSortBy;
|
||||
bool _prevSortAsc;
|
||||
string? _prevViewMode;
|
||||
bool _masonrySetup;
|
||||
|
||||
protected override async Task OnParametersSetAsync() {
|
||||
bool filterChanged = _prevLoadVersion != LoadVersion
|
||||
@@ -62,6 +69,14 @@
|
||||
|| _prevSortBy != SortBy
|
||||
|| _prevSortAsc != SortAsc;
|
||||
|
||||
if (_prevViewMode == "masonry" && ViewMode != "masonry") {
|
||||
_masonrySetup = false;
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-grid.masonry");
|
||||
} else if (_prevViewMode != "masonry" && ViewMode == "masonry") {
|
||||
_masonrySetup = false;
|
||||
}
|
||||
|
||||
_prevViewMode = ViewMode;
|
||||
_prevLoadVersion = LoadVersion;
|
||||
_prevSearch = SearchQuery;
|
||||
_prevSortBy = SortBy;
|
||||
@@ -72,18 +87,22 @@
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (!isLoading && (albums.Count > 0 || hasMore) && dotNetRef == null) {
|
||||
if (!_masonrySetup && ViewMode == "masonry" && buffer.Count > 0) {
|
||||
_masonrySetup = true;
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.setup", ".album-grid.masonry");
|
||||
}
|
||||
|
||||
if (!isLoading && (buffer.Count > 0 || hasMore) && dotNetRef == null) {
|
||||
dotNetRef = DotNetObjectReference.Create(this);
|
||||
await jsRuntime.InvokeVoidAsync(
|
||||
"albumObserver.observeBottom", sentinelRef, dotNetRef
|
||||
"albumObserver.setup", dotNetRef
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async Task Reload() {
|
||||
await DisposeObserver();
|
||||
albums.Clear();
|
||||
currentPage = 0;
|
||||
buffer.Clear();
|
||||
hasMore = true;
|
||||
isLoading = true;
|
||||
StateHasChanged();
|
||||
@@ -92,11 +111,13 @@
|
||||
? await FetchAlbums(0, 30, SearchQuery, SortBy, SortAsc)
|
||||
: await albumService.GetAlbumsAsync(0, 30, SearchQuery, SortBy, SortAsc);
|
||||
if (items?.Count > 0) {
|
||||
albums = items;
|
||||
currentPage = 1;
|
||||
buffer.AddPage(0, items);
|
||||
}
|
||||
hasMore = items?.Count == 30;
|
||||
isLoading = false;
|
||||
|
||||
if (buffer.Count > 0)
|
||||
await OnDataLoaded.InvokeAsync(buffer.FlatList);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
@@ -105,18 +126,41 @@
|
||||
isLoadingMore = true;
|
||||
StateHasChanged();
|
||||
|
||||
var nextPage = buffer.MaxPage + 1;
|
||||
var items = FetchAlbums != null
|
||||
? await FetchAlbums(currentPage, 30, SearchQuery, SortBy, SortAsc)
|
||||
: await albumService.GetAlbumsAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
|
||||
? await FetchAlbums(nextPage, 30, SearchQuery, SortBy, SortAsc)
|
||||
: await albumService.GetAlbumsAsync(nextPage, 30, SearchQuery, SortBy, SortAsc);
|
||||
if (items?.Count > 0) {
|
||||
albums.AddRange(items);
|
||||
currentPage++;
|
||||
buffer.AddPage(nextPage, items);
|
||||
}
|
||||
hasMore = items?.Count == 30;
|
||||
isLoadingMore = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task LoadPreviousAlbums() {
|
||||
if (isLoadingUp || !hasMoreUp) return;
|
||||
isLoadingUp = true;
|
||||
StateHasChanged();
|
||||
|
||||
var prevPage = buffer.MinPage - 1;
|
||||
if (prevPage < 0) {
|
||||
isLoadingUp = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var items = FetchAlbums != null
|
||||
? await FetchAlbums(prevPage, 30, SearchQuery, SortBy, SortAsc)
|
||||
: await albumService.GetAlbumsAsync(prevPage, 30, SearchQuery, SortBy, SortAsc);
|
||||
if (items?.Count > 0) {
|
||||
buffer.PrependPage(prevPage, items);
|
||||
}
|
||||
|
||||
isLoadingUp = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
async Task ToggleAlbumSelection(Guid id) => await OnToggleSelection.InvokeAsync(id);
|
||||
|
||||
async Task DisposeObserver() {
|
||||
@@ -127,15 +171,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await DisposeObserver();
|
||||
public async ValueTask DisposeAsync() {
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-grid.masonry");
|
||||
await DisposeObserver();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the IDs of all currently loaded albums.
|
||||
/// </summary>
|
||||
public List<Guid> GetCurrentAlbumIds() => albums.Select(a => a.Id).ToList();
|
||||
public List<Guid> GetCurrentAlbumIds() => buffer.FlatList.Select(a => a.Id).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the previews of all currently loaded albums.
|
||||
/// </summary>
|
||||
public List<AlbumPreviewDto> GetCurrentAlbumPreviews() => [.. albums];
|
||||
public List<AlbumPreviewDto> GetCurrentAlbumPreviews() => [.. buffer.FlatList];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
column-count: 4;
|
||||
column-gap: 8px;
|
||||
padding: 0 8px;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
/* ── Grid ── */
|
||||
@@ -11,12 +12,14 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
/* ── Card in masonry ── */
|
||||
.album-grid.masonry ::deep .album-card {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 8px;
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
.album-grid.grid ::deep .album-card {
|
||||
@@ -30,6 +33,7 @@
|
||||
|
||||
.masonry-sentinel {
|
||||
height: 1px;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
@using Microsoft.Extensions.Options
|
||||
@using MilkStream.Client.Services
|
||||
@using System.Linq
|
||||
|
||||
@inject LoginService loginService
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject MediaService mediaService
|
||||
|
||||
<ModalFrame Title="Select Cover Image" Show="Show" Size="lg" OnClose="OnCancel">
|
||||
<Body>
|
||||
@@ -15,7 +12,7 @@
|
||||
<div class="picker-tile @(SelectedAssetId == id ? "selected" : "")"
|
||||
@key="id"
|
||||
@onclick="() => SelectAsset(id)">
|
||||
<img src="@ThumbUrl(id)"
|
||||
<img src="@mediaService.ThumbUrl(id)"
|
||||
loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none">
|
||||
@@ -47,15 +44,6 @@
|
||||
[Parameter] public EventCallback<Guid?> OnSelected { get; set; }
|
||||
[Parameter] public EventCallback OnClosed { get; set; }
|
||||
|
||||
string token = string.Empty;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
|
||||
protected override void OnParametersSet() {
|
||||
token = loginService.AuthInfo?.Token ?? string.Empty;
|
||||
}
|
||||
|
||||
void SelectAsset(Guid id) {
|
||||
SelectedAssetId = id;
|
||||
}
|
||||
@@ -68,6 +56,4 @@
|
||||
async Task OnCancel() {
|
||||
await OnClosed.InvokeAsync();
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user