Compare commits
191
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0267313306 | ||
|
|
985178b741 | ||
|
|
677a3816d2 | ||
|
|
451c131711 | ||
|
|
559e7f1a46 | ||
|
|
9c5bc6ccf7 | ||
|
|
4228ab6ca5 | ||
|
|
c644717808 | ||
|
|
453507a3ed | ||
|
|
c95728b097 | ||
|
|
a8122ebc39 | ||
|
|
594fe7a977 | ||
|
|
62dc693842 | ||
|
|
e00fc5e98c | ||
|
|
c773228832 | ||
|
|
225e6c4b92 | ||
|
|
dffc909353 | ||
|
|
379f640f00 | ||
|
|
8c6b18382a | ||
|
|
917a144ed5 | ||
|
|
9613e7fe13 | ||
|
|
059866eb4a | ||
|
|
65ee5cfd20 | ||
|
|
c0164a75a0 | ||
|
|
2d1d1e0d26 | ||
|
|
6f2ad6d24c | ||
|
|
5727da5798 | ||
|
|
2038bd5e20 | ||
|
|
8a4783367a | ||
|
|
4bf8c5e69f | ||
|
|
df8b5a2cf2 | ||
|
|
2d82dd7c7c | ||
|
|
c3b473ff86 | ||
|
|
e279e91f0d | ||
|
|
ccbf8ebaa4 | ||
|
|
ae62213b76 | ||
|
|
a2de4b8ac3 | ||
|
|
1127364b15 | ||
|
|
64ec5d46a8 | ||
|
|
b1bcab67e0 | ||
|
|
b43f85e7c9 | ||
|
|
b3f2e73eef | ||
|
|
3f8b36eb37 | ||
|
|
152e0c2bc0 | ||
|
|
9d79c15201 | ||
|
|
ba8f7b68be | ||
|
|
a7d2e88993 | ||
|
|
79fc81fabd | ||
|
|
a5d32d7173 | ||
|
|
90026a61cc | ||
|
|
98c3fd5371 | ||
|
|
9ccdfe5512 | ||
|
|
abe446db6c | ||
|
|
fe8c1d5531 | ||
|
|
104193a09e | ||
|
|
291eb611c0 | ||
|
|
e24e6cc2f7 | ||
|
|
03ee6c555e | ||
|
|
70c432a668 | ||
|
|
d72448b34b | ||
|
|
87b7d61f1a | ||
|
|
2ea4bb7d8b | ||
|
|
a893836f42 | ||
|
|
0a51ce676a | ||
|
|
de8589b066 | ||
|
|
c2fe80cddc | ||
|
|
1a340d23ac | ||
|
|
819be813dd | ||
|
|
d5ee952bb0 | ||
|
|
b675457e3e | ||
|
|
2dd4f8621f | ||
|
|
8209da44e7 | ||
|
|
2177cb1a0b | ||
|
|
79b15ed0ff | ||
|
|
4124daeec9 | ||
|
|
8007a2d8ce | ||
|
|
a38f5eb59e | ||
|
|
cd2fa3a355 | ||
|
|
59cb11e687 | ||
|
|
a3372881c4 | ||
|
|
f89c8f3804 | ||
|
|
b6386aad98 | ||
|
|
e63b93f645 | ||
|
|
a23231fd5f | ||
|
|
bfd99c578d | ||
|
|
0d5089c01e | ||
|
|
2c8dcb6255 | ||
|
|
85357752e6 | ||
|
|
5b4e2b9afc | ||
|
|
7fd294043e | ||
|
|
025f9f6629 | ||
|
|
f6b42f850c | ||
|
|
7596308075 | ||
|
|
70247200f1 | ||
|
|
ba66000748 | ||
|
|
ffe37fec87 | ||
|
|
7563450c35 | ||
|
|
4bc35c0625 | ||
|
|
6fbb98a3fd | ||
|
|
796f485a84 | ||
|
|
95c624aaab | ||
|
|
371e73a999 | ||
|
|
ee249adaf1 | ||
|
|
b5b8fb247c | ||
|
|
f9556cd5de | ||
|
|
05f3c40f60 | ||
|
|
22b6e0e702 | ||
|
|
b2786b487f | ||
|
|
317bf12e07 | ||
|
|
7fd7a82d54 | ||
|
|
f9706d26d5 | ||
|
|
9c3d7effc9 | ||
|
|
4d9d94b648 | ||
|
|
9eb333093a | ||
|
|
30ad404efd | ||
|
|
7823d9f4d4 | ||
|
|
82ab09adcb | ||
|
|
4d068216df | ||
|
|
24d6109d33 | ||
|
|
68a583d584 | ||
|
|
1c7ede30af | ||
|
|
a5dda805fd | ||
|
|
87259a02a5 | ||
|
|
e2bdff9277 | ||
|
|
9beeb92ff0 | ||
|
|
a798a2cf30 | ||
|
|
887e5e0b31 | ||
|
|
4e93029965 | ||
|
|
b26163296f | ||
|
|
ec6254ea73 | ||
|
|
9d08f3de3e | ||
|
|
3872fb05ed | ||
|
|
16850bc6fd | ||
|
|
4d9b595a1d | ||
|
|
a02b2f4843 | ||
|
|
68fdad13d5 | ||
|
|
a149b01690 | ||
|
|
3fce3599fb | ||
|
|
2fd03c4d14 | ||
|
|
c55ce7ac4f | ||
|
|
6026c9ec43 | ||
|
|
6add667ef6 | ||
|
|
0c09e01f3c | ||
|
|
f940d5603a | ||
|
|
83d30feadf | ||
|
|
03e4c3e86d | ||
|
|
61b7c10967 | ||
|
|
f8db265ffd | ||
|
|
164008bef1 | ||
|
|
4d5694dca6 | ||
|
|
a680f6363d | ||
|
|
0098ec3a65 | ||
|
|
fbd7f5ff00 | ||
|
|
c05082ad13 | ||
|
|
d96473eca5 | ||
|
|
34080648d6 | ||
|
|
f9385e2d9d | ||
|
|
e995388af0 | ||
|
|
3fbdd56c10 | ||
|
|
026b5d8fe8 | ||
|
|
7ded2b068b | ||
|
|
99117347d7 | ||
|
|
42154e9e5c | ||
|
|
230202337a | ||
|
|
2ded40d2a0 | ||
|
|
965caadc93 | ||
|
|
8a1b39347d | ||
|
|
06e59e164f | ||
|
|
c2f3b05903 | ||
|
|
725a020078 | ||
|
|
2782a38819 | ||
|
|
84e4492aff | ||
|
|
f75940cd20 | ||
|
|
556c4a15e0 | ||
|
|
3374fe6ad4 | ||
|
|
fe4e19a7de | ||
|
|
a5a783e8d6 | ||
|
|
3afa7f2834 | ||
|
|
66782cacfc | ||
|
|
aacd4c8049 | ||
|
|
fa62e75a72 | ||
|
|
1599715d06 | ||
|
|
4189e975a1 | ||
|
|
5fbb0e75e8 | ||
|
|
2371011720 | ||
|
|
2d4e78f014 | ||
|
|
25144ccd3c | ||
|
|
a9b52a890a | ||
|
|
b344c1d625 | ||
|
|
cff5a7969d | ||
|
|
30dca5e216 |
+1
-15
@@ -1,20 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="MariaDb" uuid="eab007eb-4f50-4961-9bce-5c4940a7cdff">
|
||||
<driver-ref>mariadb</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mariadb://localhost:3306/</jdbc-url>
|
||||
<jdbc-additional-properties>
|
||||
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.resource.type" value="Deployment" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||
</jdbc-additional-properties>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
<time-zone>Europe/Rome</time-zone>
|
||||
</data-source>
|
||||
<data-source source="LOCAL" name="TestDb@localhost" uuid="80498c22-67da-4c4a-8106-c4789470ef83">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
@@ -31,7 +17,7 @@
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://192.168.51.101:3306/TestDb</jdbc-url>
|
||||
<jdbc-url>jdbc:postgresql://192.168.51.101:5432/TestDb</jdbc-url>
|
||||
<jdbc-additional-properties>
|
||||
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||
|
||||
+2
-3
@@ -3,9 +3,8 @@
|
||||
<component name="db-forest-configuration">
|
||||
<data version="2">.
|
||||
----------------------------------------
|
||||
1:0:eab007eb-4f50-4961-9bce-5c4940a7cdff
|
||||
2:0:80498c22-67da-4c4a-8106-c4789470ef83
|
||||
3:0:a0f3e480-c856-41e0-8fb4-5cac87b9faf6
|
||||
1:0:80498c22-67da-4c4a-8106-c4789470ef83
|
||||
2:0:a0f3e480-c856-41e0-8fb4-5cac87b9faf6
|
||||
.</data>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,34 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Compose Deployment (PM) Win" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker-PM">
|
||||
<deployment type="docker-compose.yml">
|
||||
<settings>
|
||||
<option name="containerName" value="" />
|
||||
<option name="envFilePath" value="" />
|
||||
<option name="envFilePaths">
|
||||
<list>
|
||||
<option value="" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="envVars">
|
||||
<list>
|
||||
<DockerEnvVarImpl>
|
||||
<option name="name" value="ASPNETCORE_ENVIRONMENT" />
|
||||
<option name="value" value="Docker" />
|
||||
</DockerEnvVarImpl>
|
||||
</list>
|
||||
</option>
|
||||
<option name="removeOrphansOnComposeDown" value="false" />
|
||||
<option name="commandLineOptions" value="--build" />
|
||||
<option name="secondarySourceFiles">
|
||||
<list>
|
||||
<option value="docker-compose.pm.yml" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="sourceFilePath" value="docker-compose.yml" />
|
||||
<option name="upRemoveOrphans" value="true" />
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,17 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="VersionEnvGenWin" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="false" />
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/generate-version.ps1" />
|
||||
<option name="SCRIPT_OPTIONS" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="false" />
|
||||
<option name="INTERPRETER_PATH" value=""C:/Program Files/PowerShell/7/pwsh.exe"" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="EXECUTE_IN_TERMINAL" value="false" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="true" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -63,6 +63,7 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
|
||||
- **Soft delete only** — set `DeletedAt`, never hard-delete (models: Asset, User)
|
||||
- **Repository `Save()` must be called explicitly** after insert/update/delete
|
||||
- **Enum prefix `E`** (e.g. `EAccessLevel`, `EAssetType`, `EJobStatus`)
|
||||
- **Frontend fields: no `_` prefix** — private fields in `MilkStream.Client` use a plain camelCase name (e.g. `isLoading`, `selectedIndex`), never a leading `_`. Do not introduce `_`-prefixed fields; rename them out when touching such code.
|
||||
- **JWT access token**: 10 min, refresh token: 60 min (constants in `LactoseAuthService.cs`)
|
||||
- **Pagination is zero-based** everywhere — `Page = 0` is the first page. All repositories use `Skip(page * pageSize)`. All controllers validate `Page < 0` (reject negative) and `PageSize < 1 || PageSize > 250` (clamp 1–250).
|
||||
- **Conventional Commits** enforced via `cliff.toml`; changelog generated with `git-cliff`
|
||||
@@ -127,6 +128,8 @@ See issue #52 for the tracking list of candidate components.
|
||||
|
||||
Migrations in `Lactose/Migrations/` — generated, do not hand-edit.
|
||||
|
||||
**All migrations MUST be created with EF Core tools** (`dotnet ef migrations add`) and **never edited manually** (including the generated `.Designer.cs` and snapshot), unless the user explicitly asks for a manual edit. Hand-written data backfills, index tweaks, or idempotency guards belong in a separate migration added via the tools — not as edits to a generated file. If a migration is already partially applied (e.g. a backfill timed out), do not make the migration re-runnable; instead reset the partial DB state and re-apply, or add a new tool-generated migration.
|
||||
|
||||
```bash
|
||||
dotnet ef migrations add <Name> --project Lactose
|
||||
dotnet ef database update --project Lactose
|
||||
|
||||
@@ -27,6 +27,11 @@ public class AlbumAssetPreviewDto {
|
||||
/// </summary>
|
||||
public bool HasPreview { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset's display media. For animated assets this is the
|
||||
/// converted video MIME type (e.g. "video/mp4"); otherwise the original MIME type.
|
||||
/// </summary>
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the original filename of the asset.
|
||||
/// </summary>
|
||||
public string? FileName { get; set; }
|
||||
|
||||
@@ -9,9 +9,9 @@ public class AssetBrowseOptionsDto : PagedSearchParametersDto {
|
||||
/// </summary>
|
||||
public Guid FolderId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the current path relative to the folder root (empty for root).
|
||||
/// Gets or sets the current path relative to the folder root (empty or null for root).
|
||||
/// </summary>
|
||||
public string Path { get; set; } = "";
|
||||
public string? Path { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether to only include assets not assigned to any album. Defaults to true.
|
||||
/// </summary>
|
||||
|
||||
@@ -13,11 +13,12 @@ public class AssetBrowseResultDto {
|
||||
/// </summary>
|
||||
public List<AssetPreviewDto> Assets { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Gets or sets the current browsing path relative to the folder root.
|
||||
/// </summary>
|
||||
public string CurrentPath { get; set; } = "";
|
||||
/// <summary>
|
||||
/// Gets or sets the total count of assets at the current path level (for pagination).
|
||||
/// </summary>
|
||||
public int TotalAssetCount { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the current path level contains at least one non-deleted asset
|
||||
/// (registered in the database) that is not assigned to any album.
|
||||
/// </summary>
|
||||
public bool HasUnassignedFilesHere { get; set; }
|
||||
}
|
||||
|
||||
@@ -58,4 +58,16 @@ public class AssetPreviewDto {
|
||||
/// Gets or sets the soft-delete timestamp. Only populated for admins and the asset uploader.
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when processing of the asset last failed. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public DateTime? ProcessFailedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the error message recorded when the asset was last marked broken. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public string? ProcessErrorMessage { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the original file path of the asset. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public string? OriginalPath { get; set; }
|
||||
}
|
||||
|
||||
@@ -39,6 +39,19 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
|
||||
/// </summary>
|
||||
public Guid? UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether to filter exclusively for soft-deleted assets.
|
||||
/// Results remain scoped by the requester's access level.
|
||||
/// </summary>
|
||||
public bool DeletedOnly { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the album ID to filter assets belonging to that album.
|
||||
/// </summary>
|
||||
public Guid? AlbumId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID to filter assets in albums owned by that person.
|
||||
/// </summary>
|
||||
public Guid? PersonId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the total matching count should be computed and returned in the <c>X-Total-Count</c> header.
|
||||
/// Skipping it avoids a full count query over the filtered result set.
|
||||
/// </summary>
|
||||
|
||||
@@ -11,9 +11,11 @@ public class AssetUpdateDto {
|
||||
/// </summary>
|
||||
public Guid? UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the deletion date to set on the asset.
|
||||
/// Gets or sets the soft-delete state to apply to the asset.
|
||||
/// <see langword="true"/> soft-deletes the asset, <see langword="false"/> restores it,
|
||||
/// and <see langword="null"/> leaves the current state unchanged.
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
public bool? IsDeleted { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
/// Reports the outcome of a bulk restore operation on soft-deleted assets.
|
||||
/// </summary>
|
||||
public class BulkRestoreResultDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the IDs of assets that were successfully restored.
|
||||
/// </summary>
|
||||
public List<Guid> Restored { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Gets or sets the IDs of assets that were skipped because their source file no longer exists on disk.
|
||||
/// </summary>
|
||||
public List<Guid> SkippedMissingFile { get; set; } = [];
|
||||
}
|
||||
@@ -8,4 +8,13 @@ public class DirectoryEntryDto {
|
||||
/// Gets or sets the directory name (a single path segment, not a full path).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the directory path relative to the folder root (forward slashes).
|
||||
/// </summary>
|
||||
public string Path { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets whether this directory contains at least one non-deleted asset
|
||||
/// that is not assigned to any album (directly or in a subdirectory).
|
||||
/// </summary>
|
||||
public bool HasUnassignedAssets { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,4 +13,9 @@ public class BulkDto<TDto> {
|
||||
/// Gets or sets the update data to apply to each entity.
|
||||
/// </summary>
|
||||
public required TDto Data { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets optional cascade options for the update (e.g. cascade a visibility change to albums and assets).
|
||||
/// </summary>
|
||||
public CascadeOptions? Cascade { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Butter.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Represents cascade options for a bulk visibility update.
|
||||
/// </summary>
|
||||
public class CascadeOptions {
|
||||
/// <summary>
|
||||
/// Gets or sets whether to cascade the visibility update to albums.
|
||||
/// </summary>
|
||||
public bool ToAlbums { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to cascade the visibility update to assets.
|
||||
/// </summary>
|
||||
public bool ToAssets { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Butter.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a bulk visibility update with cascading.
|
||||
/// </summary>
|
||||
public class CascadeResultDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the number of people updated.
|
||||
/// </summary>
|
||||
public int PeopleUpdated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of albums updated.
|
||||
/// </summary>
|
||||
public int AlbumsUpdated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets updated.
|
||||
/// </summary>
|
||||
public int AssetsUpdated { get; set; }
|
||||
}
|
||||
@@ -67,7 +67,9 @@ public enum EJobType {
|
||||
/// <summary>Creates missing Person entities extracted from asset paths via folder regex patterns.</summary>
|
||||
CreatePersons,
|
||||
/// <summary>Creates missing Person and Album entities extracted from asset paths via folder regex patterns.</summary>
|
||||
CreateAlbums
|
||||
CreateAlbums,
|
||||
/// <summary>Converts animated assets (GIF) to video (mp4) and extracts a first-frame thumbnail.</summary>
|
||||
AnimatedConversion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,7 +9,7 @@ public class PersonUpdateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the person.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
public string? Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID to use as the profile picture.
|
||||
/// </summary>
|
||||
|
||||
@@ -108,6 +108,10 @@ public class StatsDto {
|
||||
/// </summary>
|
||||
public int AssetsMissingPhash { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets whose processing failed (marked broken).
|
||||
/// </summary>
|
||||
public int TotalBrokenAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets with no person assigned (no album with a PersonOwner).
|
||||
/// </summary>
|
||||
public int AssetsWithNoPerson { get; set; }
|
||||
|
||||
+2
-1
@@ -26,7 +26,6 @@ public static class MimeTypes{
|
||||
new("image/bmp}", [".bmp"]),
|
||||
new("image/cgm", [".cgm"]),
|
||||
new("image/g3fax", [".g3"]),
|
||||
new("image/gif", [".gif"]),
|
||||
new("image/heic", [".heif", ".heic"]),
|
||||
new("image/ief", [".ief"]),
|
||||
new("image/jpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfif", ".jfif-tbnl", ".jif"]),
|
||||
@@ -89,6 +88,8 @@ public static class MimeTypes{
|
||||
public static readonly MimeTypeMap[] Video = [
|
||||
new("video/3gpp", [".3gp"]),
|
||||
new("video/3gpp2", [".3g2"]),
|
||||
// Animated GIFs are repurposed as video so they flow through the video rendering path.
|
||||
new("image/gif", [".gif"]),
|
||||
new("video/h261", [".h261"]),
|
||||
new("video/h263", [".h263"]),
|
||||
new("video/h264", [".h264"]),
|
||||
|
||||
@@ -32,6 +32,12 @@ public enum Settings {
|
||||
ThumbnailQuality,
|
||||
/// <summary>Quality setting (1–100) for WebP preview encoding.</summary>
|
||||
PreviewQuality,
|
||||
/// <summary>Path to store converted videos for animated assets (e.g. GIF → mp4).</summary>
|
||||
ConvertedPath,
|
||||
/// <summary>Container format for converted videos (e.g. "mp4").</summary>
|
||||
ConvertedFormat,
|
||||
/// <summary>Path to the ffmpeg executable (defaults to "ffmpeg" on PATH).</summary>
|
||||
FfmpegPath,
|
||||
/// <summary>User ID used for scanner-created asset attribution.</summary>
|
||||
SystemUploaderId
|
||||
}
|
||||
@@ -61,6 +67,9 @@ public static class SettingsExtension {
|
||||
Settings.JobBatchSize => "Job Batch Size",
|
||||
Settings.ThumbnailQuality => "Thumbnail Quality",
|
||||
Settings.PreviewQuality => "Preview Quality",
|
||||
Settings.ConvertedPath => "Converted Path",
|
||||
Settings.ConvertedFormat => "Converted Format",
|
||||
Settings.FfmpegPath => "FFmpeg Path",
|
||||
Settings.SystemUploaderId => "System Uploader Id",
|
||||
_ => setting.ToString() // Fallback to the enum name
|
||||
};
|
||||
|
||||
@@ -57,7 +57,7 @@ public class RefreshTokenTransformation(
|
||||
/// </summary>
|
||||
public const string ClaimType = "LoginValid";
|
||||
/// <inheritdoc />
|
||||
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
|
||||
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
|
||||
{
|
||||
ClaimsIdentity claimsIdentity = new ClaimsIdentity();
|
||||
if (principal.HasClaim(claim => claim.Type == ClaimType)) {
|
||||
@@ -68,12 +68,12 @@ public class RefreshTokenTransformation(
|
||||
}
|
||||
}
|
||||
var userData = authService.GetUserData(principal);
|
||||
if (userData == null) return Task.FromResult(principal);
|
||||
var user = userRepository.Find(userData.Id);
|
||||
if (user == null) return Task.FromResult(principal);
|
||||
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.UtcNow) return Task.FromResult(principal);
|
||||
if (userData == null) return principal;
|
||||
var user = await userRepository.FindAsync(userData.Id, CancellationToken.None);
|
||||
if (user == null) return principal;
|
||||
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.UtcNow) return principal;
|
||||
claimsIdentity.AddClaim(new Claim(ClaimType, "true", ClaimValueTypes.Boolean));
|
||||
principal.AddIdentity(claimsIdentity);
|
||||
return Task.FromResult(principal);
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,14 @@ public class LactoseDbContext : DbContext {
|
||||
/// Gets or sets the person-maintainer join table.
|
||||
/// </summary>
|
||||
public DbSet<PersonMaintainer> PersonMaintainers { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the per-directory scan-state table used by incremental file-system scans.
|
||||
/// </summary>
|
||||
public DbSet<DirectoryScanState> DirectoryScanStates { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the temporary staging table used by the person/album discovery job.
|
||||
/// </summary>
|
||||
public DbSet<AssetAlbumStaging> AssetAlbumStaging { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initialises a new instance of the <see cref="LactoseDbContext"/> class.
|
||||
@@ -105,6 +113,15 @@ public class LactoseDbContext : DbContext {
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.ResolutionWidth, e.ResolutionHeight, e.Type })
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
// Broken assets
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.ProcessFailedAt)
|
||||
.HasDatabaseName("IX_Assets_ProcessFailedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
// Keyset pagination for scan jobs:
|
||||
// WHERE FolderId = X AND DeletedAt IS NULL AND IngestedAt >= @cursor AND Id > @last ORDER BY Id
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.FolderId, e.IngestedAt, e.Id })
|
||||
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
// People
|
||||
modelBuilder.Entity<Person>().HasIndex(p => p.Name)
|
||||
.HasDatabaseName("IX_People_Name_Trgm")
|
||||
@@ -134,5 +151,17 @@ public class LactoseDbContext : DbContext {
|
||||
modelBuilder.Entity<User>().HasMany(e => e.UploadedAssets).WithOne(e => e.Uploader).HasForeignKey(e => e.UploadedBy);
|
||||
//Folder Relationships
|
||||
modelBuilder.Entity<Folder>().HasMany(e => e.Assets).WithOne(e => e.Folder);
|
||||
//Scan state
|
||||
modelBuilder.Entity<DirectoryScanState>().HasOne(e => e.Folder).WithMany().HasForeignKey(e => e.FolderId);
|
||||
modelBuilder.Entity<DirectoryScanState>()
|
||||
.HasIndex(e => new { e.FolderId, e.Path })
|
||||
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path")
|
||||
.IsUnique();
|
||||
//Discovery staging
|
||||
modelBuilder.Entity<AssetAlbumStaging>().HasOne(e => e.Asset).WithMany().HasForeignKey(e => e.AssetId);
|
||||
modelBuilder.Entity<AssetAlbumStaging>().HasIndex(e => e.AssetId)
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
|
||||
modelBuilder.Entity<AssetAlbumStaging>().HasIndex(e => e.PersonName)
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,13 +32,14 @@ public class AlbumController(
|
||||
/// Gets an album by its ID with filtered assets based on access level.
|
||||
/// </summary>
|
||||
/// <param name="id">The album ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The full album details with accessible assets, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<AlbumFullDto> Get([FromRoute] Guid id) {
|
||||
public async Task<ActionResult<AlbumFullDto>> Get([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
Guid? uid = authService.GetUserData(User)?.Id;
|
||||
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var album = albumRepository.FindVisible(id, uid, accessLevel);
|
||||
var album = await albumRepository.FindVisibleAsync(id, uid, accessLevel, cancellationToken);
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
@@ -50,15 +51,16 @@ public class AlbumController(
|
||||
/// Searches albums with pagination, optional search term, sort options, and optional unassigned filter.
|
||||
/// </summary>
|
||||
/// <param name="pagingOptions">Pagination, search, sort, and filter parameters.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of album previews accessible to the user.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<List<AlbumPreviewDto>> Search([FromQuery] AlbumSearchParametersDto pagingOptions) {
|
||||
public async Task<ActionResult<List<AlbumPreviewDto>>> Search([FromQuery] AlbumSearchParametersDto pagingOptions, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if(pagingOptions.Page < 0 || pagingOptions.PageSize < 1 || pagingOptions.PageSize > PagedParametersDto.MaxPageSize) return BadRequest();
|
||||
|
||||
var albums = albumRepository.SearchQuery(
|
||||
var albums = await albumRepository.SearchQueryAsync(
|
||||
pagingOptions.Search ?? string.Empty,
|
||||
pagingOptions.Page,
|
||||
pagingOptions.PageSize,
|
||||
@@ -68,8 +70,9 @@ public class AlbumController(
|
||||
uid ?? default,
|
||||
accessLevel,
|
||||
pagingOptions.AssetUploadedBy,
|
||||
pagingOptions.PersonOwnerId
|
||||
).ToList();
|
||||
pagingOptions.PersonOwnerId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Ok(albums);
|
||||
}
|
||||
@@ -79,37 +82,36 @@ public class AlbumController(
|
||||
/// </summary>
|
||||
/// <param name="id">The album ID.</param>
|
||||
/// <param name="albumDto">The album update data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 403 if forbidden, 404 if not found.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto) {
|
||||
public async Task<ActionResult> Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto, CancellationToken cancellationToken) {
|
||||
Guid? uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var album = albumRepository.FindWithAssets(id);
|
||||
var album = await albumRepository.FindWithAssetsAsync(id, cancellationToken);
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
|
||||
// Maintainer scope check — must maintain the album's person
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Maintainer when album.PersonOwnerId == null || !personRepository.IsMaintainerOf(uid!.Value, album.PersonOwnerId.Value):
|
||||
return Forbid();
|
||||
case EAccessLevel.User:
|
||||
return Forbid();
|
||||
}
|
||||
if (accessLevel == EAccessLevel.Maintainer && (album.PersonOwnerId == null || !await personRepository.IsMaintainerOfAsync(uid!.Value, album.PersonOwnerId.Value, cancellationToken)))
|
||||
return Forbid();
|
||||
if (accessLevel == EAccessLevel.User)
|
||||
return Forbid();
|
||||
|
||||
// Update album properties
|
||||
album.Title = string.IsNullOrEmpty(albumDto.Name) ? album.Title : albumDto.Name;
|
||||
album.UpdatedAt = DateTime.UtcNow;
|
||||
album.PersonOwnerId = albumDto.RemovePerson ? null : albumDto.Person ?? album.PersonOwnerId;
|
||||
album.Visibility = albumDto.Visibility ?? album.Visibility;
|
||||
album.Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : album.Assets;
|
||||
album.Assets = albumDto.Assets != null ? await assetRepository.FindBulkAsync(albumDto.Assets, cancellationToken) : album.Assets;
|
||||
album.CoverAssetId = albumDto.CoverAssetId ?? album.CoverAssetId;
|
||||
|
||||
// Save changes
|
||||
albumRepository.Update(album);
|
||||
albumRepository.Save();
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -118,24 +120,31 @@ public class AlbumController(
|
||||
/// Updates multiple albums in a bulk operation.
|
||||
/// </summary>
|
||||
/// <param name="bulkDto">The bulk update data with album IDs and shared values.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 403 if forbidden.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto) {
|
||||
public async Task<ActionResult> BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var albums = albumRepository.FindBulk(bulkDto.Ids).ToList();
|
||||
var albums = await albumRepository.FindBulkAsync(bulkDto.Ids, cancellationToken);
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User:
|
||||
// Users can't update albums
|
||||
return Forbid();
|
||||
case EAccessLevel.Maintainer:
|
||||
case EAccessLevel.Maintainer: {
|
||||
// Maintainers can only update albums of persons they maintain
|
||||
albums = albums.Where(a => a.PersonOwnerId != null && personRepository.IsMaintainerOf(uid!.Value, a.PersonOwnerId.Value)).ToList();
|
||||
List<Album> maintained = [];
|
||||
foreach (var a in albums) {
|
||||
if (a.PersonOwnerId != null && await personRepository.IsMaintainerOfAsync(uid!.Value, a.PersonOwnerId.Value, cancellationToken))
|
||||
maintained.Add(a);
|
||||
}
|
||||
albums = maintained;
|
||||
if (albums.Count == 0) return Forbid();
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.Curator:
|
||||
// Curators can update any album
|
||||
break;
|
||||
@@ -144,32 +153,44 @@ public class AlbumController(
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var album in albums) {
|
||||
album.Title = bulkDto.Data.Name ?? album.Title;
|
||||
album.UpdatedAt = DateTime.UtcNow;
|
||||
album.PersonOwnerId = bulkDto.Data.Person ?? album.PersonOwnerId;
|
||||
album.Visibility = bulkDto.Data.Visibility ?? album.Visibility;
|
||||
album.Assets = bulkDto.Data.Assets != null ? assetRepository.FindBulk(bulkDto.Data.Assets).ToList() : album.Assets;
|
||||
album.CoverAssetId = bulkDto.Data.CoverAssetId ?? album.CoverAssetId;
|
||||
}
|
||||
var result = await albumRepository.ExecuteInTransactionAsync(async () => {
|
||||
foreach (var album in albums) {
|
||||
album.Title = bulkDto.Data.Name ?? album.Title;
|
||||
album.UpdatedAt = DateTime.UtcNow;
|
||||
album.PersonOwnerId = bulkDto.Data.Person ?? album.PersonOwnerId;
|
||||
album.Visibility = bulkDto.Data.Visibility ?? album.Visibility;
|
||||
album.Assets = bulkDto.Data.Assets != null ? await assetRepository.FindBulkAsync(bulkDto.Data.Assets, CancellationToken.None) : album.Assets;
|
||||
album.CoverAssetId = bulkDto.Data.CoverAssetId ?? album.CoverAssetId;
|
||||
}
|
||||
|
||||
albumRepository.UpdateBulk(albums);
|
||||
albumRepository.Save();
|
||||
var result = new CascadeResultDto { AlbumsUpdated = albums.Count };
|
||||
var cascade = bulkDto.Cascade;
|
||||
var visibility = bulkDto.Data.Visibility;
|
||||
|
||||
return Ok();
|
||||
if (cascade?.ToAssets == true && visibility.HasValue) {
|
||||
result.AssetsUpdated = await assetRepository.BulkSetVisibilityByAlbumIdsAsync(
|
||||
albums.Select(a => a.Id).ToList(), visibility.Value, uid, accessLevel, CancellationToken.None);
|
||||
}
|
||||
|
||||
await albumRepository.SaveAsync(CancellationToken.None);
|
||||
return result;
|
||||
});
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new album.
|
||||
/// </summary>
|
||||
/// <param name="albumDto">The album creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public ActionResult Create([FromBody] AlbumCreateDto albumDto) {
|
||||
public async Task<ActionResult> Create([FromBody] AlbumCreateDto albumDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : new List<Asset>();
|
||||
var assets = albumDto.Assets != null ? await assetRepository.FindBulkAsync(albumDto.Assets, cancellationToken) : [];
|
||||
|
||||
var album = new Album {
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -182,7 +203,7 @@ public class AlbumController(
|
||||
};
|
||||
|
||||
albumRepository.Insert(album);
|
||||
albumRepository.Save();
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok(album.Id);
|
||||
}
|
||||
@@ -192,18 +213,19 @@ public class AlbumController(
|
||||
/// All assets from source albums are moved to the destination, then sources are hard-deleted.
|
||||
/// </summary>
|
||||
/// <param name="dto">The merge request with destination and source IDs.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
|
||||
[HttpPost("merge")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public ActionResult Merge([FromBody] AlbumMergeDto dto) {
|
||||
public async Task<ActionResult> Merge([FromBody] AlbumMergeDto dto, CancellationToken cancellationToken) {
|
||||
if (dto.SourceIds.Contains(dto.DestinationId))
|
||||
return BadRequest("Destination album ID must not appear in source album IDs.");
|
||||
|
||||
if (albumRepository.Find(dto.DestinationId) == null)
|
||||
if (await albumRepository.FindAsync(dto.DestinationId, cancellationToken) == null)
|
||||
return NotFound("Destination album not found.");
|
||||
|
||||
albumRepository.MergeAlbums(dto.DestinationId, dto.SourceIds);
|
||||
albumRepository.Save();
|
||||
await albumRepository.MergeAlbumsAsync(dto.DestinationId, dto.SourceIds, cancellationToken);
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -211,20 +233,27 @@ public class AlbumController(
|
||||
/// Removes multiple albums (soft delete).
|
||||
/// </summary>
|
||||
/// <param name="albumIds">The list of album IDs to remove.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 403 if forbidden.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult BulkDelete([FromBody] List<Guid> albumIds) {
|
||||
public async Task<ActionResult> BulkDelete([FromBody] List<Guid> albumIds, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var albums = albumRepository.FindBulk(albumIds).ToList();
|
||||
var albums = await albumRepository.FindBulkAsync(albumIds, cancellationToken);
|
||||
|
||||
// Filter by access level
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Maintainer:
|
||||
albums = albums.Where(a => a.PersonOwnerId != null && personRepository.IsMaintainerOf(uid!.Value, a.PersonOwnerId.Value)).ToList();
|
||||
case EAccessLevel.Maintainer: {
|
||||
List<Album> maintained = [];
|
||||
foreach (var a in albums) {
|
||||
if (a.PersonOwnerId != null && await personRepository.IsMaintainerOfAsync(uid!.Value, a.PersonOwnerId.Value, cancellationToken))
|
||||
maintained.Add(a);
|
||||
}
|
||||
albums = maintained;
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.User:
|
||||
return Forbid();
|
||||
}
|
||||
@@ -232,7 +261,7 @@ public class AlbumController(
|
||||
if (albums.Count == 0) return Forbid();
|
||||
|
||||
albums.ForEach(albumRepository.Remove);
|
||||
albumRepository.Save();
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -241,27 +270,26 @@ public class AlbumController(
|
||||
/// Removes an album by its ID (soft delete).
|
||||
/// </summary>
|
||||
/// <param name="id">The album ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found, 403 if forbidden.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult Delete(Guid id) {
|
||||
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var album = albumRepository.Find(id);
|
||||
var album = await albumRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Maintainer when album.PersonOwnerId == null || !personRepository.IsMaintainerOf(uid!.Value, album.PersonOwnerId.Value):
|
||||
return Forbid();
|
||||
case EAccessLevel.User:
|
||||
return Forbid();
|
||||
}
|
||||
if (accessLevel == EAccessLevel.Maintainer && (album.PersonOwnerId == null || !await personRepository.IsMaintainerOfAsync(uid!.Value, album.PersonOwnerId.Value, cancellationToken)))
|
||||
return Forbid();
|
||||
if (accessLevel == EAccessLevel.User)
|
||||
return Forbid();
|
||||
|
||||
albumRepository.Remove(album);
|
||||
albumRepository.Save();
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace Lactose.Controllers;
|
||||
/// <param name="assetRepository">Asset repository.</param>
|
||||
/// <param name="userRepository">User repository.</param>
|
||||
/// <param name="personRepository">Person repository (for maintainer scope checks).</param>
|
||||
/// <param name="filesystemBrowseService">Filesystem browsing service for the maintenance page.</param>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AssetController(
|
||||
@@ -26,15 +27,17 @@ public class AssetController(
|
||||
LactoseAuthService authService,
|
||||
IAssetRepository assetRepository,
|
||||
IUserRepository userRepository,
|
||||
IPersonRepository personRepository
|
||||
IPersonRepository personRepository,
|
||||
IFilesystemBrowseService filesystemBrowseService
|
||||
) : ControllerBase {
|
||||
/// <summary>
|
||||
/// Gets an asset by its ID with full details, respecting access level.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The full asset details, or 404/401 based on permissions.</returns>
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<AssetDto> Get(Guid id) {
|
||||
public async Task<ActionResult<AssetDto>> Get(Guid id, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
@@ -46,7 +49,7 @@ public class AssetController(
|
||||
"""
|
||||
);
|
||||
|
||||
var asset = assetRepository.FindVisible(id, uid, accessLevel);
|
||||
var asset = await assetRepository.FindVisibleAsync(id, uid, accessLevel, cancellationToken);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found or not visible!");
|
||||
@@ -76,9 +79,10 @@ public class AssetController(
|
||||
/// Searches assets with optional date range, type filter, random ordering, unlinked filter, folder filter, uploader filter, search term, and pagination.
|
||||
/// </summary>
|
||||
/// <param name="searchOptionsDto">Search options including date range, type filter, unlinked filter, folder filter, uploader filter, random ordering, search term, and pagination.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of asset previews.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<List<AssetPreviewDto>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto) {
|
||||
public async Task<ActionResult<List<AssetPreviewDto>>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto, CancellationToken cancellationToken) {
|
||||
var userData = authService.GetUserData(User);
|
||||
var uid = userData?.Id;
|
||||
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
|
||||
@@ -95,12 +99,13 @@ public class AssetController(
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
var assets = assetRepository.GetAssets(
|
||||
var (assets, total) = await assetRepository.GetAssetsAsync(
|
||||
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
|
||||
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
|
||||
searchOptionsDto.Page, searchOptionsDto.PageSize,
|
||||
uid, accessLevel,
|
||||
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search,
|
||||
searchOptionsDto.IncludeCount
|
||||
searchOptionsDto.IncludeCount, searchOptionsDto.DeletedOnly, searchOptionsDto.AlbumId, searchOptionsDto.PersonId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
logger.LogTrace(
|
||||
@@ -115,7 +120,7 @@ public class AssetController(
|
||||
"""
|
||||
);
|
||||
|
||||
logger.LogTrace($"Returning {assets.Count()} assets (total: {total})");
|
||||
logger.LogTrace($"Returning {assets.Count} assets (total: {total})");
|
||||
Response.Headers["X-Total-Count"] = total.ToString();
|
||||
return Ok(assets);
|
||||
}
|
||||
@@ -128,27 +133,28 @@ public class AssetController(
|
||||
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
|
||||
[HttpGet("unlinked-groups")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult<List<AssetGroupDto>> GetFolderGroups([FromQuery] bool unlinkedOnly = true) {
|
||||
public async Task<ActionResult<List<AssetGroupDto>>> GetFolderGroups([FromQuery] bool unlinkedOnly = true, CancellationToken cancellationToken = default) {
|
||||
var userData = authService.GetUserData(User);
|
||||
var uid = userData?.Id;
|
||||
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
logger.LogTrace("GetFolderGroups: uid={Uid}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", uid, accessLevel, unlinkedOnly);
|
||||
|
||||
var groups = assetRepository.GetFolderGroups(uid, accessLevel, unlinkedOnly);
|
||||
var groups = await assetRepository.GetFolderGroupsAsync(uid, accessLevel, unlinkedOnly, cancellationToken);
|
||||
logger.LogTrace("GetFolderGroups: returned {Count} groups", groups.Count);
|
||||
return Ok(groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Browses assets at a specific directory level within a folder, returning immediate subdirectories
|
||||
/// and a paginated list of assets at that level.
|
||||
/// Browses a folder's filesystem directly, returning the database asset records for the files found on disk.
|
||||
/// Used by the maintenance page; does not reconstruct the folder tree from stored paths.
|
||||
/// </summary>
|
||||
/// <param name="options">Browse options including folder ID, path, pagination, search, and unlinked-only filter.</param>
|
||||
/// <returns>A browse result with subdirectories and assets at the current level.</returns>
|
||||
[HttpGet("browse")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult<AssetBrowseResultDto> Browse([FromQuery] AssetBrowseOptionsDto options) {
|
||||
/// <param name="options">Browse options including folder ID, relative path, pagination, search, and unlinked-only filter.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A browse result with subdirectories and assets at the current level, or 404 if the path is invalid.</returns>
|
||||
[HttpGet("directory")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public async Task<ActionResult<AssetBrowseResultDto>> FsBrowse([FromQuery] AssetBrowseOptionsDto options, CancellationToken cancellationToken) {
|
||||
var userData = authService.GetUserData(User);
|
||||
var uid = userData?.Id;
|
||||
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
|
||||
@@ -156,25 +162,67 @@ public class AssetController(
|
||||
if (options.Page < 0 || options.PageSize < 1 || options.PageSize > PagedParametersDto.MaxPageSize)
|
||||
return BadRequest();
|
||||
|
||||
logger.LogTrace("Browse: folderId={FolderId}, path={Path}, page={Page}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", options.FolderId, options.Path, options.Page, accessLevel, options.UnlinkedOnly);
|
||||
logger.LogTrace("FsBrowse: folderId={FolderId}, path={Path}, page={Page}, unlinkedOnly={UnlinkedOnly}", options.FolderId, options.Path, options.Page, options.UnlinkedOnly);
|
||||
|
||||
var result = assetRepository.BrowseAssets(options.FolderId, options.Path ?? "", options.Page, options.PageSize, uid, accessLevel, options.Search, options.UnlinkedOnly);
|
||||
var result = await filesystemBrowseService.BrowseAsync(options.FolderId, options.Path ?? "", options.Page, options.PageSize, options.UnlinkedOnly, options.Search, uid, accessLevel, cancellationToken);
|
||||
if (result == null) return NotFound();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a paginated list of assets marked broken (processing failed, not deleted).
|
||||
/// </summary>
|
||||
/// <param name="page">The zero-based page number.</param>
|
||||
/// <param name="pageSize">The number of assets per page.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A page of broken assets; total count in the <c>X-Total-Count</c> header.</returns>
|
||||
[HttpGet("broken")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public async Task<ActionResult<List<AssetPreviewDto>>> GetBroken([FromQuery] int page = 0, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel < EAccessLevel.Curator) { return Unauthorized(); }
|
||||
|
||||
if (page < 0 || pageSize < 1 || pageSize > PagedParametersDto.MaxPageSize)
|
||||
return BadRequest();
|
||||
|
||||
var (assets, total) = await assetRepository.GetBrokenAssetsAsync(page, pageSize, cancellationToken);
|
||||
Response.Headers["X-Total-Count"] = total.ToString();
|
||||
return Ok(assets);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the broken (processing failed) state for the given assets so they are picked up
|
||||
/// for re-processing on the next job run.
|
||||
/// </summary>
|
||||
/// <param name="ids">The list of asset IDs to retry.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPost("broken/retry")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public async Task<IStatusCodeActionResult> RetryBroken([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel < EAccessLevel.Curator) { return Unauthorized(); }
|
||||
|
||||
if (ids.Count == 0) return BadRequest();
|
||||
|
||||
await assetRepository.ClearProcessErrorAsync(ids, cancellationToken);
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an asset's metadata.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="dto">The asset update data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto) {
|
||||
public async Task<IStatusCodeActionResult> Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
var asset = assetRepository.FindWithAlbums(id);
|
||||
var asset = await assetRepository.FindWithAlbumsAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||
@@ -182,7 +230,7 @@ public class AssetController(
|
||||
}
|
||||
|
||||
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
|
||||
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
|
||||
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !await IsMaintainedByUser(uid.Value, asset, cancellationToken))) {
|
||||
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to update asset {id} that they do not own");
|
||||
return Unauthorized();
|
||||
}
|
||||
@@ -192,8 +240,18 @@ public class AssetController(
|
||||
asset.Visibility = dto.Visibility ?? asset.Visibility;
|
||||
log += $"Visibility: {asset.Visibility} -> {dto.Visibility ?? asset.Visibility}\n";
|
||||
|
||||
asset.DeletedAt = dto.DeletedAt ?? asset.DeletedAt;
|
||||
log += $"DeletedAt: {asset.DeletedAt} -> {dto.DeletedAt ?? asset.DeletedAt}\n";
|
||||
if (dto.IsDeleted.HasValue) {
|
||||
// Restoring a soft-deleted asset requires its source file to still exist on disk;
|
||||
// otherwise the crawl/integrity jobs would silently re-delete it on their next run.
|
||||
if (!dto.IsDeleted.Value && asset.DeletedAt != null && !System.IO.File.Exists(asset.OriginalPath)) {
|
||||
logger.LogWarning($"Cannot restore asset {id}: source file '{asset.OriginalPath}' does not exist on disk!");
|
||||
return Conflict();
|
||||
}
|
||||
|
||||
DateTime? newDeletedAt = dto.IsDeleted.Value ? DateTime.UtcNow : null;
|
||||
log += $"DeletedAt: {asset.DeletedAt} -> {newDeletedAt}\n";
|
||||
asset.DeletedAt = newDeletedAt;
|
||||
}
|
||||
|
||||
if (accesslevel == EAccessLevel.Admin) {
|
||||
asset.UploadedBy = dto.UploadedBy ?? asset.UploadedBy;
|
||||
@@ -203,7 +261,7 @@ public class AssetController(
|
||||
logger.LogTrace($"Updating asset {id}\n{log}");
|
||||
|
||||
assetRepository.Update(asset);
|
||||
assetRepository.Save();
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -212,13 +270,14 @@ public class AssetController(
|
||||
/// Updates multiple assets in a bulk operation.
|
||||
/// </summary>
|
||||
/// <param name="bulkDto">The bulk update data with asset IDs and shared values.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, or an error response.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto) {
|
||||
public async Task<IStatusCodeActionResult> BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
var assets = assetRepository.FindBulk(bulkDto.Ids);
|
||||
var assets = await assetRepository.FindBulkAsync(bulkDto.Ids, cancellationToken);
|
||||
|
||||
var enumerable = assets as List<Asset> ?? assets.ToList();
|
||||
|
||||
@@ -235,13 +294,20 @@ public class AssetController(
|
||||
//If the user is not an admin, they can only update their own assets
|
||||
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
|
||||
enumerable = accesslevel == EAccessLevel.Maintainer
|
||||
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
|
||||
? await FilterForMaintainerAsync(enumerable, uid.Value, cancellationToken)
|
||||
: enumerable.Where(x => x.UploadedBy == uid).ToList();
|
||||
|
||||
if (bulkDto.Data.IsDeleted == false) {
|
||||
var result = ApplyBulkRestore(enumerable, bulkDto.Data);
|
||||
assetRepository.UpdateBulk(enumerable);
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
enumerable.ForEach(
|
||||
x => {
|
||||
x.Visibility = bulkDto.Data.Visibility ?? x.Visibility;
|
||||
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
|
||||
x.DeletedAt = bulkDto.Data.IsDeleted == true ? DateTime.UtcNow : x.DeletedAt;
|
||||
|
||||
if (accesslevel == EAccessLevel.Admin) {
|
||||
x.UploadedBy = bulkDto.Data.UploadedBy ?? x.UploadedBy;
|
||||
@@ -250,21 +316,54 @@ public class AssetController(
|
||||
);
|
||||
|
||||
assetRepository.UpdateBulk(enumerable);
|
||||
assetRepository.Save();
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a restore request (<c>IsDeleted</c> = <see langword="false"/>) to the given assets.
|
||||
/// Assets whose source file no longer exists on disk are skipped and reported instead of restored,
|
||||
/// since the crawl/integrity jobs would re-delete them otherwise. All other update fields are still
|
||||
/// applied to the assets that are not skipped.
|
||||
/// </summary>
|
||||
/// <param name="assets">The assets selected for the bulk update.</param>
|
||||
/// <param name="data">The shared update values.</param>
|
||||
/// <returns>The outcome listing restored IDs and IDs skipped due to missing files.</returns>
|
||||
private BulkRestoreResultDto ApplyBulkRestore(List<Asset> assets, AssetUpdateDto data) {
|
||||
BulkRestoreResultDto result = new();
|
||||
|
||||
foreach (var x in assets) {
|
||||
if (x.DeletedAt != null && !System.IO.File.Exists(x.OriginalPath)) {
|
||||
result.SkippedMissingFile.Add(x.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool wasRestored = x.DeletedAt != null;
|
||||
|
||||
x.Visibility = data.Visibility ?? x.Visibility;
|
||||
x.DeletedAt = null;
|
||||
|
||||
if (wasRestored) result.Restored.Add(x.Id);
|
||||
}
|
||||
|
||||
if (result.SkippedMissingFile.Count > 0)
|
||||
logger.LogWarning($"Bulk restore: {result.SkippedMissingFile.Count} asset(s) skipped because their source files are missing on disk");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes an asset by its ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult Delete([FromRoute] Guid id) {
|
||||
public async Task<IStatusCodeActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
var asset = assetRepository.FindWithAlbums(id);
|
||||
var asset = await assetRepository.FindWithAlbumsAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||
@@ -272,14 +371,14 @@ public class AssetController(
|
||||
}
|
||||
|
||||
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
|
||||
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
|
||||
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !await IsMaintainedByUser(uid.Value, asset, cancellationToken))) {
|
||||
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to delete asset {id} that they do not own");
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
asset.DeletedAt = DateTime.UtcNow;
|
||||
assetRepository.Update(asset);
|
||||
assetRepository.Save();
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -288,13 +387,14 @@ public class AssetController(
|
||||
/// Soft-deletes multiple assets in a bulk operation.
|
||||
/// </summary>
|
||||
/// <param name="ids">The list of asset IDs to delete.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, or an error response.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||
public async Task<IStatusCodeActionResult> BulkDelete([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
var assets = assetRepository.FindBulk(ids);
|
||||
var assets = await assetRepository.FindBulkAsync(ids, cancellationToken);
|
||||
|
||||
var enumerable = assets as List<Asset> ?? assets.ToList();
|
||||
|
||||
@@ -311,13 +411,13 @@ public class AssetController(
|
||||
//If the user is not an admin, they can only delete their own assets
|
||||
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
|
||||
enumerable = accesslevel == EAccessLevel.Maintainer
|
||||
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
|
||||
? await FilterForMaintainerAsync(enumerable, uid.Value, cancellationToken)
|
||||
: enumerable.Where(x => x.UploadedBy == uid).ToList();
|
||||
|
||||
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
|
||||
|
||||
assetRepository.UpdateBulk(enumerable);
|
||||
assetRepository.Save();
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -325,16 +425,17 @@ public class AssetController(
|
||||
/// Creates a new asset record.
|
||||
/// </summary>
|
||||
/// <param name="dto">The asset creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the new asset ID, or an error response.</returns>
|
||||
[NonAction]
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public ActionResult<Guid> Create([FromBody] AssetCreateDto dto) {
|
||||
public async Task<ActionResult<Guid>> Create([FromBody] AssetCreateDto dto, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
|
||||
if (dto.UploadedBy.HasValue) {
|
||||
if (userRepository.Find(dto.UploadedBy.Value) == null) {
|
||||
if (await userRepository.FindAsync(dto.UploadedBy.Value, cancellationToken) == null) {
|
||||
logger.LogWarning($"User {dto.UploadedBy} does not exist in the database");
|
||||
return BadRequest();
|
||||
}
|
||||
@@ -357,11 +458,12 @@ public class AssetController(
|
||||
Duration = dto.Duration ?? 0,
|
||||
FrameRate = dto.FrameRate ?? 0,
|
||||
OriginalPath = String.Empty,
|
||||
OriginalFilename = String.Empty
|
||||
OriginalFilename = String.Empty,
|
||||
IngestedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
assetRepository.Insert(asset);
|
||||
assetRepository.Save();
|
||||
await assetRepository.InsertAsync(asset, cancellationToken);
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok(asset.Id);
|
||||
}
|
||||
@@ -372,7 +474,32 @@ public class AssetController(
|
||||
/// </summary>
|
||||
/// <param name="uid">The maintainer's user ID.</param>
|
||||
/// <param name="asset">The asset to check.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns><see langword="true"/> if the maintainer is linked via album→person→maintainer chain.</returns>
|
||||
private bool IsMaintainedByUser(Guid uid, Asset asset) =>
|
||||
asset.Albums?.Any(a => a.PersonOwnerId.HasValue && personRepository.IsMaintainerOf(uid, a.PersonOwnerId.Value)) == true;
|
||||
private async Task<bool> IsMaintainedByUser(Guid uid, Asset asset, CancellationToken cancellationToken) {
|
||||
if (asset.Albums == null) return false;
|
||||
foreach (var album in asset.Albums) {
|
||||
if (album.PersonOwnerId.HasValue
|
||||
&& await personRepository.IsMaintainerOfAsync(uid, album.PersonOwnerId.Value, cancellationToken))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters an asset list down to assets uploaded by the maintainer or linked through
|
||||
/// the cosplayers they maintain.
|
||||
/// </summary>
|
||||
/// <param name="assets">The assets to filter.</param>
|
||||
/// <param name="uid">The maintainer's user ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The filtered asset list preserving input order.</returns>
|
||||
private async Task<List<Asset>> FilterForMaintainerAsync(IEnumerable<Asset> assets, Guid uid, CancellationToken cancellationToken) {
|
||||
List<Asset> result = [];
|
||||
foreach (var asset in assets) {
|
||||
if (asset.UploadedBy == uid || await IsMaintainedByUser(uid, asset, cancellationToken))
|
||||
result.Add(asset);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,17 +35,18 @@ public class AuthController(
|
||||
/// Authenticates a user by username/email and password, issuing JWT and refresh tokens.
|
||||
/// </summary>
|
||||
/// <param name="userDto">The login credentials.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>An authentication result with tokens, or an error.</returns>
|
||||
[HttpPost("login")]
|
||||
public ActionResult<AuthResultDto> Login([FromBody] CredentialsDto userDto) {
|
||||
public async Task<ActionResult<AuthResultDto>> Login([FromBody] CredentialsDto userDto, CancellationToken cancellationToken) {
|
||||
User? user;
|
||||
|
||||
if (userDto.Identifier.Contains('@')) {
|
||||
//search for user in database (by email)
|
||||
user = userRepository.FindByEmail(userDto.Identifier);
|
||||
user = await userRepository.FindByEmailAsync(userDto.Identifier, cancellationToken);
|
||||
} else {
|
||||
//search for user in database (by username)
|
||||
user = userRepository.FindByUsername(userDto.Identifier);
|
||||
user = await userRepository.FindByUsernameAsync(userDto.Identifier, cancellationToken);
|
||||
}
|
||||
if (user == null) {
|
||||
return NotFound(new AuthResultDto() {
|
||||
@@ -79,16 +80,16 @@ public class AuthController(
|
||||
case PasswordVerificationResult.Failed: return NotFound(new AuthResultDto() { Success = false, ErrorMessage = "User or Password was wrong"});
|
||||
case PasswordVerificationResult.SuccessRehashNeeded:
|
||||
user.Password = passwordHasher.HashPassword(user, userDto.Password);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
break;
|
||||
case PasswordVerificationResult.Success: break;
|
||||
}
|
||||
|
||||
user.LastLogin = DateTime.UtcNow;
|
||||
|
||||
|
||||
var token = authService.GenerateAccessToken(user);
|
||||
var refreshToken = authService.GenerateRefreshToken(user);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return new AuthResultDto() {
|
||||
UserId = user.Id,
|
||||
Token = token,
|
||||
@@ -100,10 +101,11 @@ public class AuthController(
|
||||
/// <summary>
|
||||
/// Checks whether user registration is currently enabled.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 if enabled, 403 if disabled.</returns>
|
||||
[HttpGet("register")]
|
||||
public ActionResult CheckRegistrationEnabled() {
|
||||
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
|
||||
public async Task<ActionResult> CheckRegistrationEnabled(CancellationToken cancellationToken) {
|
||||
var regSetting = await settingsRepository.GetAsync(Settings.UserRegistrationEnabled.AsString(), cancellationToken);
|
||||
return regSetting?.Value == "true" ? Ok() : StatusCode(403);
|
||||
}
|
||||
|
||||
@@ -111,11 +113,12 @@ public class AuthController(
|
||||
/// Registers a new user account.
|
||||
/// </summary>
|
||||
/// <param name="dto">The registration data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the new user ID, 403 if registration is disabled, or 409 if the user already exists.</returns>
|
||||
//TODO: Switch Guid reply with Authentication Result (giving a complete reason in case of failure or giving the authentication token)
|
||||
[HttpPost("register")]
|
||||
public ActionResult Register([FromBody] UserRegisterDto dto) {
|
||||
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
|
||||
public async Task<ActionResult> Register([FromBody] UserRegisterDto dto, CancellationToken cancellationToken) {
|
||||
var regSetting = await settingsRepository.GetAsync(Settings.UserRegistrationEnabled.AsString(), cancellationToken);
|
||||
if (regSetting?.Value != "true")
|
||||
return StatusCode(403, "Registration is currently disabled on this server");
|
||||
|
||||
@@ -127,7 +130,8 @@ public class AuthController(
|
||||
"""
|
||||
);
|
||||
|
||||
if (userRepository.FindByEmail(dto.Email) != null || userRepository.FindByUsername(dto.Username) != null) {
|
||||
if (await userRepository.FindByEmailAsync(dto.Email, cancellationToken) != null
|
||||
|| await userRepository.FindByUsernameAsync(dto.Username, cancellationToken) != null) {
|
||||
return Conflict("User already exists");
|
||||
}
|
||||
|
||||
@@ -142,7 +146,7 @@ public class AuthController(
|
||||
user.Password = passwordHasher.HashPassword(user, dto.Password);
|
||||
|
||||
userRepository.Insert(user);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return Ok(user.Id);
|
||||
}
|
||||
|
||||
@@ -152,17 +156,17 @@ public class AuthController(
|
||||
/// <returns>200 on success, 401 if unauthorized.</returns>
|
||||
[Authorize]
|
||||
[HttpPost("logout")]
|
||||
public ActionResult Logout() {
|
||||
public async Task<ActionResult> Logout(CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? identity = authService.GetUserData(User);
|
||||
|
||||
if (identity == null) { return Unauthorized(); }
|
||||
|
||||
|
||||
//Reset token from the database
|
||||
User? user = userRepository.Find(identity.Id);
|
||||
User? user = await userRepository.FindAsync(identity.Id, cancellationToken);
|
||||
if (user == null) { return Unauthorized(); }
|
||||
user.RefreshToken = string.Empty;
|
||||
user.RefreshTokenExpires = null;
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -172,10 +176,11 @@ public class AuthController(
|
||||
/// Refreshes an expired JWT access token using a valid refresh token.
|
||||
/// </summary>
|
||||
/// <param name="token">The refresh token request data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A new access token, and optionally a new refresh token.</returns>
|
||||
[HttpPost("refresh")]
|
||||
public ActionResult<AuthResultDto> RefreshToken([FromBody] RefreshDto token) {
|
||||
User? user = userRepository.Find(token.UserId);
|
||||
public async Task<ActionResult<AuthResultDto>> RefreshToken([FromBody] RefreshDto token, CancellationToken cancellationToken) {
|
||||
User? user = await userRepository.FindAsync(token.UserId, cancellationToken);
|
||||
|
||||
if (user == null) {
|
||||
return NotFound(
|
||||
@@ -225,7 +230,7 @@ public class AuthController(
|
||||
authDto.RefreshToken = refreshToken;
|
||||
user.RefreshToken = refreshToken;
|
||||
user.RefreshTokenExpires = DateTime.UtcNow.AddMinutes(LactoseAuthService.LongExpirationTime);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return authDto;
|
||||
|
||||
@@ -29,10 +29,11 @@ namespace Lactose.Controllers;
|
||||
/// Creates a new folder for asset scanning.
|
||||
/// </summary>
|
||||
/// <param name="folderCreateDto">The folder creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Create([FromBody] FolderCreateDto folderCreateDto) {
|
||||
public async Task<ActionResult> Create([FromBody] FolderCreateDto folderCreateDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
@@ -43,40 +44,39 @@ namespace Lactose.Controllers;
|
||||
Active = false
|
||||
};
|
||||
|
||||
folderRepository.Insert(newFolder);
|
||||
await folderRepository.InsertAsync(newFolder, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tracked folders.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of all folders.</returns>
|
||||
[HttpGet]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<List<FolderFullDto>> GetAll() {
|
||||
public async Task<ActionResult<List<FolderFullDto>>> GetAll(CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
return Ok(
|
||||
folderRepository.GetAll()
|
||||
.Select(folder => folder.ToFolderDto())
|
||||
.ToList()
|
||||
);
|
||||
List<Folder> folders = await folderRepository.GetAllAsync(cancellationToken);
|
||||
return Ok(folders.Select(folder => folder.ToFolderDto()).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific folder by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The folder details, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<FolderFullDto> Get([FromRoute] Guid id) {
|
||||
public async Task<ActionResult<FolderFullDto>> Get([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
var folder = folderRepository.Get(id);
|
||||
var folder = await folderRepository.GetAsync(id, cancellationToken);
|
||||
|
||||
if (folder == null) return NotFound();
|
||||
|
||||
@@ -88,22 +88,23 @@ namespace Lactose.Controllers;
|
||||
/// </summary>
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <param name="folderUpdateDto">The folder update data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Update([FromRoute] Guid id, [FromBody] FolderUpdateDto folderUpdateDto) {
|
||||
public async Task<ActionResult> Update([FromRoute] Guid id, [FromBody] FolderUpdateDto folderUpdateDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
Folder? folder = folderRepository.Get(id);
|
||||
Folder? folder = await folderRepository.GetAsync(id, cancellationToken);
|
||||
if (folder == null) return NotFound();
|
||||
|
||||
|
||||
folder.BasePath = folderUpdateDto.BasePath ?? folder.BasePath;
|
||||
folder.Active = folderUpdateDto.Active ?? folder.Active;
|
||||
folder.RegexPattern = folderUpdateDto.RegexPattern ?? folder.RegexPattern;
|
||||
|
||||
folderRepository.Update(id, folder);
|
||||
await folderRepository.UpdateAsync(id, folder, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -112,20 +113,21 @@ namespace Lactose.Controllers;
|
||||
/// </summary>
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <param name="count">Number of sample paths to return (default 10, max 50).</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of up to <paramref name="count"/> random asset paths, or 404 if the folder is not found.</returns>
|
||||
[HttpGet("{id}/sample-paths")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<FolderSamplePathsDto> GetSamplePaths([FromRoute] Guid id, [FromQuery] int count = 10) {
|
||||
public async Task<ActionResult<FolderSamplePathsDto>> GetSamplePaths([FromRoute] Guid id, [FromQuery] int count = 10, CancellationToken cancellationToken = default) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
var folder = folderRepository.Get(id);
|
||||
var folder = await folderRepository.GetAsync(id, cancellationToken);
|
||||
if (folder == null) return NotFound();
|
||||
|
||||
count = Math.Clamp(count, 1, 50);
|
||||
|
||||
var paths = assetRepository.GetRandomPathsByFolder(id, count);
|
||||
var paths = await assetRepository.GetRandomPathsByFolderAsync(id, count, cancellationToken);
|
||||
|
||||
return Ok(new FolderSamplePathsDto {
|
||||
FolderId = id,
|
||||
@@ -137,15 +139,16 @@ namespace Lactose.Controllers;
|
||||
/// Deletes a folder (stops tracking it).
|
||||
/// </summary>
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Delete([FromRoute] Guid id) {
|
||||
public async Task<ActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
folderRepository.Remove(id);
|
||||
await folderRepository.RemoveAsync(id, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,17 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
||||
/// <summary>
|
||||
/// Returns a page of past root jobs with total count. Polled less frequently or on demand.
|
||||
/// </summary>
|
||||
/// <param name="page">The zero-based page number.</param>
|
||||
/// <param name="pageSize">The number of records per page.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A page of past jobs and the total count.</returns>
|
||||
[HttpGet("past")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 0, [FromQuery] int pageSize = 5) {
|
||||
public async Task<ActionResult<PastJobsResponse>> GetPast([FromQuery] int page = 0, [FromQuery] int pageSize = 5, CancellationToken cancellationToken = default) {
|
||||
if (page < 0 || pageSize < 1 || pageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
var (jobs, total) = jobManager.GetPastRootJobs(page, pageSize);
|
||||
var (jobs, total) = await jobManager.GetPastRootJobsAsync(page, pageSize, cancellationToken);
|
||||
return Ok(new PastJobsResponse { Jobs = jobs, Total = total });
|
||||
}
|
||||
|
||||
@@ -50,12 +54,16 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
||||
/// Supports delta sync — pass <paramref name="since"/> to receive only children changed after that timestamp.
|
||||
/// The <see cref="ChildrenResponse.Since"/> field contains the timestamp to use for the next delta poll.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The parent job ID.</param>
|
||||
/// <param name="since">Optional timestamp for delta sync.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The children of the job, plus the timestamp for the next delta poll.</returns>
|
||||
[HttpGet("{parentId}/children")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<ChildrenResponse> GetChildren(Guid parentId, [FromQuery] DateTime? since) {
|
||||
public async Task<ActionResult<ChildrenResponse>> GetChildren(Guid parentId, [FromQuery] DateTime? since, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
var children = jobManager.GetChildren(parentId, since);
|
||||
var children = await jobManager.GetChildrenAsync(parentId, since, cancellationToken);
|
||||
return Ok(new ChildrenResponse { Children = children, Since = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
@@ -96,20 +104,20 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
||||
/// Enqueues a new job of the specified type.
|
||||
/// </summary>
|
||||
/// <param name="jobRequest">The job request containing the job type and optional parameters.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 if enqueued, 400 if invalid request, 501 if not implemented.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Enqueue([FromBody] JobRequestDto jobRequest) {
|
||||
public async Task<ActionResult> Enqueue([FromBody] JobRequestDto jobRequest, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
switch (jobRequest.JobType) {
|
||||
case EJobType.FileSystemScan:
|
||||
if (jobRequest.Parameters.Count != 0)
|
||||
return BadRequest("FullFileSystemScan job does not require any parameters.");
|
||||
|
||||
jobScheduler.QueueFileSystemCrawl(null);
|
||||
if (!TryParseJobOptions(jobRequest.Parameters, out var scanDeep, out var scanFolders, out var scanError))
|
||||
return BadRequest(scanError);
|
||||
await jobScheduler.QueueFileSystemCrawlAsync(scanDeep, scanFolders, cancellationToken);
|
||||
return Ok();
|
||||
case EJobType.ThumbnailGeneration:
|
||||
if (jobRequest.Parameters.Count != 0)
|
||||
@@ -135,22 +143,56 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
||||
|
||||
jobScheduler.QueueIntegrityCheck(null);
|
||||
return Ok();
|
||||
case EJobType.CreatePersons:
|
||||
if (jobRequest.Parameters.Count != 0)
|
||||
return BadRequest("CreatePersons job does not require any parameters.");
|
||||
jobScheduler.QueueCreatePersons(null);
|
||||
return Ok();
|
||||
case EJobType.CreateAlbums:
|
||||
if (jobRequest.Parameters.Count != 0)
|
||||
return BadRequest("CreateAlbums job does not require any parameters.");
|
||||
jobScheduler.QueueCreateAlbums(null);
|
||||
if (!TryParseJobOptions(jobRequest.Parameters, out var albumsDeep, out var albumsFolders, out var albumsError))
|
||||
return BadRequest(albumsError);
|
||||
jobScheduler.QueueCreateAlbums(albumsDeep, albumsFolders);
|
||||
return Ok();
|
||||
case EJobType.PHashGeneration:
|
||||
if (jobRequest.Parameters.Count > 1)
|
||||
return BadRequest("PHashGeneration job takes zero or one parameter (the asset ID).");
|
||||
jobScheduler.QueuePHashGeneration(jobRequest.Parameters.FirstOrDefault());
|
||||
return Ok();
|
||||
case EJobType.AnimatedConversion:
|
||||
if (jobRequest.Parameters.Count != 0)
|
||||
return BadRequest("AnimatedConversion job does not require any parameters.");
|
||||
jobScheduler.QueueAnimatedConversion(null);
|
||||
return Ok();
|
||||
default: return BadRequest("Invalid job type.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the common job parameters for scan/discovery jobs: the literal <c>deep</c> plus repeatable
|
||||
/// <c>folder=<guid></c> entries. Unknown parameters are rejected.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The raw job parameters.</param>
|
||||
/// <param name="deep">Whether a deep scan was requested.</param>
|
||||
/// <param name="folderIds">The selected folder ids (empty means all active folders).</param>
|
||||
/// <param name="error">An error message when parsing fails, otherwise null.</param>
|
||||
/// <returns>True when the parameters are valid.</returns>
|
||||
static bool TryParseJobOptions(List<string> parameters, out bool deep, out List<Guid> folderIds, out string? error) {
|
||||
deep = false;
|
||||
folderIds = new List<Guid>();
|
||||
error = null;
|
||||
|
||||
foreach (var raw in parameters) {
|
||||
var param = raw.Trim();
|
||||
if (string.Equals(param, "deep", StringComparison.OrdinalIgnoreCase)) {
|
||||
deep = true;
|
||||
} else if (param.StartsWith("folder=", StringComparison.OrdinalIgnoreCase)) {
|
||||
var idText = param["folder=".Length..].Trim();
|
||||
if (!Guid.TryParse(idText, out var folderId)) {
|
||||
error = $"Invalid folder parameter '{param}'.";
|
||||
return false;
|
||||
}
|
||||
folderIds.Add(folderId);
|
||||
} else {
|
||||
error = $"Unknown job parameter '{param}'.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
|
||||
namespace Lactose.Controllers;
|
||||
@@ -18,6 +19,7 @@ namespace Lactose.Controllers;
|
||||
/// <param name="mediaRepository">Media repository.</param>
|
||||
/// <param name="assetRepository">Asset repository.</param>
|
||||
/// <param name="signKeyOptions">JWT signing key configuration.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[AllowAnonymous]
|
||||
@@ -26,43 +28,95 @@ public class MediaController(
|
||||
LactoseAuthService authService,
|
||||
IMediaRepository mediaRepository,
|
||||
IAssetRepository assetRepository,
|
||||
IOptions<SignKeyConfiguration> signKeyOptions
|
||||
IOptions<SignKeyConfiguration> signKeyOptions,
|
||||
ILogger<MediaController> logger
|
||||
) : ControllerBase {
|
||||
|
||||
/// <summary>
|
||||
/// Gets the original image file for an asset.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The physical image file, or 404 if not found or access denied.</returns>
|
||||
[HttpGet("{id}")]
|
||||
[HttpGet("original/{id}")]
|
||||
[EnableRateLimiting("media_original")]
|
||||
public ActionResult GetImage(Guid id) {
|
||||
public async Task<ActionResult> GetImage(Guid id, CancellationToken cancellationToken) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
|
||||
var asset = await assetRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
// Animated assets with a converted video on disk serve the converted file (and its MIME type),
|
||||
// never the original animation. A stale ConvertedPath (file missing) falls back to the original.
|
||||
bool hasConverted = HasConvertedFile(asset);
|
||||
|
||||
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||
return PhysicalFile(asset.OriginalPath, asset.MimeType);
|
||||
if (hasConverted)
|
||||
return ServeFile(asset.ConvertedPath, asset.ConvertedMimeType, asset);
|
||||
return ServeFile(asset.OriginalPath, asset.MimeType, asset);
|
||||
}
|
||||
var data = mediaRepository.GetOriginalData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||
|
||||
var accessLevel = user?.AccessLevel ?? EAccessLevel.User;
|
||||
var converted = hasConverted
|
||||
? await mediaRepository.GetConvertedDataAsync(id, user?.Id, accessLevel, cancellationToken)
|
||||
: null;
|
||||
if (converted != null)
|
||||
return ServeFile(converted.Path, converted.MimeType, asset);
|
||||
|
||||
var data = await mediaRepository.GetOriginalDataAsync(id, user?.Id, accessLevel, cancellationToken);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
return ServeFile(data.Path, data.MimeType, asset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves a physical media file, marking it as an attachment when the request asks to download.
|
||||
/// The <c>download</c> query parameter forces <c>Content-Disposition: attachment</c> so a plain
|
||||
/// link/navigation (not a CORS-gated fetch) saves the file to disk regardless of origin.
|
||||
/// </summary>
|
||||
/// <param name="path">The physical file path to serve.</param>
|
||||
/// <param name="mimeType">The MIME type of the file.</param>
|
||||
/// <param name="asset">The asset the file belongs to, used for the attachment filename.</param>
|
||||
/// <returns>The physical file result.</returns>
|
||||
private ActionResult ServeFile(string path, string mimeType, Models.Asset asset) {
|
||||
if (Request.Query.ContainsKey("download"))
|
||||
Response.Headers.ContentDisposition =
|
||||
new ContentDispositionHeaderValue("attachment") { FileName = asset.OriginalFilename }.ToString();
|
||||
return PhysicalFile(path, mimeType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an asset has a converted video file available on disk.
|
||||
/// Logs a warning when the converted path is set but the file is missing.
|
||||
/// </summary>
|
||||
/// <param name="asset">The asset to inspect.</param>
|
||||
/// <returns><see langword="true"/> when the converted path and MIME type are set and the file exists.</returns>
|
||||
private bool HasConvertedFile(Models.Asset asset) {
|
||||
if (string.IsNullOrEmpty(asset.ConvertedPath) || string.IsNullOrEmpty(asset.ConvertedMimeType))
|
||||
return false;
|
||||
|
||||
if (!System.IO.File.Exists(asset.ConvertedPath)) {
|
||||
logger.LogWarning("Converted file for asset {AssetId} is missing on disk at {Path}; falling back to the original.",
|
||||
asset.Id, asset.ConvertedPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the thumbnail image for an asset.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The physical thumbnail file, or 404 if not found or access denied.</returns>
|
||||
[HttpGet("thumb/{id}")]
|
||||
[EnableRateLimiting("media_thumb")]
|
||||
public ActionResult GetThumb(Guid id) {
|
||||
public async Task<ActionResult> GetThumb(Guid id, CancellationToken cancellationToken) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
|
||||
var asset = await assetRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
// Thumbnails are immutable per asset — allow browser/proxy caching
|
||||
@@ -72,7 +126,7 @@ public class MediaController(
|
||||
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
|
||||
return PhysicalFile(asset.ThumbnailPath, "image/webp");
|
||||
}
|
||||
var data = mediaRepository.GetThumbData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||
var data = await mediaRepository.GetThumbDataAsync(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User, cancellationToken);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
@@ -82,13 +136,14 @@ public class MediaController(
|
||||
/// Gets the preview image for an asset.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The physical preview file, or 404 if not found or access denied.</returns>
|
||||
[HttpGet("preview/{id}")]
|
||||
[EnableRateLimiting("media_preview")]
|
||||
public ActionResult GetPreview(Guid id) {
|
||||
public async Task<ActionResult> GetPreview(Guid id, CancellationToken cancellationToken) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
|
||||
var asset = await assetRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
// Previews are immutable per asset — allow browser/proxy caching
|
||||
@@ -98,7 +153,7 @@ public class MediaController(
|
||||
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
|
||||
return PhysicalFile(asset.PreviewPath, "image/webp");
|
||||
}
|
||||
var data = mediaRepository.GetPreviewData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||
var data = await mediaRepository.GetPreviewDataAsync(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User, cancellationToken);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace Lactose.Controllers;
|
||||
public class PersonController(
|
||||
IPersonRepository personRepository,
|
||||
IAlbumRepository albumRepository,
|
||||
IAssetRepository assetRepository,
|
||||
LactoseAuthService authService
|
||||
) : ControllerBase {
|
||||
/// <summary>
|
||||
@@ -28,15 +29,16 @@ public class PersonController(
|
||||
/// </summary>
|
||||
/// <param name="id">The person ID.</param>
|
||||
/// <param name="albumPaging">Pagination, search, and sort parameters for albums.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The person details with albums, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<PersonDetailedDto> Get([FromRoute] Guid id, [FromQuery] PagedSearchParametersDto albumPaging) {
|
||||
public async Task<ActionResult<PersonDetailedDto>> Get([FromRoute] Guid id, [FromQuery] PagedSearchParametersDto albumPaging, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var person = personRepository.FindVisible(id, uid, accessLevel,
|
||||
var person = await personRepository.FindVisibleAsync(id, uid, accessLevel,
|
||||
albumPaging.Search, albumPaging.SortBy, albumPaging.SortAsc,
|
||||
albumPaging.Page, albumPaging.PageSize);
|
||||
albumPaging.Page, albumPaging.PageSize, cancellationToken);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
return Ok(person.ToPersonDetailedDto(accessLevel, uid));
|
||||
@@ -46,23 +48,25 @@ public class PersonController(
|
||||
/// Searches people with pagination, optional search term, sort options, filtering out cosplayers with no visible content for non-admin users.
|
||||
/// </summary>
|
||||
/// <param name="pagedSearch">Pagination, search, and sort parameters.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A paginated list of person previews.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<List<PersonPreviewDto>> GetAll([FromQuery] PersonSearchParametersDto pagedSearch) {
|
||||
public async Task<ActionResult<List<PersonPreviewDto>>> GetAll([FromQuery] PersonSearchParametersDto pagedSearch, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (pagedSearch.Page < 0 || pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
|
||||
|
||||
var people = personRepository.SearchQuery(
|
||||
var people = await personRepository.SearchQueryAsync(
|
||||
pagedSearch.Search ?? string.Empty,
|
||||
pagedSearch.Page,
|
||||
pagedSearch.PageSize,
|
||||
pagedSearch.SortBy,
|
||||
pagedSearch.SortAsc,
|
||||
uid ?? default,
|
||||
accessLevel
|
||||
).ToList();
|
||||
accessLevel,
|
||||
cancellationToken
|
||||
);
|
||||
return Ok(people);
|
||||
}
|
||||
|
||||
@@ -71,22 +75,23 @@ public class PersonController(
|
||||
/// </summary>
|
||||
/// <param name="id">The person ID.</param>
|
||||
/// <param name="dto">The updated person data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) {
|
||||
public async Task<IActionResult> Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var person = personRepository.Find(id);
|
||||
var person = await personRepository.FindAsync(id, cancellationToken);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
if (accessLevel == EAccessLevel.Maintainer) {
|
||||
if (uid == null || !personRepository.IsMaintainerOf(uid.Value, id))
|
||||
if (uid == null || !await personRepository.IsMaintainerOfAsync(uid.Value, id, cancellationToken))
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
person.Name = dto.Name;
|
||||
person.Name = dto.Name ?? person.Name;
|
||||
person.ProfileAssetId = dto.ProfileAssetId;
|
||||
person.ProfileCropX = dto.ProfileCropX;
|
||||
person.ProfileCropY = dto.ProfileCropY;
|
||||
@@ -96,92 +101,103 @@ public class PersonController(
|
||||
person.Visibility = dto.Visibility.Value;
|
||||
|
||||
if (dto.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
|
||||
personRepository.SetMaintainers(person.Id, dto.MaintainerUserIds);
|
||||
await personRepository.SetMaintainersAsync(person.Id, dto.MaintainerUserIds, cancellationToken);
|
||||
|
||||
person.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
personRepository.Save();
|
||||
await personRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates multiple people in a bulk operation.
|
||||
/// Updates multiple people in a bulk operation, optionally cascading a visibility change to their albums and assets.
|
||||
/// </summary>
|
||||
/// <param name="personBulkDto">The bulk update data.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
/// <returns>200 on success with an optional cascade result.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
public IActionResult BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
|
||||
public async Task<IActionResult> BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
var people = personRepository.FindBulk(personBulkDto.Ids).ToList();
|
||||
|
||||
foreach (var person in people) {
|
||||
person.Name = personBulkDto.Data.Name;
|
||||
person.ProfileAssetId = personBulkDto.Data.ProfileAssetId;
|
||||
person.ProfileCropX = personBulkDto.Data.ProfileCropX;
|
||||
person.ProfileCropY = personBulkDto.Data.ProfileCropY;
|
||||
person.ProfileCropZoom = personBulkDto.Data.ProfileCropZoom;
|
||||
person.Visibility = personBulkDto.Data.Visibility ?? person.Visibility;
|
||||
person.UpdatedAt = DateTime.UtcNow;
|
||||
var result = await personRepository.ExecuteInTransactionAsync(async () => {
|
||||
var people = await personRepository.FindBulkAsync(personBulkDto.Ids, CancellationToken.None);
|
||||
|
||||
if (personBulkDto.Data.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
|
||||
personRepository.SetMaintainers(person.Id, personBulkDto.Data.MaintainerUserIds);
|
||||
}
|
||||
foreach (var person in people) {
|
||||
person.Name = personBulkDto.Data.Name ?? person.Name;
|
||||
person.ProfileAssetId = personBulkDto.Data.ProfileAssetId ?? person.ProfileAssetId;
|
||||
person.ProfileCropX = personBulkDto.Data.ProfileCropX ?? person.ProfileCropX;
|
||||
person.ProfileCropY = personBulkDto.Data.ProfileCropY ?? person.ProfileCropY;
|
||||
person.ProfileCropZoom = personBulkDto.Data.ProfileCropZoom ?? person.ProfileCropZoom;
|
||||
person.Visibility = personBulkDto.Data.Visibility ?? person.Visibility;
|
||||
person.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
personRepository.Save();
|
||||
return Ok();
|
||||
if (personBulkDto.Data.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
|
||||
await personRepository.SetMaintainersAsync(person.Id, personBulkDto.Data.MaintainerUserIds, CancellationToken.None);
|
||||
}
|
||||
|
||||
var result = new CascadeResultDto { PeopleUpdated = people.Count };
|
||||
var cascade = personBulkDto.Cascade;
|
||||
var visibility = personBulkDto.Data.Visibility;
|
||||
|
||||
if (cascade != null && visibility.HasValue) {
|
||||
if (cascade.ToAlbums)
|
||||
result.AlbumsUpdated = await albumRepository.BulkSetVisibilityByPersonIdsAsync(personBulkDto.Ids, visibility.Value, CancellationToken.None);
|
||||
if (cascade.ToAssets)
|
||||
result.AssetsUpdated = await assetRepository.BulkSetVisibilityByPersonIdsAsync(personBulkDto.Ids, visibility.Value, CancellationToken.None);
|
||||
}
|
||||
|
||||
await personRepository.SaveAsync(CancellationToken.None);
|
||||
return result;
|
||||
});
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a person by ID (soft delete).
|
||||
/// </summary>
|
||||
/// <param name="id">The person ID.</param>
|
||||
/// <returns>200 on success, 404 if not found.</returns>
|
||||
/// <summary>
|
||||
/// Deletes a person by ID (hard delete). Cascades to unlink all associated albums.
|
||||
/// </summary>
|
||||
/// <param name="id">The person ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
public IActionResult Delete([FromRoute] Guid id) {
|
||||
var person = personRepository.Find(id);
|
||||
public async Task<IActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
var person = await personRepository.FindAsync(id, cancellationToken);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
// Unlink all albums owned by this person
|
||||
var albums = albumRepository.FindByPerson(id).ToList();
|
||||
var albums = await albumRepository.FindByPersonAsync(id, cancellationToken);
|
||||
foreach (var album in albums) {
|
||||
album.PersonOwnerId = null;
|
||||
}
|
||||
|
||||
personRepository.Remove(person);
|
||||
personRepository.Save();
|
||||
await personRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes multiple people in a bulk operation.
|
||||
/// </summary>
|
||||
/// <param name="ids">The list of person IDs to delete.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
/// <summary>
|
||||
/// Deletes multiple people in a bulk operation (hard delete). Cascades to unlink all associated albums.
|
||||
/// </summary>
|
||||
/// <param name="ids">The list of person IDs to delete.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||
var people = personRepository.FindBulk(ids).ToList();
|
||||
public async Task<IActionResult> BulkDelete([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
|
||||
var people = await personRepository.FindBulkAsync(ids, cancellationToken);
|
||||
|
||||
foreach (var person in people) {
|
||||
// Unlink all albums owned by this person
|
||||
var albums = albumRepository.FindByPerson(person.Id).ToList();
|
||||
var albums = await albumRepository.FindByPersonAsync(person.Id, cancellationToken);
|
||||
foreach (var album in albums) {
|
||||
album.PersonOwnerId = null;
|
||||
}
|
||||
personRepository.Remove(person);
|
||||
}
|
||||
|
||||
personRepository.Save();
|
||||
await personRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -190,18 +206,19 @@ public class PersonController(
|
||||
/// Albums, faces, and maintainers are reassigned to the destination, then source people are hard-deleted.
|
||||
/// </summary>
|
||||
/// <param name="dto">The merge request with destination and source IDs.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
|
||||
[HttpPost("merge")]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
public IActionResult Merge([FromBody] PersonMergeDto dto) {
|
||||
public async Task<IActionResult> Merge([FromBody] PersonMergeDto dto, CancellationToken cancellationToken) {
|
||||
if (dto.SourceIds.Contains(dto.DestinationId))
|
||||
return BadRequest("Destination person ID must not appear in source person IDs.");
|
||||
|
||||
if (personRepository.Find(dto.DestinationId) == null)
|
||||
if (await personRepository.FindAsync(dto.DestinationId, cancellationToken) == null)
|
||||
return NotFound("Destination person not found.");
|
||||
|
||||
personRepository.MergePeople(dto.DestinationId, dto.SourceIds);
|
||||
personRepository.Save();
|
||||
await personRepository.MergePeopleAsync(dto.DestinationId, dto.SourceIds, cancellationToken);
|
||||
await personRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -209,10 +226,11 @@ public class PersonController(
|
||||
/// Creates a new person.
|
||||
/// </summary>
|
||||
/// <param name="dto">The person creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the new person ID.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
public ActionResult<Guid> Create([FromBody] PersonCreateDto dto) {
|
||||
public async Task<ActionResult<Guid>> Create([FromBody] PersonCreateDto dto, CancellationToken cancellationToken) {
|
||||
var person = new Models.Person {
|
||||
Id = Guid.NewGuid(),
|
||||
Name = dto.Name,
|
||||
@@ -220,7 +238,7 @@ public class PersonController(
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
personRepository.Insert(person);
|
||||
personRepository.Save();
|
||||
await personRepository.SaveAsync(cancellationToken);
|
||||
return Ok(person.Id);
|
||||
}
|
||||
}
|
||||
@@ -27,22 +27,24 @@ public class SettingsController(
|
||||
/// Creates a new application setting.
|
||||
/// </summary>
|
||||
/// <param name="settingDto">The setting to create.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Create([FromBody] SettingDto settingDto) {
|
||||
public async Task<ActionResult> Create([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
settingsRepository.Create(
|
||||
await settingsRepository.CreateAsync(
|
||||
new Setting {
|
||||
Name = settingDto.Name,
|
||||
Value = settingDto.Value,
|
||||
Description = settingDto.Description,
|
||||
Type = settingDto.Type,
|
||||
DisplayType = settingDto.DisplayType
|
||||
}
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Ok();
|
||||
@@ -51,28 +53,30 @@ public class SettingsController(
|
||||
/// <summary>
|
||||
/// Gets all application settings.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of all settings.</returns>
|
||||
[HttpGet]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<SettingDto> Get() {
|
||||
public async Task<ActionResult<SettingDto>> Get(CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||
|
||||
return Ok(settingsRepository.Get().Select(s => s.ToSettingDto()).ToList());
|
||||
return Ok((await settingsRepository.GetAllAsync(cancellationToken)).Select(s => s.ToSettingDto()).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing application setting.
|
||||
/// </summary>
|
||||
/// <param name="settingDto">The updated setting data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Update([FromBody] SettingDto settingDto) {
|
||||
public async Task<ActionResult> Update([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||
|
||||
settingsRepository.Update(settingDto.ToSetting());
|
||||
await settingsRepository.UpdateAsync(settingDto.ToSetting(), cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -80,29 +84,31 @@ public class SettingsController(
|
||||
/// Deletes a specific application setting.
|
||||
/// </summary>
|
||||
/// <param name="settingDto">The setting to delete.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Delete([FromBody] SettingDto settingDto) {
|
||||
public async Task<ActionResult> Delete([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||
|
||||
settingsRepository.Delete(settingDto.ToSetting());
|
||||
await settingsRepository.DeleteAsync(settingDto.ToSetting(), cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all application settings.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[Route("delete-all")]
|
||||
public ActionResult DeleteAll() {
|
||||
public async Task<ActionResult> DeleteAll(CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||
|
||||
settingsRepository.Delete();
|
||||
await settingsRepository.DeleteAllAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@ public class TagController(
|
||||
/// <param name="id">The tag ID.</param>
|
||||
/// <param name="children">Whether to include child tags.</param>
|
||||
/// <param name="ancestors">Whether to include ancestor tags.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The tag with its hierarchy, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<TagDto> Get(Guid id, bool children = false, bool ancestors = false) {
|
||||
Tag? tag = tagRepository.Find(id);
|
||||
public async Task<ActionResult<TagDto>> Get(Guid id, bool children = false, bool ancestors = false, CancellationToken cancellationToken = default) {
|
||||
Tag? tag = await tagRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (tag == null) { return NotFound(); }
|
||||
var tagDto = TagsMapper.ToTagDto(tag);
|
||||
@@ -40,7 +41,7 @@ public class TagController(
|
||||
|
||||
if (children) {
|
||||
tagDto.Children = [];
|
||||
tagRepository.GetChildren(tag.Id).ForEach(t => tagDto.Children.Add(TagsMapper.ToTagDto(t)));
|
||||
(await tagRepository.GetChildrenAsync(tag.Id, cancellationToken)).ForEach(t => tagDto.Children.Add(TagsMapper.ToTagDto(t)));
|
||||
}
|
||||
|
||||
//TODO: Protect from overflow
|
||||
@@ -65,14 +66,15 @@ public class TagController(
|
||||
/// Searches tags with pagination and optional search term.
|
||||
/// </summary>
|
||||
/// <param name="pagedSearch">Pagination and search parameters.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of tags matching the search criteria.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<List<TagDto>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) {
|
||||
public async Task<ActionResult<List<TagDto>>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch, CancellationToken cancellationToken) {
|
||||
pagedSearch.Search ??= string.Empty;
|
||||
if(pagedSearch.Page < 0) { return BadRequest("Page must be greater than 0"); }
|
||||
if(pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest($"PageSize must be between 1 and {PagedParametersDto.MaxPageSize}"); }
|
||||
|
||||
List<Tag> tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize);
|
||||
|
||||
List<Tag> tags = await tagRepository.SearchAsync(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize, cancellationToken);
|
||||
List<TagDto> tagsDto = [];
|
||||
|
||||
tags.ForEach(
|
||||
@@ -91,14 +93,15 @@ public class TagController(
|
||||
/// </summary>
|
||||
/// <param name="id">The tag ID.</param>
|
||||
/// <param name="tagDto">The updated tag data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found, 409 if name conflict.</returns>
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpPost("{id}")]
|
||||
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto) {
|
||||
public async Task<IStatusCodeActionResult> Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto, CancellationToken cancellationToken) {
|
||||
|
||||
IStatusCodeActionResult result = UpdateTag(id, tagDto.Name, tagDto.Parent);
|
||||
IStatusCodeActionResult result = await UpdateTag(id, tagDto.Name, tagDto.Parent, cancellationToken);
|
||||
|
||||
if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); }
|
||||
if (result.StatusCode == StatusCodes.Status200OK) { await tagRepository.SaveAsync(cancellationToken); }
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -107,18 +110,19 @@ public class TagController(
|
||||
/// Updates multiple tags in a bulk operation.
|
||||
/// </summary>
|
||||
/// <param name="tagBulkDto">The bulk update data with IDs and shared update values.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, or the first error response.</returns>
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpPost]
|
||||
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto) {
|
||||
public async Task<IStatusCodeActionResult> BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto, CancellationToken cancellationToken) {
|
||||
|
||||
foreach (Guid id in tagBulkDto.Ids) {
|
||||
IStatusCodeActionResult result = UpdateTag(id, tagBulkDto.Data.Name, tagBulkDto.Data.Parent);
|
||||
IStatusCodeActionResult result = await UpdateTag(id, tagBulkDto.Data.Name, tagBulkDto.Data.Parent, cancellationToken);
|
||||
|
||||
if (result.StatusCode != StatusCodes.Status200OK) { return result; }
|
||||
}
|
||||
|
||||
tagRepository.Save();
|
||||
await tagRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -126,13 +130,14 @@ public class TagController(
|
||||
/// Deletes a tag by its ID (soft delete).
|
||||
/// </summary>
|
||||
/// <param name="id">The tag ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found.</returns>
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult Delete([FromRoute] Guid id) {
|
||||
IStatusCodeActionResult result = DeleteTag(id);
|
||||
public async Task<ActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
IStatusCodeActionResult result = await DeleteTag(id, cancellationToken);
|
||||
|
||||
if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); }
|
||||
if (result.StatusCode == StatusCodes.Status200OK) { await tagRepository.SaveAsync(cancellationToken); }
|
||||
|
||||
return (ActionResult)result;
|
||||
}
|
||||
@@ -141,17 +146,18 @@ public class TagController(
|
||||
/// Deletes multiple tags in a bulk operation.
|
||||
/// </summary>
|
||||
/// <param name="ids">The list of tag IDs to delete.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, or the first error response.</returns>
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpDelete]
|
||||
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||
public async Task<IActionResult> BulkDelete([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
|
||||
foreach (Guid id in ids) {
|
||||
IStatusCodeActionResult result = DeleteTag(id);
|
||||
IStatusCodeActionResult result = await DeleteTag(id, cancellationToken);
|
||||
|
||||
if (result.StatusCode != StatusCodes.Status200OK) { return result; }
|
||||
}
|
||||
|
||||
tagRepository.Save();
|
||||
await tagRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -159,29 +165,30 @@ public class TagController(
|
||||
/// Creates a new tag.
|
||||
/// </summary>
|
||||
/// <param name="tagDto">The tag creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 409 if a tag with the same name already exists, 400 if parent not found.</returns>
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpPut]
|
||||
public ActionResult Create([FromBody] TagCreateDto tagDto) {
|
||||
public async Task<ActionResult> Create([FromBody] TagCreateDto tagDto, CancellationToken cancellationToken) {
|
||||
|
||||
if (tagRepository.FindByName(tagDto.Name) != null) { return Conflict($"Tag with name {tagDto.Name} already exist"); }
|
||||
if (await tagRepository.FindByNameAsync(tagDto.Name, cancellationToken) != null) { return Conflict($"Tag with name {tagDto.Name} already exist"); }
|
||||
|
||||
var tag = new Tag() {
|
||||
Name = tagDto.Name
|
||||
};
|
||||
|
||||
if (tagDto.Parent != null && tagRepository.Find(tagDto.Parent.Value) == null) {
|
||||
if (tagDto.Parent != null && await tagRepository.FindAsync(tagDto.Parent.Value, cancellationToken) == null) {
|
||||
return BadRequest($"No Tag with id {tagDto.Parent} found");
|
||||
}
|
||||
|
||||
tag.ParentId = tagDto.Parent;
|
||||
tagRepository.Insert(tag);
|
||||
tagRepository.Save();
|
||||
await tagRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
IStatusCodeActionResult DeleteTag(Guid id) {
|
||||
Tag? tag = tagRepository.Find(id);
|
||||
async Task<IStatusCodeActionResult> DeleteTag(Guid id, CancellationToken cancellationToken) {
|
||||
Tag? tag = await tagRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (tag == null) { return NotFound($"Tag {id} not found"); }
|
||||
tagRepository.Delete(tag);
|
||||
@@ -189,14 +196,14 @@ public class TagController(
|
||||
}
|
||||
|
||||
|
||||
IStatusCodeActionResult UpdateTag(Guid id, string? name, Guid? parentId) {
|
||||
async Task<IStatusCodeActionResult> UpdateTag(Guid id, string? name, Guid? parentId, CancellationToken cancellationToken) {
|
||||
|
||||
Tag? tag = tagRepository.Find(id);
|
||||
Tag? tag = await tagRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (tag == null) { return NotFound($"Tag {id} not found"); }
|
||||
|
||||
if (name != null) {
|
||||
Tag? foundTag = tagRepository.FindByName(name);
|
||||
Tag? foundTag = await tagRepository.FindByNameAsync(name, cancellationToken);
|
||||
|
||||
if (foundTag != null && foundTag.Id != tag.Id) { return Conflict($"Tag with name '{name}' already exists"); }
|
||||
|
||||
@@ -206,7 +213,7 @@ public class TagController(
|
||||
if (parentId == null) { return Ok(); }
|
||||
|
||||
if (parentId.Value.Equals(Guid.Empty)) { tag.Parent = null; } else {
|
||||
Tag? parent = tagRepository.Find(parentId.Value);
|
||||
Tag? parent = await tagRepository.FindAsync(parentId.Value, cancellationToken);
|
||||
|
||||
if (parent == null) { return NotFound($"Parent tag on {id} was not found"); }
|
||||
tag.Parent = parent;
|
||||
|
||||
@@ -29,10 +29,11 @@ namespace Lactose.Controllers {
|
||||
/// Creates a new user (admin only).
|
||||
/// </summary>
|
||||
/// <param name="userCreateDto">The user creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the new user ID, or conflict if the email/username already exists.</returns>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut]
|
||||
public ActionResult<UserInfoDto> Create([FromBody] UserCreateDto userCreateDto) {
|
||||
public async Task<ActionResult<UserInfoDto>> Create([FromBody] UserCreateDto userCreateDto, CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
|
||||
if (authenticatedUser != null) {
|
||||
@@ -50,8 +51,9 @@ namespace Lactose.Controllers {
|
||||
"""
|
||||
);
|
||||
|
||||
if (userRepository.GetAll().Any(x => x.Email == userCreateDto.Email)) return Conflict("Email already exists");
|
||||
if (userRepository.GetAll().Any(x => x.Username == userCreateDto.Username)) return Conflict("Username already exists");
|
||||
List<User> users = await userRepository.GetAllAsync(cancellationToken);
|
||||
if (users.Any(x => x.Email == userCreateDto.Email)) return Conflict("Email already exists");
|
||||
if (users.Any(x => x.Username == userCreateDto.Username)) return Conflict("Username already exists");
|
||||
|
||||
var user = new User {
|
||||
Username = userCreateDto.Username,
|
||||
@@ -63,7 +65,7 @@ namespace Lactose.Controllers {
|
||||
user.Password = passwordHasher.HashPassword(user, userCreateDto.Password);
|
||||
|
||||
userRepository.Insert(user);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok(user.Id);
|
||||
}
|
||||
@@ -73,9 +75,10 @@ namespace Lactose.Controllers {
|
||||
/// Gets a user by their ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The user ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The user info, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<UserInfoDto> Get(Guid id) {
|
||||
public async Task<ActionResult<UserInfoDto>> Get(Guid id, CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
|
||||
if (authenticatedUser == null) {
|
||||
@@ -85,7 +88,7 @@ namespace Lactose.Controllers {
|
||||
|
||||
logger.LogDebug($"Sending user with id {id}");
|
||||
|
||||
User? user = userRepository.FindWithMaintainedPersons(id);
|
||||
User? user = await userRepository.FindWithMaintainedPersonsAsync(id, cancellationToken);
|
||||
|
||||
if (user is null) return NotFound();
|
||||
|
||||
@@ -98,13 +101,15 @@ namespace Lactose.Controllers {
|
||||
/// <returns>A list of all users.</returns>
|
||||
[Authorize(Roles = "Admin,Curator")]
|
||||
[HttpGet]
|
||||
public ActionResult<List<UserInfoDto>> GetAll() {
|
||||
public async Task<ActionResult<List<UserInfoDto>>> GetAll(CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
EAccessLevel viewerAccessLevel = authenticatedUser?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
logger.LogDebug("Sending Users list");
|
||||
|
||||
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
|
||||
List<User> users = await userRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
return Ok(users.Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
|
||||
}
|
||||
|
||||
|
||||
@@ -112,9 +117,10 @@ namespace Lactose.Controllers {
|
||||
/// Updates an existing user's profile.
|
||||
/// </summary>
|
||||
/// <param name="dto">The user update data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The updated user info, or 403/404 if unauthorized or not found.</returns>
|
||||
[HttpPost("update")]
|
||||
public ActionResult<UserInfoDto> Update([FromBody] UserUpdateDto dto) {
|
||||
public async Task<ActionResult<UserInfoDto>> Update([FromBody] UserUpdateDto dto, CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
|
||||
if (authenticatedUser == null) {
|
||||
@@ -136,7 +142,7 @@ namespace Lactose.Controllers {
|
||||
|
||||
if (authenticatedUser.AccessLevel != EAccessLevel.Admin && authenticatedUser.Id != dto.Id) return Forbid();
|
||||
|
||||
User? user = userRepository.Find(dto.Id);
|
||||
User? user = await userRepository.FindAsync(dto.Id, cancellationToken);
|
||||
if (user == null) return NotFound();
|
||||
|
||||
if (dto.Username != null) { user.Username = dto.Username; }
|
||||
@@ -172,11 +178,11 @@ namespace Lactose.Controllers {
|
||||
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
|
||||
|
||||
if (dto.MaintainedPersonIds != null) {
|
||||
userRepository.SetMaintainedPersons(user.Id, dto.MaintainedPersonIds);
|
||||
await userRepository.SetMaintainedPersonsAsync(user.Id, dto.MaintainedPersonIds, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
|
||||
}
|
||||
|
||||
@@ -184,10 +190,11 @@ namespace Lactose.Controllers {
|
||||
/// Soft-deletes a user by their ID (admin only).
|
||||
/// </summary>
|
||||
/// <param name="id">The user ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 with the deleted user ID, or 404 if not found.</returns>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult<Guid> Delete([FromRoute] Guid id) {
|
||||
public async Task<ActionResult<Guid>> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
|
||||
if (authenticatedUser == null) {
|
||||
@@ -197,14 +204,14 @@ namespace Lactose.Controllers {
|
||||
|
||||
logger.LogDebug($"Deleting user: {id}");
|
||||
|
||||
User? user = userRepository.Find(id);
|
||||
User? user = await userRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (user == null) return NotFound();
|
||||
|
||||
user.DeletedAt = DateTime.UtcNow;
|
||||
|
||||
//TODO: eventually remove/hide all of the user content if required
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return Ok(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,27 @@
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Converted Path",
|
||||
"Value": "",
|
||||
"Description": "Path where converted videos for animated assets (GIF -> mp4) are stored.",
|
||||
"Type": 0,
|
||||
"DisplayType": 0
|
||||
},
|
||||
{
|
||||
"Name": "Converted Format",
|
||||
"Value": "mp4",
|
||||
"Description": "Container format for converted videos (e.g. mp4).",
|
||||
"Type": 0,
|
||||
"DisplayType": 0
|
||||
},
|
||||
{
|
||||
"Name": "FFmpeg Path",
|
||||
"Value": "ffmpeg",
|
||||
"Description": "Path to the ffmpeg executable. Defaults to 'ffmpeg' on PATH.",
|
||||
"Type": 0,
|
||||
"DisplayType": 0
|
||||
},
|
||||
{
|
||||
"Name": "Job Batch Size",
|
||||
"Value": "200",
|
||||
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
RUN mkdir -p /app/thumbnails /app/previews && chown -R $APP_UID /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir -p /app/thumbnails /app/previews /app/converted && chown -R $APP_UID /app
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Converts animated assets (GIF) to video (mp4 via ffmpeg) and extracts a first-frame WebP thumbnail.
|
||||
/// Original files are never modified; outputs are stored under the Converted Path setting.
|
||||
/// Master creates one sub-job per batch of assets.
|
||||
/// </summary>
|
||||
public sealed class ConvertAnimatedJob : Job {
|
||||
ConvertAnimatedJob? ParentJob;
|
||||
Asset[]? Batch;
|
||||
int ThumbnailSize;
|
||||
string ThumbnailPath = "";
|
||||
string ConvertedPath = "";
|
||||
string FfmpegPath = "ffmpeg";
|
||||
string ExpectedFormat = "mp4";
|
||||
string ExpectedThumbnailFormat = "webp";
|
||||
int processedAssets;
|
||||
int failedAssets;
|
||||
int childJobFailures;
|
||||
int childJobsWithErrors;
|
||||
int totalChildJobs;
|
||||
int totalAssets = 1;
|
||||
int batchSize;
|
||||
List<Job> childJobs = [];
|
||||
ILogger<ConvertAnimatedJob> Logger;
|
||||
ISettingsRepository SettingsRepository;
|
||||
IAssetRepository AssetRepository;
|
||||
JobManager? JobManager;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a master animated-asset conversion job.
|
||||
/// </summary>
|
||||
public ConvertAnimatedJob(
|
||||
ILogger<ConvertAnimatedJob> logger,
|
||||
ISettingsRepository settingsRepository,
|
||||
IAssetRepository assetRepository,
|
||||
JobManager jobManager
|
||||
) {
|
||||
Logger = logger;
|
||||
SettingsRepository = settingsRepository;
|
||||
AssetRepository = assetRepository;
|
||||
JobManager = jobManager;
|
||||
Name = "Animated Asset Conversion Job";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a sub-job to convert a batch of animated assets.
|
||||
/// </summary>
|
||||
public ConvertAnimatedJob(
|
||||
ConvertAnimatedJob parentJob,
|
||||
IEnumerable<Asset> batch,
|
||||
int thumbnailSize,
|
||||
string thumbnailPath,
|
||||
string convertedPath,
|
||||
string ffmpegPath,
|
||||
string expectedFormat,
|
||||
int batchSize,
|
||||
ILogger<ConvertAnimatedJob> logger,
|
||||
ISettingsRepository settingsRepository,
|
||||
IAssetRepository assetRepository
|
||||
) {
|
||||
ParentJob = parentJob;
|
||||
ParentJobId = parentJob.Id;
|
||||
Batch = batch.ToArray();
|
||||
ThumbnailSize = thumbnailSize;
|
||||
ThumbnailPath = thumbnailPath;
|
||||
ConvertedPath = convertedPath;
|
||||
FfmpegPath = ffmpegPath;
|
||||
ExpectedFormat = expectedFormat;
|
||||
this.batchSize = batchSize;
|
||||
Logger = logger;
|
||||
SettingsRepository = settingsRepository;
|
||||
AssetRepository = assetRepository;
|
||||
Name = $"Animated Conversion Batch ({Batch.Length} assets)";
|
||||
totalAssets = Batch.Length;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task TaskJob(CancellationToken token) {
|
||||
if (ParentJob == null) {
|
||||
await MasterJob(token);
|
||||
} else {
|
||||
await SlaveJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
async Task SlaveJob(CancellationToken token) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Batch == null || Batch.Length == 0) {
|
||||
Logger.LogWarning("Sub-job started with an empty batch. Completing.");
|
||||
JobStatus.Complete("Empty batch — nothing to process.");
|
||||
return;
|
||||
}
|
||||
|
||||
JobStatus.Start();
|
||||
|
||||
if (string.IsNullOrEmpty(ThumbnailPath) || string.IsNullOrEmpty(ConvertedPath)) {
|
||||
Logger.LogError("Thumbnail or converted output path is not set. Cannot convert assets.");
|
||||
JobStatus.Fail("Thumbnail or converted output path is not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < Batch.Length; i++) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
}
|
||||
|
||||
var asset = Batch[i];
|
||||
JobStatus.UpdateProgress((float)i / Batch.Length, $"Processing {asset.OriginalPath}");
|
||||
|
||||
try {
|
||||
if (string.IsNullOrEmpty(asset.OriginalPath) || !File.Exists(asset.OriginalPath)) {
|
||||
var errorMsg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
|
||||
Logger.LogWarning(errorMsg);
|
||||
JobHelpers.MarkBroken(asset, errorMsg);
|
||||
failedAssets++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var thumbPath = PathUtils.PathFromGuid(asset.Id, ThumbnailPath, ExpectedThumbnailFormat);
|
||||
var videoPath = PathUtils.PathFromGuid(asset.Id, ConvertedPath, ExpectedFormat);
|
||||
|
||||
var thumbOutDir = Path.GetDirectoryName(thumbPath);
|
||||
if (thumbOutDir != null && !Directory.Exists(thumbOutDir))
|
||||
Directory.CreateDirectory(thumbOutDir);
|
||||
var videoOutDir = Path.GetDirectoryName(videoPath);
|
||||
if (videoOutDir != null && !Directory.Exists(videoOutDir))
|
||||
Directory.CreateDirectory(videoOutDir);
|
||||
|
||||
// First frame as a static WebP thumbnail (avoids decoding the whole animation).
|
||||
if (asset.ThumbnailPath != thumbPath || !File.Exists(thumbPath)) {
|
||||
var thumbArgs = $"-y -i \"{asset.OriginalPath}\" -vf \"scale={ThumbnailSize}:-1\" -frames:v 1 \"{thumbPath}\"";
|
||||
await FfmpegRunner.RunAsync(FfmpegPath, thumbArgs, Logger, token);
|
||||
}
|
||||
|
||||
// Full animation → video (mp4 / H.264, no audio, faststart for streaming).
|
||||
if (asset.ConvertedPath != videoPath || !File.Exists(videoPath)) {
|
||||
var videoArgs = $"-y -i \"{asset.OriginalPath}\" -c:v libx264 -crf 23 -pix_fmt yuv420p -movflags +faststart -an \"{videoPath}\"";
|
||||
await FfmpegRunner.RunAsync(FfmpegPath, videoArgs, Logger, token);
|
||||
}
|
||||
|
||||
asset.ThumbnailPath = thumbPath;
|
||||
asset.ThumbnailSize = ThumbnailSize;
|
||||
asset.ThumbnailFormat = ExpectedThumbnailFormat;
|
||||
asset.ConvertedPath = videoPath;
|
||||
asset.ConvertedMimeType = MimeTypeFor(ExpectedFormat);
|
||||
asset.ConvertedFormat = ExpectedFormat;
|
||||
processedAssets++;
|
||||
} catch (OperationCanceledException) {
|
||||
throw;
|
||||
} catch (Exception ex) {
|
||||
Logger.LogError(ex, "Failed to convert animated asset ID {Id} at {Path}.", asset.Id, asset.OriginalPath);
|
||||
JobHelpers.MarkBroken(asset, $"Animated conversion failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
}
|
||||
}
|
||||
|
||||
AssetRepository.UpdateBulk(Batch);
|
||||
await AssetRepository.SaveAsync(token);
|
||||
|
||||
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
||||
Logger.LogInformation(resultMsg);
|
||||
|
||||
if (failedAssets == Batch.Length)
|
||||
JobStatus.Fail($"All {failedAssets:N0} assets in batch failed.");
|
||||
else if (failedAssets > 0)
|
||||
JobStatus.CompleteWithErrors(resultMsg);
|
||||
else
|
||||
JobStatus.Complete(resultMsg);
|
||||
}
|
||||
|
||||
async Task MasterJob(CancellationToken token) {
|
||||
if (JobManager == null) {
|
||||
Logger.LogError("JobManager is not available. Cannot create sub-jobs.");
|
||||
JobStatus.Fail("JobManager is not available. Cannot create sub-jobs.");
|
||||
return;
|
||||
}
|
||||
|
||||
JobStatus.Start();
|
||||
Logger.LogInformation("Starting master animated conversion job.");
|
||||
|
||||
var thumbPathSetting = await SettingsRepository.GetAsync(Settings.ThumbnailPath.AsString(), token);
|
||||
var convertedPathSetting = await SettingsRepository.GetAsync(Settings.ConvertedPath.AsString(), token);
|
||||
var convertedFormatSetting = await SettingsRepository.GetAsync(Settings.ConvertedFormat.AsString(), token);
|
||||
var ffmpegPathSetting = await SettingsRepository.GetAsync(Settings.FfmpegPath.AsString(), token);
|
||||
var thumbSizeSetting = await SettingsRepository.GetAsync(Settings.ThumbnailSize.AsString(), token);
|
||||
var batchSizeSetting = await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
|
||||
|
||||
if (thumbPathSetting == null || convertedPathSetting == null
|
||||
|| string.IsNullOrWhiteSpace(thumbPathSetting.Value) || string.IsNullOrWhiteSpace(convertedPathSetting.Value)) {
|
||||
Logger.LogError("Thumbnail path or converted path is not set. Cannot proceed.");
|
||||
JobStatus.Fail("Thumbnail path or converted path is not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(thumbSizeSetting?.Value, out var thumbnailSize)) {
|
||||
thumbnailSize = 512;
|
||||
Logger.LogWarning("Thumbnail Size setting not found or invalid. Defaulting to {Size}.", thumbnailSize);
|
||||
}
|
||||
|
||||
var convertedFormat = string.IsNullOrWhiteSpace(convertedFormatSetting?.Value) ? "mp4" : convertedFormatSetting.Value;
|
||||
var ffmpegPath = string.IsNullOrWhiteSpace(ffmpegPathSetting?.Value) ? "ffmpeg" : ffmpegPathSetting.Value;
|
||||
|
||||
batchSize = 200;
|
||||
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
|
||||
batchSize = Math.Max(1, parsed);
|
||||
|
||||
totalAssets = await AssetRepository.CountAssetsNeedingConversionAsync(token);
|
||||
Logger.LogInformation("Found {Count} animated assets needing conversion.", totalAssets);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
JobStatus.Complete("No animated assets need conversion.");
|
||||
return;
|
||||
}
|
||||
|
||||
var childTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
int pending = 0;
|
||||
|
||||
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
||||
var batch = await AssetRepository.GetAssetsNeedingConversionAsync(batchSize, offset, token);
|
||||
var batchList = batch.ToList();
|
||||
|
||||
if (batchList.Count == 0) continue;
|
||||
|
||||
var job = JobManager.CreateJob<ConvertAnimatedJob>(
|
||||
this, batchList, thumbnailSize, thumbPathSetting.Value!, convertedPathSetting.Value!, ffmpegPath, convertedFormat, batchSize
|
||||
);
|
||||
job.LinkParentToken(token);
|
||||
lock (childJobs) { childJobs.Add(job); }
|
||||
|
||||
job.Done += (o, _) => {
|
||||
var sub = (ConvertAnimatedJob)o!;
|
||||
lock (childJobs) { childJobs.Remove(sub); }
|
||||
Interlocked.Add(ref processedAssets, sub.processedAssets);
|
||||
Interlocked.Add(ref failedAssets, sub.failedAssets);
|
||||
switch (sub.JobStatus.Status) {
|
||||
case EJobStatus.Failed:
|
||||
case EJobStatus.Canceled:
|
||||
Interlocked.Increment(ref childJobFailures);
|
||||
break;
|
||||
case EJobStatus.CompletedWithErrors:
|
||||
Interlocked.Increment(ref childJobsWithErrors);
|
||||
break;
|
||||
}
|
||||
if (Interlocked.Decrement(ref pending) == 0)
|
||||
childTcs.TrySetResult();
|
||||
};
|
||||
|
||||
Interlocked.Increment(ref pending);
|
||||
Interlocked.Increment(ref totalChildJobs);
|
||||
JobManager.EnqueueJob(job);
|
||||
|
||||
JobStatus.UpdateProgress(
|
||||
(float)offset / totalAssets,
|
||||
$"Created batch at {offset:N0}/{totalAssets:N0}."
|
||||
);
|
||||
}
|
||||
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
}
|
||||
|
||||
var remain = Interlocked.CompareExchange(ref pending, 0, 0);
|
||||
if (remain == 0) {
|
||||
childTcs.TrySetResult();
|
||||
} else {
|
||||
using var ctr = token.Register(() => childTcs.TrySetCanceled(token));
|
||||
try {
|
||||
await childTcs.Task;
|
||||
} catch (TaskCanceledException) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (childJobFailures > 0)
|
||||
JobStatus.Fail($"{childJobFailures:N0} of {totalChildJobs:N0} conversion batch(es) failed.");
|
||||
else if (childJobsWithErrors > 0 || failedAssets > 0)
|
||||
JobStatus.CompleteWithErrors($"{failedAssets:N0} of {totalAssets:N0} animated conversions failed.");
|
||||
else
|
||||
JobStatus.Complete("All animated assets converted successfully.");
|
||||
}
|
||||
|
||||
static string MimeTypeFor(string format) => format switch {
|
||||
"webm" => "video/webm",
|
||||
"mp4" => "video/mp4",
|
||||
_ => $"video/{format}"
|
||||
};
|
||||
}
|
||||
+246
-288
@@ -2,399 +2,335 @@ using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Scans active folders with regex patterns in a streaming fashion (suitable for millions of assets),
|
||||
/// creates missing Person and Album entities, and assigns matching assets to their albums.
|
||||
/// Discovers persons and albums from asset paths using folder regex patterns.
|
||||
/// Scans active folders in a streaming, keyset-paginated fashion (suitable for millions of assets),
|
||||
/// creates any missing Person and Album entities, assigns matching assets to their albums, and
|
||||
/// backfills cover images. Runs incrementally (only new assets since the last scan) unless a deep scan
|
||||
/// or a changed regex pattern forces a full rescan.
|
||||
/// </summary>
|
||||
public class CreateAlbumsJob(ILogger<CreateAlbumsJob> logger, IServiceProvider serviceProvider) : Job {
|
||||
const int ScanBatchSize = 5000;
|
||||
const int SaveBatchSize = 1000;
|
||||
public class CreateAlbumsJob(
|
||||
bool deep,
|
||||
IReadOnlyList<Guid>? folderIds,
|
||||
ILogger<CreateAlbumsJob> logger,
|
||||
IServiceProvider serviceProvider) : Job {
|
||||
const int ScanBatchSize = 5000;
|
||||
const int StagingFlushSize = 2000;
|
||||
const int AssignmentBatchSize = 2000;
|
||||
const int LookupChunkSize = 1000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Create Albums Job";
|
||||
public override string Name => "Create Persons & Albums Job";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task TaskJob(CancellationToken token) {
|
||||
try {
|
||||
logger.LogInformation("Starting create albums job...");
|
||||
logger.LogInformation("Starting create persons & albums job (deep={Deep}, folders={FolderCount})...",
|
||||
deep, folderIds?.Count ?? 0);
|
||||
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
|
||||
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
|
||||
var personRepo = scope.ServiceProvider.GetRequiredService<IPersonRepository>();
|
||||
var albumRepo = scope.ServiceProvider.GetRequiredService<IAlbumRepository>();
|
||||
var albumRepo = scope.ServiceProvider.GetRequiredService<IAlbumRepository>();
|
||||
|
||||
var folders = folderRepo.GetAll()
|
||||
var activeFolders = (await folderRepo.GetAllAsync(token))
|
||||
.Where(f => f.Active && !string.IsNullOrWhiteSpace(f.RegexPattern))
|
||||
.ToList();
|
||||
|
||||
var folders = folderIds is { Count: > 0 }
|
||||
? activeFolders.Where(f => folderIds.Contains(f.Id)).ToList()
|
||||
: activeFolders;
|
||||
|
||||
if (folders.Count == 0) {
|
||||
logger.LogInformation("No active folders with a regex pattern found.");
|
||||
JobStatus.Complete("No active folders with a regex pattern found.");
|
||||
logger.LogInformation("No matching active folders with a regex pattern found.");
|
||||
JobStatus.Complete("No matching active folders with a regex pattern found.");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Scanning {FolderCount} active folders for person/album patterns...", folders.Count);
|
||||
// Fresh staging table for this run.
|
||||
await db.Database.ExecuteSqlRawAsync("TRUNCATE TABLE \"AssetAlbumStaging\"", token);
|
||||
|
||||
logger.LogInformation("Scanning {FolderCount} folder(s) for person/album patterns...", folders.Count);
|
||||
|
||||
// ── Phase 1: Incremental scan → stage matched (asset, person, album) rows ──
|
||||
var personAlbumCounts = new ConcurrentDictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
var albumKeys = new ConcurrentDictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
int uniquePersons = 0, uniqueAlbums = 0, totalAssetsScanned = 0, totalAssetsMatched = 0;
|
||||
var stagedBuffer = new List<AssetAlbumStaging>();
|
||||
var folderCursorUpdates = new List<(Guid FolderId, DateTime ScanAt, string Regex)>();
|
||||
|
||||
// ── Phase 1: Streaming scan → collect unique (person, album) names only ──
|
||||
var personAlbumNames = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
float folderIndex = 0;
|
||||
int uniquePersons = 0;
|
||||
int uniqueAlbums = 0;
|
||||
int totalAssetsScanned = 0;
|
||||
int totalAssetsMatched = 0;
|
||||
|
||||
foreach (var folder in folders) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
bool folderDeep = deep || folder.LastDiscoveryScannedRegex != folder.RegexPattern;
|
||||
DateTime? cursor = folderDeep ? null : folder.LastDiscoveryScanAt;
|
||||
|
||||
var regex = new Regex(folder.RegexPattern!, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
var groupNames = regex.GetGroupNames().Select(g => g.ToLowerInvariant()).ToHashSet();
|
||||
|
||||
if (!groupNames.Contains("person") || !groupNames.Contains("group")) {
|
||||
logger.LogWarning("Folder {FolderId} ({BasePath}) regex lacks 'person' and/or 'group' named groups. Skipping.", folder.Id, folder.BasePath);
|
||||
if (!groupNames.Contains("person")) {
|
||||
logger.LogWarning("Folder {FolderId} ({BasePath}) regex lacks a 'person' named group. Skipping.", folder.Id, folder.BasePath);
|
||||
continue;
|
||||
}
|
||||
bool hasGroup = groupNames.Contains("group");
|
||||
|
||||
var totalInFolder = await db.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.CountAsync(token);
|
||||
DateTime scanAt = DateTime.UtcNow;
|
||||
int folderScanned = 0, folderMatched = 0;
|
||||
Guid lastId = Guid.Empty;
|
||||
using var threadLocalRegex = new ThreadLocal<Regex>(() =>
|
||||
new Regex(folder.RegexPattern!, RegexOptions.IgnoreCase | RegexOptions.Compiled));
|
||||
|
||||
int folderScanned = 0;
|
||||
int folderMatched = 0;
|
||||
|
||||
for (int offset = 0; offset < totalInFolder; offset += ScanBatchSize) {
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var paths = await db.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
|
||||
var batch = await db.Assets.AsNoTracking()
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null && a.Id > lastId
|
||||
&& (cursor == null || a.IngestedAt >= cursor.Value))
|
||||
.OrderBy(a => a.Id)
|
||||
.Skip(offset)
|
||||
.Take(ScanBatchSize)
|
||||
.Select(a => a.OriginalPath)
|
||||
.Select(a => new { a.Id, a.OriginalPath })
|
||||
.ToListAsync(token);
|
||||
|
||||
int batchMatched = 0;
|
||||
foreach (var path in paths) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var match = regex.Match(path);
|
||||
if (!match.Success) continue;
|
||||
if (batch.Count == 0) break;
|
||||
lastId = batch[^1].Id;
|
||||
|
||||
var matched = new List<AssetAlbumStaging>(batch.Count);
|
||||
Parallel.ForEach(batch, new ParallelOptions {
|
||||
CancellationToken = token,
|
||||
MaxDegreeOfParallelism = Environment.ProcessorCount
|
||||
}, item => {
|
||||
var match = threadLocalRegex.Value!.Match(item.OriginalPath);
|
||||
if (!match.Success) return;
|
||||
|
||||
var personGroup = match.Groups["person"];
|
||||
var groupGroup = match.Groups["group"];
|
||||
if (!personGroup.Success || !groupGroup.Success) continue;
|
||||
if (!personGroup.Success) return;
|
||||
|
||||
var personName = ToTitleCase(personGroup.Value.Replace('_', ' ').Trim());
|
||||
var albumName = ToTitleCase(groupGroup.Value.Replace('_', ' ').Trim());
|
||||
if (string.IsNullOrWhiteSpace(personName)) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(personName) || string.IsNullOrWhiteSpace(albumName))
|
||||
continue;
|
||||
|
||||
if (!personAlbumNames.TryGetValue(personName, out var albums)) {
|
||||
albums = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
personAlbumNames[personName] = albums;
|
||||
uniquePersons++;
|
||||
string? albumName = null;
|
||||
if (hasGroup) {
|
||||
var groupGroup = match.Groups["group"];
|
||||
if (groupGroup.Success) {
|
||||
var candidate = ToTitleCase(groupGroup.Value.Replace('_', ' ').Trim());
|
||||
if (!string.IsNullOrWhiteSpace(candidate)) albumName = candidate;
|
||||
}
|
||||
}
|
||||
if (albums.Add(albumName))
|
||||
uniqueAlbums++;
|
||||
|
||||
batchMatched++;
|
||||
lock (matched) matched.Add(new AssetAlbumStaging {
|
||||
Id = Guid.NewGuid(), AssetId = item.Id, PersonName = personName, AlbumName = albumName
|
||||
});
|
||||
});
|
||||
|
||||
stagedBuffer.AddRange(matched);
|
||||
folderScanned += batch.Count;
|
||||
folderMatched += matched.Count;
|
||||
|
||||
if (stagedBuffer.Count >= StagingFlushSize) {
|
||||
await FlushStaging(db, stagedBuffer, token);
|
||||
stagedBuffer.Clear();
|
||||
}
|
||||
|
||||
folderScanned += paths.Count;
|
||||
folderMatched += batchMatched;
|
||||
totalAssetsScanned += paths.Count;
|
||||
totalAssetsMatched += batchMatched;
|
||||
// Track unique counts for progress display (cheap approximate).
|
||||
foreach (var m in matched) {
|
||||
if (!personAlbumCounts.ContainsKey(m.PersonName)) uniquePersons++;
|
||||
personAlbumCounts.AddOrUpdate(m.PersonName, 1, (_, v) => v + 1);
|
||||
if (m.AlbumName != null) {
|
||||
var key = $"{m.PersonName}|{m.AlbumName}";
|
||||
if (albumKeys.TryAdd(key, 1)) uniqueAlbums++;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep progress bar at per-folder level, but update the message per batch
|
||||
// so the expanded details panel shows live counts
|
||||
float progress = folderIndex / folders.Count * 0.15f;
|
||||
float progress = folderIndex / folders.Count * 0.25f;
|
||||
JobStatus.UpdateProgress(progress,
|
||||
$"Scanning {folder.BasePath}: {folderScanned:N0}/{totalInFolder:N0} — {uniquePersons} persons, {uniqueAlbums} albums found so far");
|
||||
$"Scanning {folder.BasePath}: {folderScanned:N0} scanned — {uniquePersons} persons, {uniqueAlbums} albums");
|
||||
}
|
||||
|
||||
if (stagedBuffer.Count > 0) {
|
||||
await FlushStaging(db, stagedBuffer, token);
|
||||
stagedBuffer.Clear();
|
||||
}
|
||||
|
||||
totalAssetsScanned += folderScanned;
|
||||
totalAssetsMatched += folderMatched;
|
||||
folderCursorUpdates.Add((folder.Id, scanAt, folder.RegexPattern!));
|
||||
|
||||
folderIndex++;
|
||||
float folderProgress = folderIndex / folders.Count * 0.15f;
|
||||
JobStatus.UpdateProgress(folderProgress,
|
||||
$"Scanned folder {folder.BasePath}: {folderScanned:N0} assets, {folderMatched} matched — {uniquePersons} persons, {uniqueAlbums} albums");
|
||||
JobStatus.UpdateProgress(folderIndex / folders.Count * 0.25f,
|
||||
$"Scanned {folder.BasePath}: {folderMatched:N0} matched — {uniquePersons} persons, {uniqueAlbums} albums");
|
||||
|
||||
logger.LogInformation("Scanned folder {BasePath}: {Scanned:N0} assets, {Matched} matched. Cumulative: {Persons} persons, {Albums} albums",
|
||||
folder.BasePath, folderScanned, folderMatched, uniquePersons, uniqueAlbums);
|
||||
}
|
||||
|
||||
if (personAlbumNames.Count == 0) {
|
||||
if (totalAssetsMatched == 0)
|
||||
logger.LogInformation("No person/album pairs extracted from asset paths.");
|
||||
JobStatus.Complete("No person/album pairs extracted from asset paths.");
|
||||
return;
|
||||
|
||||
JobStatus.UpdateProgress(0.25f, $"Discovered {uniquePersons} persons, {uniqueAlbums} albums from {totalAssetsMatched:N0} matched assets.");
|
||||
|
||||
// ── Phase 2: Create missing persons ──
|
||||
var distinctPersons = await db.AssetAlbumStaging.Select(s => s.PersonName).Distinct().ToListAsync(token);
|
||||
var existingPersons = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var chunk in distinctPersons.Chunk(LookupChunkSize)) {
|
||||
foreach (var p in await personRepo.FindByNamesAsync(chunk, token))
|
||||
existingPersons.TryAdd(p.Name, p.Id);
|
||||
}
|
||||
|
||||
int totalGroups = personAlbumNames.Sum(kv => kv.Value.Count);
|
||||
logger.LogInformation("Discovery complete: {PersonCount} persons, {GroupCount} album groups from {Matched:N0} matched assets.",
|
||||
personAlbumNames.Count, totalGroups, totalAssetsMatched);
|
||||
JobStatus.UpdateProgress(0.15f, $"Discovered {personAlbumNames.Count} persons, {totalGroups} albums from {totalAssetsMatched:N0} matched assets.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// ── Phase 2: Create missing persons and albums ──
|
||||
|
||||
var personNames = personAlbumNames.Keys;
|
||||
var existingPersons = personRepo.FindByNames(personNames)
|
||||
.ToDictionary(p => p.Name, p => p.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var missingPersonCount = personNames.Count(n => !existingPersons.ContainsKey(n));
|
||||
if (missingPersonCount > 0)
|
||||
logger.LogInformation("{Existing} existing persons found, {Missing} missing to create.", existingPersons.Count, missingPersonCount);
|
||||
else
|
||||
logger.LogInformation("All {Count} persons already exist.", existingPersons.Count);
|
||||
|
||||
var personCreated = 0;
|
||||
foreach (var name in personNames) {
|
||||
int personCreated = 0;
|
||||
foreach (var name in distinctPersons) {
|
||||
if (existingPersons.ContainsKey(name)) continue;
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
var person = new Person {
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
var person = new Person { Id = Guid.NewGuid(), Name = name, CreatedAt = DateTime.UtcNow };
|
||||
personRepo.Insert(person);
|
||||
existingPersons[name] = person.Id;
|
||||
personCreated++;
|
||||
|
||||
if (personCreated % 10 == 0) {
|
||||
JobStatus.UpdateProgress(0.15f + (float)personCreated / missingPersonCount * 0.10f,
|
||||
$"Creating persons: {personCreated}/{missingPersonCount} — {name}");
|
||||
}
|
||||
}
|
||||
|
||||
if (personCreated > 0) {
|
||||
personRepo.Save();
|
||||
await personRepo.SaveAsync(token);
|
||||
logger.LogInformation("Created {Count} missing persons.", personCreated);
|
||||
}
|
||||
|
||||
JobStatus.UpdateProgress(0.25f, $"Persons resolved (created {personCreated} new). Checking existing albums...");
|
||||
JobStatus.UpdateProgress(0.35f, $"Persons resolved (created {personCreated} new). Checking existing albums...");
|
||||
|
||||
var existingAlbumKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var allAlbumTitles = personAlbumNames.Values.SelectMany(v => v).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
if (allAlbumTitles.Count > 0) {
|
||||
var existing = await db.Albums
|
||||
.Where(a => allAlbumTitles.Contains(a.Title))
|
||||
.Select(a => new { a.Title, a.PersonOwnerId })
|
||||
.ToListAsync(token);
|
||||
foreach (var a in existing)
|
||||
existingAlbumKeys.Add($"{a.Title}|{a.PersonOwnerId}");
|
||||
}
|
||||
// ── Phase 3: Create missing albums ──
|
||||
var pairs = await db.AssetAlbumStaging
|
||||
.Where(s => s.AlbumName != null)
|
||||
.Select(s => new { s.PersonName, AlbumName = s.AlbumName! })
|
||||
.Distinct()
|
||||
.ToListAsync(token);
|
||||
|
||||
logger.LogInformation("Found {Count} existing album-person pairings in DB.", existingAlbumKeys.Count);
|
||||
// albumName|personId → albumId (existing + newly created)
|
||||
var albumIdByKey = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var albumIdMap = new Dictionary<(string Person, string Album), Guid>();
|
||||
int albumCreated = 0;
|
||||
int totalAlbumSlots = personAlbumNames.Sum(kv => kv.Value.Count);
|
||||
int newAlbumSlots = totalAlbumSlots - existingAlbumKeys.Count;
|
||||
int albumsChecked = 0;
|
||||
foreach (var group in pairs.GroupBy(p => p.PersonName, StringComparer.OrdinalIgnoreCase)) {
|
||||
if (!existingPersons.TryGetValue(group.Key, out var personId)) continue;
|
||||
var titles = group.Select(p => p.AlbumName).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
if (newAlbumSlots > 0)
|
||||
logger.LogInformation("{New} new albums to create out of {Total} total.", newAlbumSlots, totalAlbumSlots);
|
||||
|
||||
foreach (var (personName, albumNames) in personAlbumNames) {
|
||||
if (!existingPersons.TryGetValue(personName, out var personId)) continue;
|
||||
|
||||
foreach (var albumName in albumNames) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
albumsChecked++;
|
||||
var existsKey = $"{albumName}|{personId}";
|
||||
if (existingAlbumKeys.Contains(existsKey)) continue;
|
||||
|
||||
var albumId = Guid.NewGuid();
|
||||
albumRepo.Insert(new Album {
|
||||
Id = albumId,
|
||||
Title = albumName,
|
||||
PersonOwnerId = personId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
albumIdMap[(personName, albumName)] = albumId;
|
||||
existingAlbumKeys.Add(existsKey);
|
||||
albumCreated++;
|
||||
|
||||
if (albumCreated % 10 == 0) {
|
||||
JobStatus.UpdateProgress(0.25f + (float)albumsChecked / totalAlbumSlots * 0.15f,
|
||||
$"Creating albums: {albumCreated}/{newAlbumSlots} new — {albumName}");
|
||||
}
|
||||
foreach (var titleChunk in titles.Chunk(LookupChunkSize)) {
|
||||
var existing = await db.Albums
|
||||
.Where(a => a.PersonOwnerId == personId && titleChunk.Contains(a.Title))
|
||||
.Select(a => new { a.Title, a.Id })
|
||||
.ToListAsync(token);
|
||||
foreach (var a in existing)
|
||||
albumIdByKey.TryAdd($"{a.Title}|{personId}", a.Id);
|
||||
}
|
||||
}
|
||||
|
||||
int albumCreated = 0;
|
||||
foreach (var pair in pairs) {
|
||||
if (!existingPersons.TryGetValue(pair.PersonName, out var personId)) continue;
|
||||
var key = $"{pair.AlbumName}|{personId}";
|
||||
if (albumIdByKey.ContainsKey(key)) continue;
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
var albumId = Guid.NewGuid();
|
||||
albumRepo.Insert(new Album {
|
||||
Id = albumId, Title = pair.AlbumName, PersonOwnerId = personId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
albumIdByKey[key] = albumId;
|
||||
albumCreated++;
|
||||
}
|
||||
if (albumCreated > 0) {
|
||||
albumRepo.Save();
|
||||
await albumRepo.SaveAsync(token);
|
||||
logger.LogInformation("Created {Count} new albums.", albumCreated);
|
||||
}
|
||||
|
||||
// Build lookup for the assignment phase (covers both new and pre-existing albums)
|
||||
var albumLookup = new Dictionary<(string Person, string Album), Guid>();
|
||||
foreach (var (personName, albumNames) in personAlbumNames) {
|
||||
if (!existingPersons.TryGetValue(personName, out var personId)) continue;
|
||||
JobStatus.UpdateProgress(0.45f, $"Entities complete: {personCreated} persons, {albumCreated} albums. Assigning assets...");
|
||||
|
||||
foreach (var albumName in albumNames) {
|
||||
if (albumIdMap.TryGetValue((personName, albumName), out var id)) {
|
||||
albumLookup[(personName, albumName)] = id;
|
||||
} else {
|
||||
var existing = await db.Albums
|
||||
.Where(a => a.Title == albumName && a.PersonOwnerId == personId)
|
||||
.Select(a => a.Id)
|
||||
.FirstOrDefaultAsync(token);
|
||||
if (existing != Guid.Empty)
|
||||
albumLookup[(personName, albumName)] = existing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JobStatus.UpdateProgress(0.40f, $"Entities complete: {personCreated} persons, {albumCreated} albums created. Assigning assets...");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// ── Phase 3: Re-scan assets in batches, assign to albums via Asset.Albums ──
|
||||
// ── Phase 4: Assign assets to albums (bulk, idempotent) ──
|
||||
var personIdByName = existingPersons; // name → id (includes newly created)
|
||||
int assetAssignments = 0;
|
||||
int pendingBeforeSave = 0;
|
||||
folderIndex = 0;
|
||||
long processed = 0;
|
||||
Guid lastStagingId = Guid.Empty;
|
||||
var assignmentBuffer = new List<(Guid AlbumId, Guid AssetId)>(AssignmentBatchSize);
|
||||
long totalToAssign = await db.AssetAlbumStaging.CountAsync(s => s.AlbumName != null, token);
|
||||
|
||||
foreach (var folder in folders) {
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var regex = new Regex(folder.RegexPattern!, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
var groupNames = regex.GetGroupNames().Select(g => g.ToLowerInvariant()).ToHashSet();
|
||||
var rows = await db.AssetAlbumStaging.AsNoTracking()
|
||||
.Where(s => s.Id > lastStagingId && s.AlbumName != null)
|
||||
.OrderBy(s => s.Id)
|
||||
.Take(ScanBatchSize)
|
||||
.Select(s => new { s.Id, AssetId = s.AssetId, s.PersonName, AlbumName = s.AlbumName! })
|
||||
.ToListAsync(token);
|
||||
|
||||
if (!groupNames.Contains("person") || !groupNames.Contains("group"))
|
||||
continue;
|
||||
if (rows.Count == 0) break;
|
||||
lastStagingId = rows[^1].Id;
|
||||
processed += rows.Count;
|
||||
|
||||
var totalInFolder = await db.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.CountAsync(token);
|
||||
|
||||
int folderAssigned = 0;
|
||||
int folderScanned = 0;
|
||||
|
||||
for (int offset = 0; offset < totalInFolder; offset += ScanBatchSize) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var assets = await db.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.OrderBy(a => a.Id)
|
||||
.Skip(offset)
|
||||
.Take(ScanBatchSize)
|
||||
.Select(a => new { a.Id, a.OriginalPath })
|
||||
.ToListAsync(token);
|
||||
|
||||
var batchAssetIds = assets.Select(a => a.Id).ToList();
|
||||
var existingPairs = new HashSet<(Guid AlbumId, Guid AssetId)>();
|
||||
if (batchAssetIds.Count > 0) {
|
||||
var existing = await db.Albums
|
||||
.Where(a => a.Assets!.Any(asset => batchAssetIds.Contains(asset.Id)))
|
||||
.SelectMany(a => a.Assets!.Select(asset => new { AlbumId = a.Id, AssetId = asset.Id }))
|
||||
.Where(x => batchAssetIds.Contains(x.AssetId))
|
||||
.Select(x => new { x.AlbumId, x.AssetId })
|
||||
.ToListAsync(token);
|
||||
foreach (var pair in existing)
|
||||
existingPairs.Add((pair.AlbumId, pair.AssetId));
|
||||
foreach (var row in rows) {
|
||||
if (!personIdByName.TryGetValue(row.PersonName, out var personId)) continue;
|
||||
if (!albumIdByKey.TryGetValue($"{row.AlbumName}|{personId}", out var albumId)) continue;
|
||||
assignmentBuffer.Add((albumId, row.AssetId));
|
||||
if (assignmentBuffer.Count >= AssignmentBatchSize) {
|
||||
await InsertAlbumAssetBatch(db, assignmentBuffer, token);
|
||||
assetAssignments += assignmentBuffer.Count;
|
||||
assignmentBuffer.Clear();
|
||||
}
|
||||
|
||||
foreach (var asset in assets) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var match = regex.Match(asset.OriginalPath);
|
||||
if (!match.Success) continue;
|
||||
|
||||
var personGroup = match.Groups["person"];
|
||||
var groupGroup = match.Groups["group"];
|
||||
if (!personGroup.Success || !groupGroup.Success) continue;
|
||||
|
||||
var personName = ToTitleCase(personGroup.Value.Replace('_', ' ').Trim());
|
||||
var albumName = ToTitleCase(groupGroup.Value.Replace('_', ' ').Trim());
|
||||
|
||||
if (string.IsNullOrWhiteSpace(personName) || string.IsNullOrWhiteSpace(albumName))
|
||||
continue;
|
||||
|
||||
if (!albumLookup.TryGetValue((personName, albumName), out var albumId))
|
||||
continue;
|
||||
|
||||
var assetStub = db.Assets.Local.FirstOrDefault(a => a.Id == asset.Id);
|
||||
if (assetStub == null) {
|
||||
assetStub = new Asset {
|
||||
Id = asset.Id,
|
||||
OriginalPath = string.Empty,
|
||||
OriginalFilename = string.Empty,
|
||||
Type = EAssetType.Image,
|
||||
CreatedAt = DateTime.MinValue
|
||||
};
|
||||
db.Entry(assetStub).State = EntityState.Unchanged;
|
||||
}
|
||||
|
||||
var albumStub = db.Albums.Local.FirstOrDefault(a => a.Id == albumId);
|
||||
if (albumStub == null) {
|
||||
albumStub = new Album { Id = albumId };
|
||||
db.Entry(albumStub).State = EntityState.Unchanged;
|
||||
}
|
||||
|
||||
if (assetStub.Albums == null)
|
||||
assetStub.Albums = new List<Album>();
|
||||
|
||||
if (!existingPairs.Contains((albumId, asset.Id)) && !assetStub.Albums.Any(a => a.Id == albumId)) {
|
||||
assetStub.Albums.Add(albumStub);
|
||||
assetAssignments++;
|
||||
folderAssigned++;
|
||||
}
|
||||
pendingBeforeSave++;
|
||||
|
||||
if (pendingBeforeSave >= SaveBatchSize) {
|
||||
await db.SaveChangesAsync(token);
|
||||
db.ChangeTracker.Clear();
|
||||
pendingBeforeSave = 0;
|
||||
}
|
||||
}
|
||||
|
||||
folderScanned += assets.Count;
|
||||
|
||||
// Per-batch message update while keeping progress at per-folder level
|
||||
float batchScanFraction = Math.Min(1f, (float)(offset + ScanBatchSize) / totalInFolder);
|
||||
float progress = 0.40f + (folderIndex + batchScanFraction) / folders.Count * 0.60f;
|
||||
JobStatus.UpdateProgress(progress,
|
||||
$"Assigning in {folder.BasePath}: {Math.Min(offset + ScanBatchSize, totalInFolder):N0}/{totalInFolder:N0} — {assetAssignments:N0} total");
|
||||
}
|
||||
|
||||
folderIndex++;
|
||||
float folderProgress = 0.40f + folderIndex / folders.Count * 0.60f;
|
||||
JobStatus.UpdateProgress(folderProgress,
|
||||
$"Assigned {folderAssigned:N0} assets in {folder.BasePath} ({assetAssignments:N0} total so far)");
|
||||
|
||||
logger.LogInformation("Assigned {FolderAssignments:N0} assets in folder {BasePath} ({Total:N0} total)",
|
||||
folderAssigned, folder.BasePath, assetAssignments);
|
||||
float assignFraction = totalToAssign == 0 ? 1f : (float)processed / totalToAssign;
|
||||
JobStatus.UpdateProgress(0.45f + Math.Clamp(assignFraction, 0f, 1f) * 0.25f,
|
||||
$"Assigning assets: {assetAssignments:N0} assigned");
|
||||
}
|
||||
|
||||
if (pendingBeforeSave > 0)
|
||||
await db.SaveChangesAsync(token);
|
||||
if (assignmentBuffer.Count > 0) {
|
||||
await InsertAlbumAssetBatch(db, assignmentBuffer, token);
|
||||
assetAssignments += assignmentBuffer.Count;
|
||||
assignmentBuffer.Clear();
|
||||
}
|
||||
|
||||
db.ChangeTracker.Clear();
|
||||
JobStatus.UpdateProgress(0.70f, $"Assigned {assetAssignments:N0} assets. Setting cover images...");
|
||||
|
||||
logger.LogInformation("Setting cover images for albums without one...");
|
||||
// ── Phase 5: Bulk cover assignment (one grouped query) ──
|
||||
int coversAssigned = 0;
|
||||
var albumsNeedingCover = await db.Albums
|
||||
var covers = await db.Albums
|
||||
.Where(a => a.CoverAssetId == null)
|
||||
.Select(a => new {
|
||||
AlbumId = a.Id,
|
||||
AssetId = a.Assets!
|
||||
.Where(s => s.DeletedAt == null)
|
||||
.OrderBy(s => s.OriginalFilename)
|
||||
.Select(s => s.Id)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.Where(x => x.AssetId != Guid.Empty)
|
||||
.ToListAsync(token);
|
||||
|
||||
foreach (var album in albumsNeedingCover) {
|
||||
var firstAsset = await db.Assets
|
||||
.Where(a => a.Albums!.Any(al => al.Id == album.Id) && a.DeletedAt == null)
|
||||
.OrderBy(a => a.OriginalFilename)
|
||||
.Select(a => new { a.Id })
|
||||
.FirstOrDefaultAsync(token);
|
||||
|
||||
if (firstAsset != null) {
|
||||
album.CoverAssetId = firstAsset.Id;
|
||||
if (covers.Count > 0) {
|
||||
var albumIds = covers.Select(c => c.AlbumId).Distinct().ToList();
|
||||
var albums = await db.Albums.Where(a => albumIds.Contains(a.Id)).ToListAsync(token);
|
||||
foreach (var cover in covers) {
|
||||
var album = albums.FirstOrDefault(a => a.Id == cover.AlbumId);
|
||||
if (album == null || album.CoverAssetId != null) continue;
|
||||
album.CoverAssetId = cover.AssetId;
|
||||
coversAssigned++;
|
||||
}
|
||||
if (coversAssigned > 0) {
|
||||
await db.SaveChangesAsync(token);
|
||||
logger.LogInformation("Assigned cover images to {Count} albums.", coversAssigned);
|
||||
}
|
||||
}
|
||||
|
||||
if (coversAssigned > 0) {
|
||||
await db.SaveChangesAsync(token);
|
||||
logger.LogInformation("Assigned cover images to {Count} albums.", coversAssigned);
|
||||
// ── Phase 6: Commit folder cursors + clean staging ──
|
||||
foreach (var (folderId, scanAt, regex) in folderCursorUpdates) {
|
||||
var f = await db.Folders.FirstOrDefaultAsync(x => x.Id == folderId, token);
|
||||
if (f == null) continue;
|
||||
f.LastDiscoveryScanAt = scanAt;
|
||||
f.LastDiscoveryScannedRegex = regex;
|
||||
}
|
||||
await db.SaveChangesAsync(token);
|
||||
await TruncateStaging(db, token);
|
||||
|
||||
logger.LogInformation("Completed: {Assigned} assets assigned across {PersonsCreated} persons, {AlbumsCreated} albums.",
|
||||
assetAssignments, personCreated, albumCreated);
|
||||
@@ -405,6 +341,28 @@ public class CreateAlbumsJob(ILogger<CreateAlbumsJob> logger, IServiceProvider s
|
||||
}
|
||||
}
|
||||
|
||||
static async Task FlushStaging(LactoseDbContext db, List<AssetAlbumStaging> buffer, CancellationToken token) {
|
||||
db.AssetAlbumStaging.AddRange(buffer);
|
||||
await db.SaveChangesAsync(token);
|
||||
db.ChangeTracker.Clear();
|
||||
}
|
||||
|
||||
static async Task TruncateStaging(LactoseDbContext db, CancellationToken token) =>
|
||||
await db.AssetAlbumStaging.ExecuteDeleteAsync(token);
|
||||
|
||||
static async Task InsertAlbumAssetBatch(LactoseDbContext db, List<(Guid AlbumId, Guid AssetId)> batch, CancellationToken token) {
|
||||
var sql = new StringBuilder("INSERT INTO \"AlbumAsset\" (\"AlbumsId\",\"AssetsId\") VALUES ");
|
||||
var parameters = new List<NpgsqlParameter>(batch.Count * 2);
|
||||
for (int i = 0; i < batch.Count; i++) {
|
||||
if (i > 0) sql.Append(',');
|
||||
sql.Append($"(@a{i},@s{i})");
|
||||
parameters.Add(new NpgsqlParameter($"@a{i}", batch[i].AlbumId));
|
||||
parameters.Add(new NpgsqlParameter($"@s{i}", batch[i].AssetId));
|
||||
}
|
||||
sql.Append(" ON CONFLICT DO NOTHING");
|
||||
await db.Database.ExecuteSqlRawAsync(sql.ToString(), parameters.ToArray(), token);
|
||||
}
|
||||
|
||||
static string ToTitleCase(string input) =>
|
||||
CultureInfo.CurrentCulture.TextInfo.ToTitleCase(input.ToLowerInvariant());
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Scans active folders with regex patterns in a streaming fashion (suitable for millions of assets),
|
||||
/// extracts person names from asset paths, and creates any missing Person entities.
|
||||
/// </summary>
|
||||
public class CreatePersonsJob(ILogger<CreatePersonsJob> logger, IServiceProvider serviceProvider) : Job {
|
||||
const int ScanBatchSize = 5000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Create Persons Job";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task TaskJob(CancellationToken token) {
|
||||
try {
|
||||
logger.LogInformation("Starting create persons job...");
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
|
||||
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
|
||||
var personRepo = scope.ServiceProvider.GetRequiredService<IPersonRepository>();
|
||||
|
||||
var folders = folderRepo.GetAll()
|
||||
.Where(f => f.Active && !string.IsNullOrWhiteSpace(f.RegexPattern))
|
||||
.ToList();
|
||||
|
||||
if (folders.Count == 0) {
|
||||
logger.LogInformation("No active folders with a regex pattern found.");
|
||||
JobStatus.Complete("No active folders with a regex pattern found.");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Scanning {FolderCount} active folders for person names...", folders.Count);
|
||||
|
||||
// ── Phase 1: Streaming scan → collect unique person names ──
|
||||
var personNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
float folderIndex = 0;
|
||||
int totalAssetsMatched = 0;
|
||||
|
||||
foreach (var folder in folders) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var regex = new Regex(folder.RegexPattern!, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
if (!regex.GetGroupNames().Contains("person", StringComparer.OrdinalIgnoreCase)) {
|
||||
logger.LogWarning("Folder {FolderId} ({BasePath}) regex is missing the 'person' named group. Skipping.", folder.Id, folder.BasePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
var totalInFolder = await db.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.CountAsync(token);
|
||||
|
||||
int folderScanned = 0;
|
||||
int folderMatched = 0;
|
||||
|
||||
for (int offset = 0; offset < totalInFolder; offset += ScanBatchSize) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var paths = await db.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.OrderBy(a => a.Id)
|
||||
.Skip(offset)
|
||||
.Take(ScanBatchSize)
|
||||
.Select(a => a.OriginalPath)
|
||||
.ToListAsync(token);
|
||||
|
||||
int batchMatched = 0;
|
||||
foreach (var path in paths) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var match = regex.Match(path);
|
||||
if (!match.Success) continue;
|
||||
|
||||
var group = match.Groups["person"];
|
||||
if (!group.Success) continue;
|
||||
|
||||
var name = ToTitleCase(group.Value.Replace('_', ' ').Trim());
|
||||
if (!string.IsNullOrWhiteSpace(name)) {
|
||||
personNames.Add(name);
|
||||
batchMatched++;
|
||||
}
|
||||
}
|
||||
|
||||
folderScanned += paths.Count;
|
||||
folderMatched += batchMatched;
|
||||
|
||||
// Per-batch message update while keeping progress at per-folder level
|
||||
float progress = folderIndex / folders.Count * 0.5f;
|
||||
JobStatus.UpdateProgress(progress,
|
||||
$"Scanning {folder.BasePath}: {folderScanned:N0}/{totalInFolder:N0} — {personNames.Count:N0} persons found so far");
|
||||
}
|
||||
|
||||
totalAssetsMatched += folderMatched;
|
||||
folderIndex++;
|
||||
float folderProgress = folderIndex / folders.Count * 0.5f;
|
||||
JobStatus.UpdateProgress(folderProgress,
|
||||
$"Scanned folder {folder.BasePath}: {folderScanned:N0} assets, {folderMatched:N0} matched — {personNames.Count:N0} unique persons");
|
||||
|
||||
logger.LogInformation("Scanned folder {BasePath}: {Scanned:N0} assets, {Matched} matched. Cumulative: {Persons} unique persons",
|
||||
folder.BasePath, folderScanned, folderMatched, personNames.Count);
|
||||
}
|
||||
|
||||
if (personNames.Count == 0) {
|
||||
logger.LogInformation("No person names extracted from asset paths.");
|
||||
JobStatus.Complete("No person names extracted from asset paths.");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Extracted {Count} unique person names from {Matched:N0} matched assets. Checking existing...",
|
||||
personNames.Count, totalAssetsMatched);
|
||||
JobStatus.UpdateProgress(0.5f, $"Extracted {personNames.Count:N0} unique person names from {totalAssetsMatched:N0} matched assets.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
|
||||
// ── Phase 2: Create missing persons ──
|
||||
|
||||
var existingNames = personRepo.FindByNames(personNames)
|
||||
.Select(p => p.Name)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var missingNames = personNames
|
||||
.Where(n => !existingNames.Contains(n))
|
||||
.ToList();
|
||||
|
||||
if (missingNames.Count == 0) {
|
||||
logger.LogInformation("All {Count} persons already exist.", personNames.Count);
|
||||
JobStatus.Complete($"All {personNames.Count:N0} persons already exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Creating {Count} missing persons ({Existing} already exist).",
|
||||
missingNames.Count, existingNames.Count);
|
||||
|
||||
float created = 0;
|
||||
foreach (var name in missingNames) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
personRepo.Insert(new Person {
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
created++;
|
||||
float progress = 0.5f + created / missingNames.Count * 0.5f;
|
||||
JobStatus.UpdateProgress(progress, $"Creating persons: {created:N0}/{missingNames.Count:N0} — {name}");
|
||||
}
|
||||
|
||||
personRepo.Save();
|
||||
logger.LogInformation("Created {Count} new persons.", missingNames.Count);
|
||||
JobStatus.Complete($"Created {missingNames.Count:N0} new persons from {totalAssetsMatched:N0} matched assets.");
|
||||
} catch (OperationCanceledException) {
|
||||
JobStatus.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
static string ToTitleCase(string input) =>
|
||||
CultureInfo.CurrentCulture.TextInfo.ToTitleCase(input.ToLowerInvariant());
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Runs ffmpeg/ffprobe invocations for media conversion jobs.
|
||||
/// </summary>
|
||||
internal static class FfmpegRunner {
|
||||
/// <summary>
|
||||
/// Executes ffmpeg with the given arguments, capturing output for logging.
|
||||
/// </summary>
|
||||
/// <param name="ffmpegPath">Path to the ffmpeg executable.</param>
|
||||
/// <param name="arguments">Command-line arguments to pass to ffmpeg.</param>
|
||||
/// <param name="logger">Logger for ffmpeg output.</param>
|
||||
/// <param name="token">Cancellation token; the process is killed if cancelled.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when ffmpeg exits with a non-zero exit code.</exception>
|
||||
internal static async Task RunAsync(string ffmpegPath, string arguments, ILogger logger, CancellationToken token) {
|
||||
var startInfo = new ProcessStartInfo {
|
||||
FileName = ffmpegPath,
|
||||
Arguments = arguments,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
process.Start();
|
||||
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync();
|
||||
var stderrTask = process.StandardError.ReadToEndAsync();
|
||||
|
||||
try {
|
||||
await process.WaitForExitAsync(token);
|
||||
} catch (OperationCanceledException) {
|
||||
try { process.Kill(entireProcessTree: true); } catch { /* already exited */ }
|
||||
throw;
|
||||
}
|
||||
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
|
||||
if (!string.IsNullOrEmpty(stdout))
|
||||
logger.LogTrace("ffmpeg stdout: {Output}", stdout);
|
||||
if (!string.IsNullOrEmpty(stderr))
|
||||
logger.LogTrace("ffmpeg stderr: {Output}", stderr);
|
||||
|
||||
if (process.ExitCode != 0) {
|
||||
logger.LogError("ffmpeg exited with code {Code}. Stderr: {Error}", process.ExitCode, stderr);
|
||||
throw new InvalidOperationException($"ffmpeg exited with code {process.ExitCode}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+240
-141
@@ -1,204 +1,316 @@
|
||||
using Butter;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using System.Collections;
|
||||
using System.Data;
|
||||
using static System.String;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Crawls a file system directory, recursively discovering assets and creating child crawl jobs for subdirectories.
|
||||
/// Crawls a file system directory, incrementally discovering and reconciling assets against the database.
|
||||
/// Uses per-directory modification timestamps to skip file enumeration in unchanged directories,
|
||||
/// and batches all database writes instead of saving once per file.
|
||||
/// </summary>
|
||||
public sealed class FileSystemCrawlJob : Job {
|
||||
/// <inheritdoc />
|
||||
public override string Name { get; } = "Directory Scan: ";
|
||||
public override string Name { get; }
|
||||
/// <inheritdoc />
|
||||
public override JobStatus JobStatus { get; }
|
||||
IAssetRepository assetRepository;
|
||||
ISettingsRepository settingsRepository;
|
||||
string workingPath;
|
||||
Guid folderId;
|
||||
ILogger<FileSystemCrawlJob> logger;
|
||||
JobManager jobManager;
|
||||
List<Job> childJobs = [];
|
||||
int pendingChildren;
|
||||
readonly Guid folderId;
|
||||
readonly string workingPath;
|
||||
readonly bool deep;
|
||||
readonly ILogger<FileSystemCrawlJob> logger;
|
||||
|
||||
const int ProgressUpdateEveryDirs = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises a new file system crawl job for the specified folder.
|
||||
/// Initialises a new file-system crawl job for the specified folder.
|
||||
/// </summary>
|
||||
/// <param name="folderId">The folder ID.</param>
|
||||
/// <param name="workingPath">The base path to crawl.</param>
|
||||
/// <param name="deep">When true, ignores recorded scan state and reconciles every asset.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="assetRepository">The asset repository.</param>
|
||||
/// <param name="settingsRepository">The settings repository.</param>
|
||||
/// <param name="jobManager">The job manager.</param>
|
||||
public FileSystemCrawlJob(
|
||||
Guid folderId,
|
||||
string workingPath,
|
||||
ILogger<FileSystemCrawlJob> logger,
|
||||
IAssetRepository assetRepository,
|
||||
ISettingsRepository settingsRepository,
|
||||
JobManager jobManager
|
||||
) {
|
||||
JobStatus = new(this);
|
||||
this.logger = logger;
|
||||
this.assetRepository = assetRepository;
|
||||
this.settingsRepository = settingsRepository;
|
||||
this.workingPath = workingPath;
|
||||
public FileSystemCrawlJob(Guid folderId, string workingPath, bool deep, ILogger<FileSystemCrawlJob> logger) {
|
||||
JobStatus = new(this);
|
||||
this.folderId = folderId;
|
||||
this.jobManager = jobManager;
|
||||
Name += $"{workingPath}";
|
||||
this.workingPath = workingPath;
|
||||
this.deep = deep;
|
||||
this.logger = logger;
|
||||
Name = $"Directory Scan: {workingPath}";
|
||||
}
|
||||
|
||||
///<inheritdoc />
|
||||
protected override async Task TaskJob(CancellationToken token) {
|
||||
logger.LogInformation($"Started crawling directory {workingPath}");
|
||||
logger.LogInformation("Started crawling directory {Path} (deep={Deep})", workingPath, deep);
|
||||
|
||||
DirectoryInfo dir = new(workingPath);
|
||||
|
||||
if (!dir.Exists) {
|
||||
var root = new DirectoryInfo(workingPath);
|
||||
if (!root.Exists) {
|
||||
logger.LogWarning("Directory {Dir} does not exist. Skipping crawl.", workingPath);
|
||||
JobStatus.Complete("Directory does not exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
var childTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var db = Scope!.ServiceProvider.GetRequiredService<LactoseDbContext>();
|
||||
var settingsRepo = Scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
|
||||
|
||||
int steps,
|
||||
totalSteps;
|
||||
|
||||
// Resolve system uploader from settings once
|
||||
Guid? uploaderId = null;
|
||||
var uploaderSetting = settingsRepository.Get(Settings.SystemUploaderId.AsString())?.Value;
|
||||
if (Guid.TryParse(uploaderSetting, out var parsed))
|
||||
uploaderId = parsed;
|
||||
var uploaderSetting = (await settingsRepo.GetAsync(Settings.SystemUploaderId.AsString(), token))?.Value;
|
||||
if (Guid.TryParse(uploaderSetting, out var parsed)) uploaderId = parsed;
|
||||
|
||||
var files = dir.GetFiles();
|
||||
logger.LogDebug($"Found {files.Length} files.");
|
||||
// Separate context for asset reconciliation so the change tracker can be cleared per directory
|
||||
// without detaching the scan-state entities tracked by `db`.
|
||||
using var assetScope = Scope.ServiceProvider.CreateScope();
|
||||
var assetDb = assetScope.ServiceProvider.GetRequiredService<LactoseDbContext>();
|
||||
|
||||
var folders = dir.GetDirectories();
|
||||
logger.LogDebug($"Found {folders.Length} folders.");
|
||||
var existingStates = await db.DirectoryScanStates
|
||||
.Where(s => s.FolderId == folderId)
|
||||
.ToDictionaryAsync(s => s.Path, StringComparer.Ordinal, token);
|
||||
|
||||
totalSteps = files.Length + folders.Length;
|
||||
steps = 0;
|
||||
// Progress denominator: reuse the number of directories recorded by the previous scan, which is
|
||||
// accurate once a first run has completed. On the very first run (no state yet) there is no
|
||||
// denominator, so the bar ramps heuristically while the live message shows real progress.
|
||||
int totalDirs = Math.Max(existingStates.Count, 1);
|
||||
bool haveDenominator = existingStates.Count > 0;
|
||||
logger.LogInformation("Crawling {Path}: {Total} known directories.", workingPath, totalDirs);
|
||||
JobStatus.UpdateProgress(0.02f, haveDenominator
|
||||
? $"Scanning {totalDirs:N0} directories..."
|
||||
: "Scanning... (first run)");
|
||||
|
||||
foreach (var folder in folders) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel();
|
||||
return;
|
||||
}
|
||||
int dirsProcessed = 0, dirsSkipped = 0, walkedDirs = 0,
|
||||
filesAdded = 0, filesUpdated = 0, filesDeleted = 0;
|
||||
var lastProgressUpdate = DateTime.UtcNow;
|
||||
var seenPaths = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
steps++;
|
||||
var job = jobManager.CreateJob<FileSystemCrawlJob>(folderId, folder.FullName);
|
||||
job.ParentJobId = Id;
|
||||
job.LinkParentToken(token);
|
||||
job.Done += (o, _) => {
|
||||
lock (childJobs) { childJobs.Remove((o as Job)!); }
|
||||
if (Interlocked.Decrement(ref pendingChildren) == 0)
|
||||
childTcs.TrySetResult();
|
||||
};
|
||||
Interlocked.Increment(ref pendingChildren);
|
||||
lock (childJobs) { childJobs.Add(job); }
|
||||
jobManager.EnqueueJob(job);
|
||||
logger.LogDebug($"Enqueued crawl job for folder {folder.FullName}");
|
||||
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing folder {folder.FullName}");
|
||||
}
|
||||
var stack = new Stack<string>();
|
||||
stack.Push(root.FullName);
|
||||
|
||||
foreach (var file in files) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel();
|
||||
return;
|
||||
}
|
||||
while (stack.Count > 0) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
var currentPath = stack.Pop();
|
||||
seenPaths.Add(currentPath);
|
||||
|
||||
steps++;
|
||||
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing file {file.FullName}");
|
||||
var currentDir = new DirectoryInfo(currentPath);
|
||||
if (!currentDir.Exists) continue;
|
||||
walkedDirs++;
|
||||
|
||||
var existing = assetRepository.FindByPath(file.FullName);
|
||||
|
||||
if (existing != null) {
|
||||
if (!file.Exists) {
|
||||
existing.DeletedAt = DateTime.UtcNow;
|
||||
assetRepository.Update(existing);
|
||||
assetRepository.Save();
|
||||
logger.LogInformation("Soft-deleted asset {Asset} — file no longer exists.", existing.OriginalPath);
|
||||
} else if (file.LastWriteTimeUtc > (existing.UpdatedAt ?? existing.CreatedAt)) {
|
||||
existing.FileSize = file.Length;
|
||||
existing.UpdatedAt = file.LastWriteTimeUtc;
|
||||
if (existing.UploadedBy == null && uploaderId.HasValue)
|
||||
existing.UploadedBy = uploaderId;
|
||||
assetRepository.Update(existing);
|
||||
assetRepository.Save();
|
||||
logger.LogInformation("Updated asset {Asset} — file modification detected.", existing.OriginalPath);
|
||||
} else if (existing.UploadedBy == null && uploaderId.HasValue) {
|
||||
existing.UploadedBy = uploaderId;
|
||||
assetRepository.Update(existing);
|
||||
assetRepository.Save();
|
||||
logger.LogInformation("Backfilled UploadedBy for asset {Asset}.", existing.OriginalPath);
|
||||
// A directory's mtime changes only when its direct children change; descendant changes bump
|
||||
// only their own parent. So we must always descend, but skip file reconciliation for clean dirs.
|
||||
bool changed = deep;
|
||||
if (!changed) {
|
||||
if (!existingStates.TryGetValue(currentPath, out var state)) {
|
||||
changed = true; // new directory
|
||||
} else {
|
||||
changed = state.LastMtime != currentDir.LastWriteTimeUtc;
|
||||
}
|
||||
}
|
||||
|
||||
List<DirectoryInfo> subdirs;
|
||||
FileInfo[]? files = null;
|
||||
try {
|
||||
subdirs = currentDir.GetDirectories().ToList();
|
||||
if (changed) files = currentDir.GetFiles();
|
||||
} catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Could not enumerate directory {Path}.", currentPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
var asset = AssetFromPath(file.FullName, uploaderId);
|
||||
if (asset == null) continue;
|
||||
if (changed) {
|
||||
dirsProcessed++;
|
||||
bool reconciled = await ReconcileDirectory(assetDb, currentPath, files!, uploaderId, token,
|
||||
(added, updated, deleted) => { filesAdded += added; filesUpdated += updated; filesDeleted += deleted; });
|
||||
|
||||
try {
|
||||
assetRepository.Insert(asset);
|
||||
assetRepository.Save();
|
||||
logger.LogInformation("Inserted asset {Asset} into the database.", asset.OriginalPath);
|
||||
} catch (DuplicateNameException) {
|
||||
logger.LogWarning("Asset {Asset} already exists in the database. Skipping.", asset.OriginalPath);
|
||||
} catch (Exception e) {
|
||||
logger.LogError(e, "Error inserting asset {Asset} into the database. {e}", asset.OriginalPath, e);
|
||||
// Only record scan state when the reconciliation batch actually committed; a dropped
|
||||
// batch leaves the mtime stale so the directory is retried on the next run.
|
||||
if (reconciled) {
|
||||
if (existingStates.TryGetValue(currentPath, out var existingState)) {
|
||||
existingState.LastMtime = currentDir.LastWriteTimeUtc;
|
||||
} else {
|
||||
existingStates[currentPath] = new DirectoryScanState {
|
||||
Id = Guid.NewGuid(),
|
||||
FolderId = folderId,
|
||||
Path = currentPath,
|
||||
LastMtime = currentDir.LastWriteTimeUtc
|
||||
};
|
||||
db.DirectoryScanStates.Add(existingStates[currentPath]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dirsSkipped++;
|
||||
}
|
||||
|
||||
foreach (var subdir in subdirs)
|
||||
stack.Push(subdir.FullName);
|
||||
|
||||
if (walkedDirs % ProgressUpdateEveryDirs == 0
|
||||
|| DateTime.UtcNow - lastProgressUpdate > TimeSpan.FromMilliseconds(500)) {
|
||||
lastProgressUpdate = DateTime.UtcNow;
|
||||
// Ramp heuristically when we have no accurate denominator (first run); otherwise
|
||||
// progress is walkedDirs/totalDirs. Cap before the final 0.95 "saving" step.
|
||||
float progress = haveDenominator
|
||||
? Math.Clamp(0.05f + 0.90f * walkedDirs / totalDirs, 0.05f, 0.90f)
|
||||
: Math.Clamp(0.05f + 0.80f * Math.Min(1f, walkedDirs / 5000f), 0.05f, 0.85f);
|
||||
JobStatus.UpdateProgress(progress,
|
||||
haveDenominator
|
||||
? $"Scanning {workingPath}: {walkedDirs:N0}/{totalDirs:N0} dirs — {filesAdded:N0} added, {filesUpdated:N0} updated, {filesDeleted:N0} removed"
|
||||
: $"Scanning {workingPath}: {walkedDirs:N0} dirs — {filesAdded:N0} added, {filesUpdated:N0} updated, {filesDeleted:N0} removed");
|
||||
}
|
||||
}
|
||||
|
||||
var childCount = Interlocked.CompareExchange(ref pendingChildren, 0, 0);
|
||||
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Waiting for {childCount:N0} child jobs to complete.");
|
||||
JobStatus.Wait();
|
||||
JobStatus.UpdateProgress(0.95f, "Saving scan state...");
|
||||
|
||||
if (childCount > 0) {
|
||||
using var ctr = token.Register(() => childTcs.TrySetCanceled(token));
|
||||
try {
|
||||
await childTcs.Task.ConfigureAwait(false);
|
||||
} catch (TaskCanceledException) {
|
||||
JobStatus.Cancel();
|
||||
return;
|
||||
}
|
||||
// Drop scan-state rows for directories that no longer exist on disk.
|
||||
var staleStates = existingStates.Values.Where(s => !seenPaths.Contains(s.Path)).ToList();
|
||||
db.DirectoryScanStates.RemoveRange(staleStates);
|
||||
|
||||
var folderEntity = await db.Folders.FirstOrDefaultAsync(f => f.Id == folderId, token);
|
||||
if (folderEntity != null) {
|
||||
folderEntity.LastFileScanAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
JobStatus.Complete("Crawl completed successfully.");
|
||||
await db.SaveChangesAsync(token);
|
||||
|
||||
logger.LogInformation("Crawl complete for {Path}: {Dirs} dirs processed, {Skipped} skipped; {Added} added, {Updated} updated, {Deleted} removed.",
|
||||
workingPath, dirsProcessed, dirsSkipped, filesAdded, filesUpdated, filesDeleted);
|
||||
JobStatus.Complete($"Scanned {workingPath}: {filesAdded:N0} added, {filesUpdated:N0} updated, {filesDeleted:N0} removed, {dirsSkipped:N0} dirs unchanged.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles the direct children of a directory against the database: inserts new assets,
|
||||
/// updates changed ones, and soft-deletes previously tracked files that no longer exist.
|
||||
/// All writes are batched into a single <see cref="DbContext.SaveChangesAsync(CancellationToken)"/> call.
|
||||
/// </summary>
|
||||
/// <param name="assetDb">A dedicated context for asset writes; cleared after each directory.</param>
|
||||
/// <param name="dirPath">The directory being reconciled.</param>
|
||||
/// <param name="files">The current direct files of the directory.</param>
|
||||
/// <param name="uploaderId">The system uploader id to backfill, if any.</param>
|
||||
/// <param name="token">Cancellation token.</param>
|
||||
/// <param name="report">Callback receiving (added, updated, deleted) counts.</param>
|
||||
/// <returns>True when the batch committed; false when it was dropped due to a duplicate-key race.</returns>
|
||||
async Task<bool> ReconcileDirectory(LactoseDbContext assetDb, string dirPath, FileInfo[] files, Guid? uploaderId,
|
||||
CancellationToken token, Action<int, int, int> report) {
|
||||
var prefix = dirPath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var lo = prefix;
|
||||
var hi = prefix + "\uFFFF";
|
||||
|
||||
// Load known assets that are direct children of this directory. Soft-deleted assets are included
|
||||
// so a file that still exists on disk is reconciled (or left deleted) instead of re-inserted,
|
||||
// which would violate the unique index on OriginalPath. The path prefix (not FolderId) is what
|
||||
// scopes the lookup — OriginalPath is globally unique, so a row can exist under any folder.
|
||||
var known = (await assetDb.Assets.AsNoTracking()
|
||||
.Where(a => a.OriginalPath.CompareTo(lo) >= 0 && a.OriginalPath.CompareTo(hi) < 0)
|
||||
.ToListAsync(token))
|
||||
.Where(a => !a.OriginalPath.AsSpan(prefix.Length).Contains(Path.DirectorySeparatorChar))
|
||||
.ToDictionary(a => a.OriginalPath, StringComparer.Ordinal);
|
||||
|
||||
// Exact-path fallback: OriginalPath is globally unique, so a row can exist under a different
|
||||
// folder (e.g. a recreated folder with a new ID) or fall outside the range query above.
|
||||
// Such files must be reconciled, never re-inserted.
|
||||
var unmatched = files
|
||||
.Select(f => f.FullName)
|
||||
.Where(p => !known.ContainsKey(p))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
foreach (var chunk in unmatched.Chunk(1000)) {
|
||||
var existing = await assetDb.Assets.AsNoTracking()
|
||||
.Where(a => chunk.Contains(a.OriginalPath))
|
||||
.ToListAsync(token);
|
||||
foreach (var a in existing)
|
||||
known.TryAdd(a.OriginalPath, a);
|
||||
}
|
||||
|
||||
int added = 0, updated = 0, deleted = 0;
|
||||
|
||||
foreach (var file in files) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (known.TryGetValue(file.FullName, out var asset)) {
|
||||
known.Remove(file.FullName);
|
||||
if (file.LastWriteTimeUtc > (asset.UpdatedAt ?? asset.CreatedAt)) {
|
||||
assetDb.Attach(asset);
|
||||
asset.FileSize = file.Length;
|
||||
asset.UpdatedAt = file.LastWriteTimeUtc;
|
||||
if (asset.UploadedBy == null && uploaderId.HasValue) asset.UploadedBy = uploaderId;
|
||||
updated++;
|
||||
} else if (asset.UploadedBy == null && uploaderId.HasValue) {
|
||||
assetDb.Attach(asset);
|
||||
asset.UploadedBy = uploaderId;
|
||||
updated++;
|
||||
}
|
||||
} else {
|
||||
var newAsset = AssetFromPath(file.FullName, uploaderId);
|
||||
if (newAsset != null) {
|
||||
assetDb.Assets.Add(newAsset);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var gone in known.Values) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (gone.DeletedAt != null) continue; // already soft-deleted
|
||||
assetDb.Attach(gone);
|
||||
gone.DeletedAt = DateTime.UtcNow;
|
||||
deleted++;
|
||||
}
|
||||
|
||||
if (added + updated + deleted > 0) {
|
||||
try {
|
||||
await assetDb.SaveChangesAsync(token);
|
||||
} catch (DbUpdateException ex) when (IsDuplicateKey(ex)) {
|
||||
// Last-resort safety net for genuine races (e.g. two overlapping folders inserting the
|
||||
// same new path concurrently). The batch is dropped; the file is found next run.
|
||||
logger.LogDebug(ex, "Dropped {Count} asset change(s) in {Path} due to a duplicate asset path.",
|
||||
added + updated + deleted, dirPath);
|
||||
report(0, 0, 0);
|
||||
return false;
|
||||
} finally {
|
||||
assetDb.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
report(added, updated, deleted);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool IsDuplicateKey(DbUpdateException ex) =>
|
||||
ex.InnerException is PostgresException { SqlState: "23505" }
|
||||
|| ex.InnerException?.InnerException is PostgresException { SqlState: "23505" };
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="Asset"/> from a file path, or null when the extension is not a supported media type.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The full path of the file.</param>
|
||||
/// <param name="uploaderId">The uploader id to assign, if any.</param>
|
||||
/// <returns>A new asset, or null if the file type is not supported.</returns>
|
||||
Asset? AssetFromPath(string filePath, Guid? uploaderId) {
|
||||
logger.LogTrace("Loading asset from file path {filePath}", filePath);
|
||||
// Get the file info
|
||||
var finfo = new FileInfo(filePath);
|
||||
|
||||
// Determine the type of the file
|
||||
string ext = finfo.Extension.ToLower();
|
||||
string ext = finfo.Extension.ToLower();
|
||||
EAssetType? type;
|
||||
|
||||
// ReSharper disable ArrangeObjectCreationWhenTypeNotEvident
|
||||
var mimeImg = MimeTypes.Image.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
|
||||
var mimeVid = MimeTypes.Video.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
|
||||
|
||||
if (mimeImg.MimeType != Empty) type = EAssetType.Image;
|
||||
else if (mimeVid.MimeType != Empty) type = EAssetType.Video;
|
||||
if (mimeVid.MimeType != Empty) type = EAssetType.Video;
|
||||
else if (mimeImg.MimeType != Empty) type = EAssetType.Image;
|
||||
else return null;
|
||||
|
||||
// Create a new asset
|
||||
var asset = new Asset() {
|
||||
return new Asset {
|
||||
FolderId = folderId,
|
||||
OriginalPath = finfo.FullName,
|
||||
OriginalFilename = finfo.Name,
|
||||
Type = type.Value,
|
||||
Visibility = EVisibility.Private,
|
||||
Visibility = EVisibility.Private,
|
||||
UploadedBy = uploaderId,
|
||||
CreatedAt = finfo.CreationTimeUtc,
|
||||
UpdatedAt = finfo.LastWriteTimeUtc,
|
||||
MimeType = type.Value switch {
|
||||
IngestedAt = DateTime.UtcNow,
|
||||
MimeType = type.Value switch {
|
||||
EAssetType.Image => mimeImg.MimeType,
|
||||
EAssetType.Video => mimeVid.MimeType,
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
@@ -206,18 +318,5 @@ public sealed class FileSystemCrawlJob : Job {
|
||||
FileSize = finfo.Length,
|
||||
Hash = new BitArray(64)
|
||||
};
|
||||
|
||||
logger.LogDebug(
|
||||
$"""
|
||||
Asset created from path: {asset.OriginalPath}
|
||||
Data:
|
||||
- Filename: {asset.OriginalFilename}
|
||||
- Type: {asset.Type}
|
||||
- MimeType: {asset.MimeType}
|
||||
- CreatedAt: {asset.CreatedAt}
|
||||
- UpdatedAt: {asset.UpdatedAt}
|
||||
- FileSize: {asset.FileSize} bytes
|
||||
""");
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
+292
-106
@@ -1,7 +1,8 @@
|
||||
using Butter.Settings;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Utils;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
@@ -9,129 +10,188 @@ namespace Lactose.Jobs;
|
||||
/// Performs a comprehensive integrity check of the system — database, assets, albums, people, thumbnails, and previews.
|
||||
/// </summary>
|
||||
public class IntegrityCheckJob (ILogger<IntegrityCheckJob> logger, IServiceProvider serviceProvider): Job{
|
||||
const float totalSteps = 7;
|
||||
const float totalSteps = 8;
|
||||
const int LiveProgressEvery = 50_000;
|
||||
const int BatchSize = 1000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Integrity Check Job";
|
||||
|
||||
void IncrementProgress(float f) => JobStatus.UpdateProgress(f/totalSteps, "Database connection successful.");
|
||||
/// <summary>
|
||||
/// Advances overall progress to the given step and surfaces a meaningful status message in the web UI.
|
||||
/// </summary>
|
||||
/// <param name="step">The completed step number (1-based).</param>
|
||||
/// <param name="message">A human-readable status message.</param>
|
||||
void ReportStep(int step, string message) => JobStatus.UpdateProgress(step / totalSteps, message);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task TaskJob(CancellationToken token) {
|
||||
try {
|
||||
|
||||
float progress = 0;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
logger.LogInformation("Starting integrity check job...");
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
|
||||
|
||||
//Step 1
|
||||
// Check database connection
|
||||
// Step 1 — Database connection
|
||||
logger.LogInformation("[1/8] Checking database connection...");
|
||||
if (!dbContext.Database.CanConnect()) {
|
||||
logger.LogError("Cannot connect to the database.");
|
||||
JobStatus.Fail("Cannot connect to the database.");
|
||||
return;
|
||||
}
|
||||
IncrementProgress(++progress);
|
||||
logger.LogInformation("[1/8] Database connection successful.");
|
||||
ReportStep(1, "Database connection successful.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Step 2
|
||||
// Check for pending migrations
|
||||
// Step 2 — Pending migrations
|
||||
logger.LogInformation("[2/8] Checking for pending migrations...");
|
||||
var pendingMigrations = dbContext.Database.GetPendingMigrations().ToList();
|
||||
|
||||
if (pendingMigrations.Any()) {
|
||||
logger.LogWarning("There are pending migrations:");
|
||||
logger.LogWarning("Found {Count} pending migrations:", pendingMigrations.Count);
|
||||
|
||||
foreach (var migration in pendingMigrations) logger.LogWarning($"- {migration}");
|
||||
|
||||
// Apply migrations
|
||||
try {
|
||||
dbContext.Database.Migrate();
|
||||
logger.LogInformation("Applying {Count} pending migrations...", pendingMigrations.Count);
|
||||
await dbContext.Database.MigrateAsync(token);
|
||||
logger.LogInformation("Applied pending migrations successfully.");
|
||||
} catch (OperationCanceledException) {
|
||||
throw;
|
||||
} catch (Exception ex) {
|
||||
logger.LogError(ex, "Error applying migrations.");
|
||||
JobStatus.Fail("Error applying migrations.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
logger.LogInformation("[2/8] No pending migrations.");
|
||||
}
|
||||
IncrementProgress(++progress);
|
||||
ReportStep(2, "Migrations up to date.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// A bit of context: Load() will load all entities from the table into the DbContext.
|
||||
// Essentially it will ask the DB for all rows in the table and create entities for them in the DbContext.
|
||||
// This will basically warm up the cache of the DbContext so that when we later access all the rows in the table,
|
||||
// it won't lazy load them one by one, one call at the time.
|
||||
// This is important because lazy loading one by one would be a huge performance hit,
|
||||
// especially if there are many rows in the table.
|
||||
// While for folders it might not be a big deal, this is just for consistency with all the other steps,
|
||||
// especially the asset one.
|
||||
|
||||
// Step 3
|
||||
// Check for folders existence
|
||||
dbContext.Folders.Load();
|
||||
foreach (Folder folder in dbContext.Folders.ToList()) {
|
||||
// Step 3 — Folder existence
|
||||
logger.LogInformation("[3/8] Checking folder existence...");
|
||||
await dbContext.Folders.LoadAsync(token);
|
||||
var folders = await dbContext.Folders.ToListAsync(token);
|
||||
logger.LogInformation("[3/8] Loaded {Count} folders.", folders.Count);
|
||||
int missingFolders = 0;
|
||||
int missingFolderAssets = 0;
|
||||
var lastFolderReport = DateTime.UtcNow;
|
||||
for (int i = 0; i < folders.Count; i++) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!Directory.Exists(folder.BasePath)) {
|
||||
logger.LogWarning($"Folder {folder.Id} does not exist. Deactivating.");
|
||||
folder.Active = false;
|
||||
dbContext.Folders.Update(folder);
|
||||
|
||||
var affectedAssets = dbContext.Assets.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.ToList();
|
||||
foreach (var asset in affectedAssets) {
|
||||
asset.DeletedAt = DateTime.UtcNow;
|
||||
dbContext.Assets.Update(asset);
|
||||
var folder = folders[i];
|
||||
if (Directory.Exists(folder.BasePath)) {
|
||||
if ((i + 1) % LiveProgressEvery == 0 || DateTime.UtcNow - lastFolderReport > TimeSpan.FromSeconds(5)) {
|
||||
lastFolderReport = DateTime.UtcNow;
|
||||
logger.LogInformation("[3/8] Verified {Processed}/{Total} folders ({Missing} missing so far).", i + 1, folders.Count, missingFolders);
|
||||
JobStatus.UpdateProgress(
|
||||
(2f + (float)(i + 1) / folders.Count) / totalSteps,
|
||||
$"Checking folders: {i + 1:N0}/{folders.Count:N0} (missing {missingFolders:N0})");
|
||||
}
|
||||
dbContext.SaveChanges();
|
||||
logger.LogWarning("Soft-deleted {Count} assets associated with missing folder {FolderId}.", affectedAssets.Count, folder.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
missingFolders++;
|
||||
logger.LogWarning("Folder {FolderId} ({Path}) does not exist on disk. Deactivating.", folder.Id, folder.BasePath);
|
||||
folder.Active = false;
|
||||
dbContext.Folders.Update(folder);
|
||||
|
||||
var affectedAssets = await dbContext.Assets
|
||||
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.ToListAsync(token);
|
||||
foreach (var asset in affectedAssets) {
|
||||
asset.DeletedAt = DateTime.UtcNow;
|
||||
dbContext.Assets.Update(asset);
|
||||
}
|
||||
missingFolderAssets += affectedAssets.Count;
|
||||
await dbContext.SaveChangesAsync(token);
|
||||
logger.LogWarning("Soft-deleted {Count} assets associated with missing folder {FolderId}.", affectedAssets.Count, folder.Id);
|
||||
}
|
||||
logger.LogInformation("[3/8] Folders checked: {Missing} missing, {Assets} assets soft-deleted.", missingFolders, missingFolderAssets);
|
||||
ReportStep(3, $"Folders checked: {missingFolders} missing.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Step 4 — Asset existence (streamed via keyset pagination, never loading the whole table)
|
||||
logger.LogInformation("[4/8] Checking asset existence...");
|
||||
var totalLive = await dbContext.Assets.CountAsync(a => a.DeletedAt == null, token);
|
||||
logger.LogInformation("[4/8] {Count} live assets to verify.", totalLive);
|
||||
|
||||
Guid lastId = Guid.Empty;
|
||||
long processed = 0;
|
||||
int deletedAssets = 0;
|
||||
var lastReport = DateTime.UtcNow;
|
||||
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var batch = await dbContext.Assets.AsNoTracking()
|
||||
.Where(a => a.DeletedAt == null && a.Id > lastId)
|
||||
.OrderBy(a => a.Id)
|
||||
.Take(BatchSize)
|
||||
.Select(a => new { a.Id, a.OriginalPath })
|
||||
.ToListAsync(token);
|
||||
|
||||
if (batch.Count == 0) break;
|
||||
|
||||
var missingIds = new List<Guid>(batch.Count);
|
||||
foreach (var item in batch) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!File.Exists(item.OriginalPath)) {
|
||||
missingIds.Add(item.Id);
|
||||
logger.LogWarning("Asset {AssetId} ({Path}) does not exist. Soft-deleting.", item.Id, item.OriginalPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingIds.Count > 0) {
|
||||
await dbContext.Assets
|
||||
.Where(a => missingIds.Contains(a.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.DeletedAt, DateTime.UtcNow), token);
|
||||
deletedAssets += missingIds.Count;
|
||||
}
|
||||
|
||||
lastId = batch[^1].Id;
|
||||
processed += batch.Count;
|
||||
|
||||
if (processed % LiveProgressEvery == 0 || DateTime.UtcNow - lastReport > TimeSpan.FromSeconds(5)) {
|
||||
lastReport = DateTime.UtcNow;
|
||||
logger.LogInformation("[4/8] Verified {Processed}/{Total} assets ({Missing} missing so far).", processed, totalLive, deletedAssets);
|
||||
JobStatus.UpdateProgress(
|
||||
(3f + (float)processed / Math.Max(totalLive, 1)) / totalSteps,
|
||||
$"Checking assets: {processed:N0}/{totalLive:N0} (missing {deletedAssets:N0})");
|
||||
}
|
||||
}
|
||||
IncrementProgress(++progress);
|
||||
|
||||
logger.LogInformation("[4/8] Assets checked: {Deleted} soft-deleted.", deletedAssets);
|
||||
ReportStep(4, $"Assets checked: {deletedAssets:N0} missing.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Step 4
|
||||
// Check for assets existence
|
||||
dbContext.Assets.Load();
|
||||
foreach (Asset asset in dbContext.Assets.Where(a => a.DeletedAt == null)) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (File.Exists(asset.OriginalPath)) continue;
|
||||
asset.DeletedAt = DateTime.UtcNow;
|
||||
dbContext.Assets.Update(asset);
|
||||
logger.LogWarning($"Asset {asset.Id} does not exist. Soft-deleting.");
|
||||
}
|
||||
|
||||
int changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Soft-deleted {Changes} non-existent assets.", changes);
|
||||
IncrementProgress(++progress);
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Step 5
|
||||
// Check for empty albums
|
||||
dbContext.Albums.Load();
|
||||
dbContext.Albums.Include(a => a.Assets)
|
||||
// Step 5 — Empty albums
|
||||
logger.LogInformation("[5/8] Checking for empty albums...");
|
||||
var emptyAlbums = await dbContext.Albums.Include(a => a.Assets)
|
||||
.Where(a => a.Assets!.Count == 0)
|
||||
.ToList().ForEach(a => {
|
||||
logger.LogWarning($"Album {a.Id} is empty. Removing from database.");
|
||||
dbContext.Albums.Remove(a);
|
||||
});
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Removed {Changes} empty albums from the database.", changes);
|
||||
IncrementProgress(++progress);
|
||||
.ToListAsync(token);
|
||||
foreach (var album in emptyAlbums) {
|
||||
logger.LogWarning("Album {AlbumId} ({Title}) is empty. Removing from database.", album.Id, album.Title);
|
||||
dbContext.Albums.Remove(album);
|
||||
}
|
||||
await dbContext.SaveChangesAsync(token);
|
||||
logger.LogInformation("[5/8] Removed {Count} empty albums.", emptyAlbums.Count);
|
||||
ReportStep(5, $"Empty albums removed: {emptyAlbums.Count:N0}.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Step 6
|
||||
// Check for orphaned thumbnails
|
||||
dbContext.Settings.Load();
|
||||
dbContext.Assets.Load();
|
||||
// Step 6 — Orphaned thumbnails (streamed via keyset pagination)
|
||||
logger.LogInformation("[6/8] Checking thumbnails...");
|
||||
await dbContext.Settings.LoadAsync(token);
|
||||
var thumbnailPath = dbContext.Settings.First(s => s.Name == Settings.ThumbnailPath.AsString()).Value;
|
||||
|
||||
int missingThumbnails;
|
||||
if (!Directory.Exists(thumbnailPath)) {
|
||||
logger.LogWarning("Thumbnail path does not exist. Creating...");
|
||||
logger.LogWarning("Thumbnail path does not exist at {Path}. Creating...", thumbnailPath);
|
||||
|
||||
try {
|
||||
if (thumbnailPath != null)
|
||||
@@ -141,31 +201,68 @@ public class IntegrityCheckJob (ILogger<IntegrityCheckJob> logger, IServiceProvi
|
||||
}
|
||||
|
||||
logger.LogWarning("Marking all assets as missing thumbnails.");
|
||||
dbContext.Assets.ForEach(a => a.ThumbnailPath = "");
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Marked {Changes} assets as missing thumbnails.", changes);
|
||||
missingThumbnails = await dbContext.Assets
|
||||
.Where(a => !string.IsNullOrEmpty(a.ThumbnailPath))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ThumbnailPath, ""), token);
|
||||
} else {
|
||||
dbContext.Assets.Where(a => !String.IsNullOrEmpty(a.ThumbnailPath)).AsEnumerable()
|
||||
.Where(a => !File.Exists(a.ThumbnailPath)).AsEnumerable()
|
||||
.ForEach(a => {
|
||||
logger.LogWarning($"Asset {a.Id} has a missing thumbnail. Marking as missing.");
|
||||
a.ThumbnailPath = "";
|
||||
dbContext.Assets.Update(a);
|
||||
});
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Marked {Changes} assets as missing thumbnails.", changes);
|
||||
}
|
||||
var totalWithThumb = await dbContext.Assets.CountAsync(a => !string.IsNullOrEmpty(a.ThumbnailPath), token);
|
||||
missingThumbnails = 0;
|
||||
Guid lastThumbId = Guid.Empty;
|
||||
long thumbProcessed = 0;
|
||||
var lastThumbReport = DateTime.UtcNow;
|
||||
|
||||
IncrementProgress(++progress);
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var batch = await dbContext.Assets.AsNoTracking()
|
||||
.Where(a => !string.IsNullOrEmpty(a.ThumbnailPath) && a.Id > lastThumbId)
|
||||
.OrderBy(a => a.Id)
|
||||
.Take(BatchSize)
|
||||
.Select(a => new { a.Id, a.ThumbnailPath })
|
||||
.ToListAsync(token);
|
||||
|
||||
if (batch.Count == 0) break;
|
||||
|
||||
var missingIds = new List<Guid>(batch.Count);
|
||||
foreach (var item in batch) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!File.Exists(item.ThumbnailPath)) {
|
||||
missingIds.Add(item.Id);
|
||||
logger.LogWarning("Asset {AssetId} has a missing thumbnail at {Path}. Marking as missing.", item.Id, item.ThumbnailPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingIds.Count > 0) {
|
||||
await dbContext.Assets
|
||||
.Where(a => missingIds.Contains(a.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ThumbnailPath, ""), token);
|
||||
missingThumbnails += missingIds.Count;
|
||||
}
|
||||
|
||||
lastThumbId = batch[^1].Id;
|
||||
thumbProcessed += batch.Count;
|
||||
|
||||
if (thumbProcessed % LiveProgressEvery == 0 || DateTime.UtcNow - lastThumbReport > TimeSpan.FromSeconds(5)) {
|
||||
lastThumbReport = DateTime.UtcNow;
|
||||
logger.LogInformation("[6/8] Verified {Processed}/{Total} thumbnails ({Missing} missing so far).", thumbProcessed, totalWithThumb, missingThumbnails);
|
||||
JobStatus.UpdateProgress(
|
||||
(5f + (float)thumbProcessed / Math.Max(totalWithThumb, 1)) / totalSteps,
|
||||
$"Checking thumbnails: {thumbProcessed:N0}/{totalWithThumb:N0} (missing {missingThumbnails:N0})");
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.LogInformation("[6/8] Thumbnails checked: {Count} marked as missing.", missingThumbnails);
|
||||
ReportStep(6, $"Thumbnails: {missingThumbnails:N0} missing.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Step 7
|
||||
// Check for orphaned previews
|
||||
|
||||
// Step 7 — Orphaned previews (streamed via keyset pagination)
|
||||
logger.LogInformation("[7/8] Checking previews...");
|
||||
var previewPath = dbContext.Settings.First(s => s.Name == Settings.PreviewPath.AsString()).Value;
|
||||
|
||||
int missingPreviews;
|
||||
if (!Directory.Exists(previewPath)) {
|
||||
logger.LogWarning("Preview path does not exist. Creating...");
|
||||
logger.LogWarning("Preview path does not exist at {Path}. Creating...", previewPath);
|
||||
|
||||
try {
|
||||
if (previewPath != null)
|
||||
@@ -175,25 +272,114 @@ public class IntegrityCheckJob (ILogger<IntegrityCheckJob> logger, IServiceProvi
|
||||
}
|
||||
|
||||
logger.LogWarning("Marking all assets as missing previews.");
|
||||
dbContext.Assets.ForEach(a => a.PreviewPath = "");
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Marked {Changes} assets as missing previews.", changes);
|
||||
missingPreviews = await dbContext.Assets
|
||||
.Where(a => !string.IsNullOrEmpty(a.PreviewPath))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.PreviewPath, ""), token);
|
||||
} else {
|
||||
dbContext.Assets.Where(a => !String.IsNullOrEmpty(a.PreviewPath)).AsEnumerable()
|
||||
.Where(a => !File.Exists(a.PreviewPath)).AsEnumerable()
|
||||
.ForEach(a => {
|
||||
logger.LogWarning($"Asset {a.Id} has a missing preview. Marking as missing.");
|
||||
a.PreviewPath = "";
|
||||
dbContext.Assets.Update(a);
|
||||
});
|
||||
var totalWithPreview = await dbContext.Assets.CountAsync(a => !string.IsNullOrEmpty(a.PreviewPath), token);
|
||||
missingPreviews = 0;
|
||||
Guid lastPreviewId = Guid.Empty;
|
||||
long previewProcessed = 0;
|
||||
var lastPreviewReport = DateTime.UtcNow;
|
||||
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var batch = await dbContext.Assets.AsNoTracking()
|
||||
.Where(a => !string.IsNullOrEmpty(a.PreviewPath) && a.Id > lastPreviewId)
|
||||
.OrderBy(a => a.Id)
|
||||
.Take(BatchSize)
|
||||
.Select(a => new { a.Id, a.PreviewPath })
|
||||
.ToListAsync(token);
|
||||
|
||||
if (batch.Count == 0) break;
|
||||
|
||||
var missingIds = new List<Guid>(batch.Count);
|
||||
foreach (var item in batch) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!File.Exists(item.PreviewPath)) {
|
||||
missingIds.Add(item.Id);
|
||||
logger.LogWarning("Asset {AssetId} has a missing preview at {Path}. Marking as missing.", item.Id, item.PreviewPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingIds.Count > 0) {
|
||||
await dbContext.Assets
|
||||
.Where(a => missingIds.Contains(a.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.PreviewPath, ""), token);
|
||||
missingPreviews += missingIds.Count;
|
||||
}
|
||||
|
||||
lastPreviewId = batch[^1].Id;
|
||||
previewProcessed += batch.Count;
|
||||
|
||||
if (previewProcessed % LiveProgressEvery == 0 || DateTime.UtcNow - lastPreviewReport > TimeSpan.FromSeconds(5)) {
|
||||
lastPreviewReport = DateTime.UtcNow;
|
||||
logger.LogInformation("[7/8] Verified {Processed}/{Total} previews ({Missing} missing so far).", previewProcessed, totalWithPreview, missingPreviews);
|
||||
JobStatus.UpdateProgress(
|
||||
(6f + (float)previewProcessed / Math.Max(totalWithPreview, 1)) / totalSteps,
|
||||
$"Checking previews: {previewProcessed:N0}/{totalWithPreview:N0} (missing {missingPreviews:N0})");
|
||||
}
|
||||
}
|
||||
}
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Marked {Changes} assets as missing previews.", changes);
|
||||
logger.LogInformation("[7/8] Previews checked: {Count} marked as missing.", missingPreviews);
|
||||
ReportStep(7, $"Previews: {missingPreviews:N0} missing.");
|
||||
|
||||
IncrementProgress(++progress);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Complete
|
||||
logger.LogInformation("Integrity check completed successfully.");
|
||||
// Step 8 — Orphaned converted videos (streamed via keyset pagination)
|
||||
logger.LogInformation("[8/8] Checking converted videos...");
|
||||
var totalWithConverted = await dbContext.Assets.CountAsync(a => !string.IsNullOrEmpty(a.ConvertedPath), token);
|
||||
int missingConverted = 0;
|
||||
Guid lastConvertedId = Guid.Empty;
|
||||
long convertedProcessed = 0;
|
||||
var lastConvertedReport = DateTime.UtcNow;
|
||||
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var batch = await dbContext.Assets.AsNoTracking()
|
||||
.Where(a => !string.IsNullOrEmpty(a.ConvertedPath) && a.Id > lastConvertedId)
|
||||
.OrderBy(a => a.Id)
|
||||
.Take(BatchSize)
|
||||
.Select(a => new { a.Id, a.ConvertedPath })
|
||||
.ToListAsync(token);
|
||||
|
||||
if (batch.Count == 0) break;
|
||||
|
||||
var missingIds = new List<Guid>(batch.Count);
|
||||
foreach (var item in batch) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!File.Exists(item.ConvertedPath)) {
|
||||
missingIds.Add(item.Id);
|
||||
logger.LogWarning("Asset {AssetId} has a missing converted video at {Path}. Marking as missing.", item.Id, item.ConvertedPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingIds.Count > 0) {
|
||||
await dbContext.Assets
|
||||
.Where(a => missingIds.Contains(a.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ConvertedPath, ""), token);
|
||||
missingConverted += missingIds.Count;
|
||||
}
|
||||
|
||||
lastConvertedId = batch[^1].Id;
|
||||
convertedProcessed += batch.Count;
|
||||
|
||||
if (convertedProcessed % LiveProgressEvery == 0 || DateTime.UtcNow - lastConvertedReport > TimeSpan.FromSeconds(5)) {
|
||||
lastConvertedReport = DateTime.UtcNow;
|
||||
logger.LogInformation("[8/8] Verified {Processed}/{Total} converted videos ({Missing} missing so far).", convertedProcessed, totalWithConverted, missingConverted);
|
||||
JobStatus.UpdateProgress(
|
||||
(7f + (float)convertedProcessed / Math.Max(totalWithConverted, 1)) / totalSteps,
|
||||
$"Checking converted videos: {convertedProcessed:N0}/{totalWithConverted:N0} (missing {missingConverted:N0})");
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("[8/8] Converted videos checked: {Count} marked as missing.", missingConverted);
|
||||
|
||||
stopwatch.Stop();
|
||||
logger.LogInformation("Integrity check completed successfully in {Elapsed}.",
|
||||
stopwatch.Elapsed.ToString(@"h\:mm\:ss"));
|
||||
JobStatus.Complete("Integrity check completed successfully.");
|
||||
} catch (OperationCanceledException) {
|
||||
JobStatus.Cancel();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for background processing jobs.
|
||||
/// </summary>
|
||||
internal static class JobHelpers {
|
||||
/// <summary>
|
||||
/// Marks an asset as broken (processing failed) so it is excluded from re-processing
|
||||
/// until an admin explicitly retries it.
|
||||
/// </summary>
|
||||
/// <param name="asset">The asset to mark broken.</param>
|
||||
/// <param name="message">A human-readable error message describing the failure.</param>
|
||||
internal static void MarkBroken(Asset asset, string message) {
|
||||
asset.ProcessFailedAt = DateTime.UtcNow;
|
||||
asset.ProcessErrorMessage = message;
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -35,10 +35,10 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
/// <summary>
|
||||
/// Returns a page of past root jobs from the database.
|
||||
/// </summary>
|
||||
public (List<JobStatusDto> Jobs, int TotalCount) GetPastRootJobs(int page, int pageSize) {
|
||||
public async Task<(List<JobStatusDto> Jobs, int TotalCount)> GetPastRootJobsAsync(int page, int pageSize, CancellationToken cancellationToken) {
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
|
||||
var records = repo.GetPastRootJobs(page, pageSize, out var total);
|
||||
var (records, total) = await repo.GetPastRootJobsAsync(page, pageSize, cancellationToken);
|
||||
logger.LogInformation("GetPastRootJobs page={Page} pageSize={PageSize}: {Count} records out of {Total}", page, pageSize, records.Count, total);
|
||||
return (records.Select(r => r.ToJobStatusDto()).ToList(), total);
|
||||
}
|
||||
@@ -49,7 +49,8 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
/// </summary>
|
||||
/// <param name="parentId">The parent job ID.</param>
|
||||
/// <param name="since">If set, only children changed after this timestamp are returned. Null returns all children.</param>
|
||||
public List<JobStatusDto> GetChildren(Guid parentId, DateTime? since) {
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
public async Task<List<JobStatusDto>> GetChildrenAsync(Guid parentId, DateTime? since, CancellationToken cancellationToken) {
|
||||
var seenIds = new HashSet<Guid>();
|
||||
|
||||
// Active children still in memory
|
||||
@@ -71,8 +72,8 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
|
||||
var records = since is null
|
||||
? repo.GetChildren(parentId)
|
||||
: repo.GetChildrenModifiedSince(parentId, since.Value);
|
||||
? await repo.GetChildrenAsync(parentId, cancellationToken)
|
||||
: await repo.GetChildrenModifiedSinceAsync(parentId, since.Value, cancellationToken);
|
||||
results.AddRange(records.Where(r => !seenIds.Contains(r.Id)).Select(r => r.ToJobStatusDto()));
|
||||
} catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Could not load children for parent {ParentId}", parentId);
|
||||
@@ -178,14 +179,14 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
}
|
||||
|
||||
///<inheritdoc />
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||
logger.LogInformation("Preparing JobManager...");
|
||||
|
||||
// Load past jobs from database on startup
|
||||
try {
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
|
||||
foreach (var record in repo.GetAll()) {
|
||||
foreach (var record in await repo.GetAllAsync(stoppingToken)) {
|
||||
pastJobs.TryAdd(record.Id, record.ToJobStatusDto());
|
||||
}
|
||||
logger.LogInformation($"Loaded {pastJobs.Count} past job(s) from database.");
|
||||
@@ -197,7 +198,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
try {
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var settingsRepo = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
|
||||
var maxJobsSetting = settingsRepo.Get(Settings.MaxConcurrentJobs.AsString());
|
||||
var maxJobsSetting = await settingsRepo.GetAsync(Settings.MaxConcurrentJobs.AsString(), stoppingToken);
|
||||
if (maxJobsSetting != null && int.TryParse(maxJobsSetting.Value, out var maxJobs) && maxJobs > 0) {
|
||||
MaxConcurrentJobs = maxJobs;
|
||||
logger.LogInformation("Loaded MaxConcurrentJobs from settings: {Value}", MaxConcurrentJobs);
|
||||
@@ -227,7 +228,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
}
|
||||
|
||||
foreach (var job in queued) {
|
||||
job.Done += (sender, status) => {
|
||||
job.Done += async (sender, status) => {
|
||||
var j = (Job)sender!;
|
||||
var dto = j.ToJobStatusDto();
|
||||
|
||||
@@ -247,7 +248,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
Progress = dto.Progress
|
||||
};
|
||||
repo.Insert(record);
|
||||
repo.Save();
|
||||
await repo.SaveAsync(CancellationToken.None);
|
||||
} catch (Exception ex) {
|
||||
logger.LogError(ex, "Failed to persist job {JobId}", status.Id);
|
||||
}
|
||||
@@ -266,6 +267,6 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
);
|
||||
|
||||
lifetime.ApplicationStarted.Register(() => service.Start());
|
||||
return service;
|
||||
await service;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +55,11 @@ public sealed class MetadataJob : Job {
|
||||
if (ParentJob == null) {
|
||||
await MasterJob(token);
|
||||
} else {
|
||||
SlaveJob(token);
|
||||
await SlaveJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
void SlaveJob(CancellationToken token) {
|
||||
async Task SlaveJob(CancellationToken token) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
@@ -84,7 +84,9 @@ public sealed class MetadataJob : Job {
|
||||
|
||||
try {
|
||||
if (string.IsNullOrEmpty(asset.OriginalPath) || !File.Exists(asset.OriginalPath)) {
|
||||
Logger.LogWarning($"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}");
|
||||
var errorMsg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
|
||||
Logger.LogWarning(errorMsg);
|
||||
JobHelpers.MarkBroken(asset, errorMsg);
|
||||
failedAssets++;
|
||||
continue;
|
||||
}
|
||||
@@ -95,12 +97,13 @@ public sealed class MetadataJob : Job {
|
||||
processedAssets++;
|
||||
} catch (Exception ex) {
|
||||
Logger.LogError(ex, $"Failed to extract metadata for asset ID {asset.Id}");
|
||||
JobHelpers.MarkBroken(asset, $"Metadata extraction failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
}
|
||||
}
|
||||
|
||||
AssetRepository.UpdateBulk(Batch);
|
||||
AssetRepository.Save();
|
||||
await AssetRepository.SaveAsync(token);
|
||||
|
||||
var msg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
||||
Logger.LogInformation(msg);
|
||||
@@ -123,11 +126,11 @@ public sealed class MetadataJob : Job {
|
||||
JobStatus.Start();
|
||||
|
||||
batchSize = 200;
|
||||
var batchSizeSetting = SettingsRepository?.Get(Settings.JobBatchSize.AsString());
|
||||
var batchSizeSetting = SettingsRepository is null ? null : await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
|
||||
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
|
||||
batchSize = Math.Max(1, parsed);
|
||||
|
||||
totalAssets = AssetRepository.CountAssetsMissingMetadata();
|
||||
totalAssets = await AssetRepository.CountAssetsMissingMetadataAsync(token);
|
||||
Logger.LogInformation("Found {Count} image assets missing metadata.", totalAssets);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
@@ -139,7 +142,7 @@ public sealed class MetadataJob : Job {
|
||||
int pending = 0;
|
||||
|
||||
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
||||
var batch = AssetRepository.GetAssetsMissingMetadata(batchSize, offset);
|
||||
var batch = await AssetRepository.GetAssetsMissingMetadataAsync(batchSize, offset, token);
|
||||
var batchList = batch.ToList();
|
||||
|
||||
if (batchList.Count == 0) continue;
|
||||
|
||||
@@ -58,11 +58,11 @@ public sealed class PHashJob : Job {
|
||||
if (ParentJob == null) {
|
||||
await MasterJob(token);
|
||||
} else {
|
||||
SlaveJob(token);
|
||||
await SlaveJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
void SlaveJob(CancellationToken token) {
|
||||
async Task SlaveJob(CancellationToken token) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
@@ -93,24 +93,28 @@ public sealed class PHashJob : Job {
|
||||
} catch (ArgumentNullException ex) {
|
||||
Logger.LogError(ex, $"Image at path {asset.OriginalPath} could not be found. Setting hash to 0.");
|
||||
asset.Hash = new BitArray(64);
|
||||
JobHelpers.MarkBroken(asset, $"pHash failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
} catch (UnknownImageFormatException ex) {
|
||||
Logger.LogError(ex, $"Image at path {asset.OriginalPath} is in an unknown or unsupported format.");
|
||||
asset.Hash = new BitArray(64);
|
||||
JobHelpers.MarkBroken(asset, $"pHash failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
} catch (InvalidImageContentException ex) {
|
||||
Logger.LogError(ex, $"Image at path {asset.OriginalPath} is corrupted or unreadable.");
|
||||
asset.Hash = new BitArray(64);
|
||||
JobHelpers.MarkBroken(asset, $"pHash failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
} catch (Exception ex) {
|
||||
Logger.LogError(ex, $"Failed to calculate pHash for asset {asset.Id} | {asset.OriginalPath}.");
|
||||
asset.Hash = new BitArray(64);
|
||||
JobHelpers.MarkBroken(asset, $"pHash failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
}
|
||||
}
|
||||
|
||||
AssetRepository.UpdateBulk(Batch);
|
||||
AssetRepository.Save();
|
||||
await AssetRepository.SaveAsync(token);
|
||||
|
||||
var msg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
||||
Logger.LogInformation(msg);
|
||||
@@ -133,11 +137,11 @@ public sealed class PHashJob : Job {
|
||||
JobStatus.Start();
|
||||
|
||||
batchSize = 200;
|
||||
var batchSizeSetting = SettingsRepository?.Get(Settings.JobBatchSize.AsString());
|
||||
var batchSizeSetting = SettingsRepository is null ? null : await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
|
||||
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
|
||||
batchSize = Math.Max(1, parsed);
|
||||
|
||||
totalAssets = AssetRepository.CountAssetsMissingPHash();
|
||||
totalAssets = await AssetRepository.CountAssetsMissingPHashAsync(token);
|
||||
Logger.LogInformation("Found {Count} assets missing pHash.", totalAssets);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
@@ -149,7 +153,7 @@ public sealed class PHashJob : Job {
|
||||
int pending = 0;
|
||||
|
||||
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
||||
var batch = AssetRepository.GetAssetsMissingPHash(batchSize, offset);
|
||||
var batch = await AssetRepository.GetAssetsMissingPHashAsync(batchSize, offset, token);
|
||||
var batchList = batch.ToList();
|
||||
|
||||
if (batchList.Count == 0) continue;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
static class PathUtils {
|
||||
internal static string PathFromGuid(Guid id, string root) {
|
||||
internal static string PathFromGuid(Guid id, string root, string extension = "webp") {
|
||||
var s = id.ToString("N");
|
||||
|
||||
return Path.Combine(
|
||||
@@ -9,7 +9,7 @@ static class PathUtils {
|
||||
s[..2],
|
||||
s.Substring(2, 2),
|
||||
s.Substring(4, 2),
|
||||
$"{s}.webp"
|
||||
$"{s}.{extension}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -82,11 +82,11 @@ public class PreviewJob : Job {
|
||||
if (ParentJob == null) {
|
||||
await MasterJob(token);
|
||||
} else {
|
||||
SlaveJob(token);
|
||||
await SlaveJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
void SlaveJob(CancellationToken token) {
|
||||
async Task SlaveJob(CancellationToken token) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
@@ -119,6 +119,7 @@ public class PreviewJob : Job {
|
||||
if (string.IsNullOrEmpty(asset.OriginalPath) || !File.Exists(asset.OriginalPath)) {
|
||||
var msg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
|
||||
Logger.LogWarning(msg);
|
||||
JobHelpers.MarkBroken(asset, msg);
|
||||
failedAssets++;
|
||||
continue;
|
||||
}
|
||||
@@ -159,12 +160,13 @@ public class PreviewJob : Job {
|
||||
processedAssets++;
|
||||
} catch (Exception ex) {
|
||||
Logger.LogError(ex, $"Failed to generate preview for asset ID {asset.Id}");
|
||||
JobHelpers.MarkBroken(asset, $"Preview generation failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
}
|
||||
}
|
||||
|
||||
AssetRepository.UpdateBulk(Batch);
|
||||
AssetRepository.Save();
|
||||
await AssetRepository.SaveAsync(token);
|
||||
|
||||
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
||||
Logger.LogInformation(resultMsg);
|
||||
@@ -187,10 +189,10 @@ public class PreviewJob : Job {
|
||||
JobStatus.Start();
|
||||
Logger.LogInformation("Starting master Preview job.");
|
||||
|
||||
var previewPathSetting = SettingsRepository.Get(Settings.PreviewPath.AsString());
|
||||
var previewSizeSetting = SettingsRepository.Get(Settings.PreviewSize.AsString());
|
||||
var previewQualitySetting = SettingsRepository.Get(Settings.PreviewQuality.AsString());
|
||||
var batchSizeSetting = SettingsRepository.Get(Settings.JobBatchSize.AsString());
|
||||
var previewPathSetting = await SettingsRepository.GetAsync(Settings.PreviewPath.AsString(), token);
|
||||
var previewSizeSetting = await SettingsRepository.GetAsync(Settings.PreviewSize.AsString(), token);
|
||||
var previewQualitySetting = await SettingsRepository.GetAsync(Settings.PreviewQuality.AsString(), token);
|
||||
var batchSizeSetting = await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
|
||||
|
||||
if (previewPathSetting == null) {
|
||||
Logger.LogError("Preview path not found. Cannot proceed.");
|
||||
@@ -214,7 +216,7 @@ public class PreviewJob : Job {
|
||||
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
|
||||
batchSize = Math.Max(1, parsed);
|
||||
|
||||
totalAssets = AssetRepository.CountAssetsMissingOrWrongPreview(previewSize, ExpectedFormat);
|
||||
totalAssets = await AssetRepository.CountAssetsMissingOrWrongPreviewAsync(previewSize, ExpectedFormat, token);
|
||||
Logger.LogInformation("Found {Count} assets needing previews.", totalAssets);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
@@ -226,7 +228,7 @@ public class PreviewJob : Job {
|
||||
int pending = 0;
|
||||
|
||||
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
||||
var batch = AssetRepository.GetAssetsMissingOrWrongPreview(previewSize, ExpectedFormat, batchSize, offset);
|
||||
var batch = await AssetRepository.GetAssetsMissingOrWrongPreviewAsync(previewSize, ExpectedFormat, batchSize, offset, token);
|
||||
var batchList = batch.ToList();
|
||||
|
||||
if (batchList.Count == 0) continue;
|
||||
|
||||
@@ -82,11 +82,11 @@ public class ThumbnailJob : Job {
|
||||
if (ParentJob == null) {
|
||||
await MasterJob(token);
|
||||
} else {
|
||||
SlaveJob(token);
|
||||
await SlaveJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
void SlaveJob(CancellationToken token) {
|
||||
async Task SlaveJob(CancellationToken token) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
@@ -119,6 +119,7 @@ public class ThumbnailJob : Job {
|
||||
if (string.IsNullOrEmpty(asset.OriginalPath) || !File.Exists(asset.OriginalPath)) {
|
||||
var msg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
|
||||
Logger.LogWarning(msg);
|
||||
JobHelpers.MarkBroken(asset, msg);
|
||||
failedAssets++;
|
||||
continue;
|
||||
}
|
||||
@@ -159,12 +160,13 @@ public class ThumbnailJob : Job {
|
||||
processedAssets++;
|
||||
} catch (Exception ex) {
|
||||
Logger.LogError(ex, $"Failed to generate thumbnail for asset ID {asset.Id}");
|
||||
JobHelpers.MarkBroken(asset, $"Thumbnail generation failed: {ex.Message}");
|
||||
failedAssets++;
|
||||
}
|
||||
}
|
||||
|
||||
AssetRepository.UpdateBulk(Batch);
|
||||
AssetRepository.Save();
|
||||
await AssetRepository.SaveAsync(token);
|
||||
|
||||
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
||||
Logger.LogInformation(resultMsg);
|
||||
@@ -187,10 +189,10 @@ public class ThumbnailJob : Job {
|
||||
JobStatus.Start();
|
||||
Logger.LogInformation("Starting master Thumbnail job.");
|
||||
|
||||
var thumbPathSetting = SettingsRepository.Get(Settings.ThumbnailPath.AsString());
|
||||
var thumbSizeSetting = SettingsRepository.Get(Settings.ThumbnailSize.AsString());
|
||||
var thumbQualitySetting = SettingsRepository.Get(Settings.ThumbnailQuality.AsString());
|
||||
var batchSizeSetting = SettingsRepository.Get(Settings.JobBatchSize.AsString());
|
||||
var thumbPathSetting = await SettingsRepository.GetAsync(Settings.ThumbnailPath.AsString(), token);
|
||||
var thumbSizeSetting = await SettingsRepository.GetAsync(Settings.ThumbnailSize.AsString(), token);
|
||||
var thumbQualitySetting = await SettingsRepository.GetAsync(Settings.ThumbnailQuality.AsString(), token);
|
||||
var batchSizeSetting = await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
|
||||
|
||||
if (thumbPathSetting == null) {
|
||||
Logger.LogError("Thumbnail path not found. Cannot proceed.");
|
||||
@@ -214,7 +216,7 @@ public class ThumbnailJob : Job {
|
||||
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
|
||||
batchSize = Math.Max(1, parsed);
|
||||
|
||||
totalAssets = AssetRepository.CountAssetsMissingOrWrongThumbnail(thumbnailSize, ExpectedFormat);
|
||||
totalAssets = await AssetRepository.CountAssetsMissingOrWrongThumbnailAsync(thumbnailSize, ExpectedFormat, token);
|
||||
Logger.LogInformation("Found {Count} assets needing thumbnails.", totalAssets);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
@@ -226,7 +228,7 @@ public class ThumbnailJob : Job {
|
||||
int pending = 0;
|
||||
|
||||
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
||||
var batch = AssetRepository.GetAssetsMissingOrWrongThumbnail(thumbnailSize, ExpectedFormat, batchSize, offset);
|
||||
var batch = await AssetRepository.GetAssetsMissingOrWrongThumbnailAsync(thumbnailSize, ExpectedFormat, batchSize, offset, token);
|
||||
var batchList = batch.ToList();
|
||||
|
||||
if (batchList.Count == 0) continue;
|
||||
|
||||
@@ -44,6 +44,7 @@ public static class AlbumMapper {
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||
MimeType = string.IsNullOrEmpty(asset.ConvertedMimeType) ? asset.MimeType : asset.ConvertedMimeType,
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
|
||||
|
||||
@@ -43,7 +43,7 @@ public static class AssetsMapper {
|
||||
/// <returns>A AssetPreviewDto object.</returns>
|
||||
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
MimeType = asset.MimeType,
|
||||
MimeType = string.IsNullOrEmpty(asset.ConvertedMimeType) ? asset.MimeType : asset.ConvertedMimeType,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
@@ -62,7 +62,10 @@ public static class AssetsMapper {
|
||||
.Distinct()
|
||||
.ToList(),
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null,
|
||||
ProcessFailedAt = accessLevel >= EAccessLevel.Curator ? asset.ProcessFailedAt : null,
|
||||
ProcessErrorMessage = accessLevel >= EAccessLevel.Curator ? asset.ProcessErrorMessage : null,
|
||||
OriginalPath = accessLevel >= EAccessLevel.Curator ? asset.OriginalPath : null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -30,8 +30,8 @@ public static class JobMapper {
|
||||
IntegrityCheckJob => EJobType.IntegrityCheck,
|
||||
PHashJob => EJobType.PHashGeneration,
|
||||
MetadataJob => EJobType.MetadataExtraction,
|
||||
CreatePersonsJob => EJobType.CreatePersons,
|
||||
CreateAlbumsJob => EJobType.CreateAlbums,
|
||||
ConvertAnimatedJob => EJobType.AnimatedConversion,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(job))
|
||||
}
|
||||
};
|
||||
|
||||
+647
@@ -0,0 +1,647 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
[DbContext(typeof(LactoseDbContext))]
|
||||
[Migration("20260818090402_AddAssetProcessFailureAndConverted")]
|
||||
partial class AddAssetProcessFailureAndConverted
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("AlbumsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AlbumsId", "AssetsId");
|
||||
|
||||
b.HasIndex("AssetsId");
|
||||
|
||||
b.ToTable("AlbumAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AssetsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConvertedFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ConvertedMimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("ConvertedPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<float?>("Duration")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<BitArray>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ProcessErrorMessage")
|
||||
.HasColumnType("VARCHAR(1024)");
|
||||
|
||||
b.Property<DateTime?>("ProcessFailedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PreviewPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ProcessFailedAt")
|
||||
.HasDatabaseName("IX_Assets_ProcessFailedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ThumbnailPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BoundingBoxX1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxX2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BasePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Folders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Options")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Album", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AlbumsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("AssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("PersonId");
|
||||
|
||||
b.Navigation("Asset");
|
||||
|
||||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Navigation("Albums");
|
||||
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAssetProcessFailureAndConverted : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ConvertedFormat",
|
||||
table: "Assets",
|
||||
type: "VARCHAR(16)",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ConvertedMimeType",
|
||||
table: "Assets",
|
||||
type: "VARCHAR(255)",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ConvertedPath",
|
||||
table: "Assets",
|
||||
type: "VARCHAR(2048)",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ProcessErrorMessage",
|
||||
table: "Assets",
|
||||
type: "VARCHAR(1024)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ProcessFailedAt",
|
||||
table: "Assets",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_ProcessFailedAt",
|
||||
table: "Assets",
|
||||
column: "ProcessFailedAt",
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_ProcessFailedAt",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ConvertedFormat",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ConvertedMimeType",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ConvertedPath",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProcessErrorMessage",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProcessFailedAt",
|
||||
table: "Assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
[DbContext(typeof(LactoseDbContext))]
|
||||
[Migration("20260819004150_AddIncrementalScanState")]
|
||||
partial class AddIncrementalScanState
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("AlbumsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AlbumsId", "AssetsId");
|
||||
|
||||
b.HasIndex("AssetsId");
|
||||
|
||||
b.ToTable("AlbumAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AssetsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<float?>("Duration")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<BitArray>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PreviewPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ThumbnailPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("FolderId", "Id")
|
||||
.HasDatabaseName("IX_Assets_FolderId_Id")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AlbumName")
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PersonName")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
|
||||
|
||||
b.HasIndex("PersonName")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
|
||||
|
||||
b.ToTable("AssetAlbumStaging");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("LastMtime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId", "Path")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
|
||||
|
||||
b.ToTable("DirectoryScanStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BoundingBoxX1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxX2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BasePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("LastDiscoveryScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastDiscoveryScannedRegex")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("LastFileScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Folders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Options")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Album", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AlbumsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Asset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany()
|
||||
.HasForeignKey("FolderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Folder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("AssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("PersonId");
|
||||
|
||||
b.Navigation("Asset");
|
||||
|
||||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Navigation("Albums");
|
||||
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIncrementalScanState : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_FolderId",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastDiscoveryScanAt",
|
||||
table: "Folders",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LastDiscoveryScannedRegex",
|
||||
table: "Folders",
|
||||
type: "VARCHAR(2048)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastFileScanAt",
|
||||
table: "Folders",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AssetAlbumStaging",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PersonName = table.Column<string>(type: "VARCHAR(255)", nullable: false),
|
||||
AlbumName = table.Column<string>(type: "VARCHAR(255)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AssetAlbumStaging", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AssetAlbumStaging_Assets_AssetId",
|
||||
column: x => x.AssetId,
|
||||
principalTable: "Assets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DirectoryScanStates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FolderId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Path = table.Column<string>(type: "VARCHAR(2048)", nullable: false),
|
||||
LastMtime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DirectoryScanStates", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DirectoryScanStates_Folders_FolderId",
|
||||
column: x => x.FolderId,
|
||||
principalTable: "Folders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_FolderId_Id",
|
||||
table: "Assets",
|
||||
columns: new[] { "FolderId", "Id" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AssetAlbumStaging_AssetId",
|
||||
table: "AssetAlbumStaging",
|
||||
column: "AssetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AssetAlbumStaging_PersonName",
|
||||
table: "AssetAlbumStaging",
|
||||
column: "PersonName");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DirectoryScanStates_FolderId_Path",
|
||||
table: "DirectoryScanStates",
|
||||
columns: new[] { "FolderId", "Path" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AssetAlbumStaging");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DirectoryScanStates");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_FolderId_Id",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastDiscoveryScanAt",
|
||||
table: "Folders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastDiscoveryScannedRegex",
|
||||
table: "Folders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastFileScanAt",
|
||||
table: "Folders");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_FolderId",
|
||||
table: "Assets",
|
||||
column: "FolderId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
[DbContext(typeof(LactoseDbContext))]
|
||||
[Migration("20260819224929_AddAssetIngestedAt")]
|
||||
partial class AddAssetIngestedAt
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("AlbumsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AlbumsId", "AssetsId");
|
||||
|
||||
b.HasIndex("AssetsId");
|
||||
|
||||
b.ToTable("AlbumAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AssetsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<float?>("Duration")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<BitArray>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<DateTime>("IngestedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PreviewPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ThumbnailPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("FolderId", "IngestedAt", "Id")
|
||||
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AlbumName")
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PersonName")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
|
||||
|
||||
b.HasIndex("PersonName")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
|
||||
|
||||
b.ToTable("AssetAlbumStaging");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("LastMtime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId", "Path")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
|
||||
|
||||
b.ToTable("DirectoryScanStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BoundingBoxX1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxX2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BasePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("LastDiscoveryScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastDiscoveryScannedRegex")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("LastFileScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Folders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Options")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Album", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AlbumsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Asset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany()
|
||||
.HasForeignKey("FolderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Folder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("AssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("PersonId");
|
||||
|
||||
b.Navigation("Asset");
|
||||
|
||||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Navigation("Albums");
|
||||
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAssetIngestedAt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_FolderId_Id",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "IngestedAt",
|
||||
table: "Assets",
|
||||
type: "timestamp with time zone",
|
||||
nullable: false,
|
||||
defaultValueSql: "now()");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_FolderId_IngestedAt_Id",
|
||||
table: "Assets",
|
||||
columns: new[] { "FolderId", "IngestedAt", "Id" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_FolderId_IngestedAt_Id",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IngestedAt",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_FolderId_Id",
|
||||
table: "Assets",
|
||||
columns: new[] { "FolderId", "Id" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
[DbContext(typeof(LactoseDbContext))]
|
||||
[Migration("20260819235054_BackfillAssetIngestedAt")]
|
||||
partial class BackfillAssetIngestedAt
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("AlbumsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AlbumsId", "AssetsId");
|
||||
|
||||
b.HasIndex("AssetsId");
|
||||
|
||||
b.ToTable("AlbumAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AssetsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<float?>("Duration")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<BitArray>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<DateTime>("IngestedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Hash")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("PreviewPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ThumbnailPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("FolderId", "IngestedAt", "Id")
|
||||
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AlbumName")
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PersonName")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
|
||||
|
||||
b.HasIndex("PersonName")
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
|
||||
|
||||
b.ToTable("AssetAlbumStaging");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("LastMtime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId", "Path")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
|
||||
|
||||
b.ToTable("DirectoryScanStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BoundingBoxX1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxX2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BasePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("LastDiscoveryScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastDiscoveryScannedRegex")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("LastFileScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Folders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Options")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Album", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AlbumsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Asset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany()
|
||||
.HasForeignKey("FolderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Folder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("AssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("PersonId");
|
||||
|
||||
b.Navigation("Asset");
|
||||
|
||||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Navigation("Albums");
|
||||
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BackfillAssetIngestedAt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Intentionally a no-op. The IngestedAt backfill was removed because a single UPDATE on the
|
||||
// large Assets table exceeds the connection command timeout and crashes the app on startup.
|
||||
// IngestedAt remains populated (set to now() when the column was added); the next discovery
|
||||
// run will process the library once, after which incremental scans resume normally.
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,18 @@ namespace Lactose.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConvertedFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ConvertedMimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("ConvertedPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -123,6 +135,9 @@ namespace Lactose.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<DateTime>("IngestedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
@@ -146,6 +161,12 @@ namespace Lactose.Migrations
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ProcessErrorMessage")
|
||||
.HasColumnType("VARCHAR(1024)");
|
||||
|
||||
b.Property<DateTime?>("ProcessFailedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -181,8 +202,6 @@ namespace Lactose.Migrations
|
||||
.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");
|
||||
@@ -200,6 +219,10 @@ namespace Lactose.Migrations
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ProcessFailedAt")
|
||||
.HasDatabaseName("IX_Assets_ProcessFailedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("ThumbnailPath")
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
@@ -218,6 +241,10 @@ namespace Lactose.Migrations
|
||||
.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");
|
||||
@@ -225,6 +252,58 @@ namespace Lactose.Migrations
|
||||
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")
|
||||
@@ -277,6 +356,15 @@ namespace Lactose.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("LastDiscoveryScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastDiscoveryScannedRegex")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("LastFileScanAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
@@ -543,6 +631,28 @@ namespace Lactose.Migrations
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Asset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany()
|
||||
.HasForeignKey("FolderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Folder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
|
||||
@@ -69,6 +69,26 @@ public class Asset {
|
||||
[Column(TypeName = "VARCHAR(16)")]
|
||||
public string PreviewFormat { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path to the converted video for animated assets (e.g. GIF → mp4).
|
||||
/// Empty means no converted video has been generated.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(2048)")]
|
||||
public string ConvertedPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the converted video (e.g. "video/mp4").
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(255)")]
|
||||
public string ConvertedMimeType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the container format of the converted video (e.g. "mp4").
|
||||
/// Used to detect when conversion needs to be redone due to format changes.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(16)")]
|
||||
public string ConvertedFormat { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the type of the asset.
|
||||
/// </summary>
|
||||
@@ -101,6 +121,26 @@ public class Asset {
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when processing of this asset last failed.
|
||||
/// Non-null means the asset is marked broken and excluded from re-processing until manually retried.
|
||||
/// </summary>
|
||||
public DateTime? ProcessFailedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error message recorded when the asset was last marked broken.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(1024)")]
|
||||
public string? ProcessErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when this asset was ingested into the system.
|
||||
/// Unlike <see cref="CreatedAt"/> (the file's on-disk creation time), this reflects when the
|
||||
/// asset was first inserted into the database, which is what incremental discovery scans use as their cursor.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required DateTime IngestedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Lactose.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Temporary staging row produced by the person/album discovery job.
|
||||
/// Records a matched asset together with the person and (optional) album name extracted from its path.
|
||||
/// The table is truncated at the start and end of each discovery run.
|
||||
/// </summary>
|
||||
public class AssetAlbumStaging {
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for the staging row.
|
||||
/// </summary>
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the matched asset.
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(Asset))]
|
||||
public Guid AssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the normalized person name extracted from the asset path.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(255)")]
|
||||
public string PersonName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the normalized album name extracted from the asset path.
|
||||
/// Null when the folder's regex has no <c>group</c> group or the group did not match.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(255)")]
|
||||
public string? AlbumName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the matched asset.
|
||||
/// </summary>
|
||||
public Asset? Asset { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Lactose.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the last-known filesystem modification timestamp of a scanned directory.
|
||||
/// Used by the file-system crawl job to skip directories whose mtime is unchanged since the last scan.
|
||||
/// </summary>
|
||||
public class DirectoryScanState {
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for the scan-state entry.
|
||||
/// </summary>
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the folder this directory belongs to.
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(Folder))]
|
||||
public Guid FolderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the full filesystem path of the scanned directory.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(2048)")]
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last-known <c>LastWriteTimeUtc</c> of the directory.
|
||||
/// </summary>
|
||||
public DateTime LastMtime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the folder this directory belongs to.
|
||||
/// </summary>
|
||||
public Folder? Folder { get; set; }
|
||||
}
|
||||
@@ -32,6 +32,25 @@ public class Folder {
|
||||
[MaxLength(2048)]
|
||||
public string? RegexPattern { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp of the last file-system scan of this folder.
|
||||
/// Used by incremental scans to skip directories whose mtime is unchanged.
|
||||
/// </summary>
|
||||
public DateTime? LastFileScanAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp of the last person/album discovery scan of this folder.
|
||||
/// Incremental discovery only processes assets created at or after this timestamp.
|
||||
/// </summary>
|
||||
public DateTime? LastDiscoveryScanAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the regex pattern used during the last discovery scan.
|
||||
/// When this differs from <see cref="RegexPattern"/>, discovery automatically re-runs as a full (deep) scan.
|
||||
/// </summary>
|
||||
[Column(TypeName = "VARCHAR(2048)")]
|
||||
public string? LastDiscoveryScannedRegex { get; set; }
|
||||
|
||||
#region Navigation Properties
|
||||
|
||||
/// <summary>
|
||||
|
||||
+7
-5
@@ -117,11 +117,12 @@ string host = builder.Configuration["DatabaseAddress:Host"] ?? "localhost";
|
||||
string port = builder.Configuration["DatabaseAddress:Port"] ?? "3306";
|
||||
|
||||
var strBuilder = new NpgsqlConnectionStringBuilder() {
|
||||
Host = host,
|
||||
Port = int.Parse(port),
|
||||
Username = user,
|
||||
Password = password,
|
||||
Database = database
|
||||
Host = host,
|
||||
Port = int.Parse(port),
|
||||
Username = user,
|
||||
Password = password,
|
||||
Database = database,
|
||||
CommandTimeout = 120
|
||||
};
|
||||
|
||||
builder.Services.AddDbContext<LactoseDbContext>(
|
||||
@@ -161,6 +162,7 @@ builder.Services.AddTransient<IPersonRepository, PersonRepository>();
|
||||
builder.Services.AddTransient<IMediaRepository, MediaRepository>();
|
||||
builder.Services.AddTransient<IJobRecordRepository, JobRecordRepository>();
|
||||
builder.Services.AddTransient<IStatsRepository, StatsRepository>();
|
||||
builder.Services.AddTransient<IFilesystemBrowseService, FilesystemBrowseService>();
|
||||
builder.Services.AddTransient<ITokenService, TokenService>();
|
||||
builder.Services.AddTransient<LactoseAuthService>();
|
||||
builder.Services.AddSingleton<JobManager>();
|
||||
|
||||
@@ -4,6 +4,7 @@ using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Mapper;
|
||||
using Lactose.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -27,7 +28,7 @@ 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, Guid? personOwnerId = null) {
|
||||
public async Task<List<AlbumPreviewDto>> SearchQueryAsync(string query, int page, int pageSize, string? sortBy, bool sortAsc, bool unassigned, Guid userId, EAccessLevel accessLevel, Guid? uploadedBy, Guid? personOwnerId, CancellationToken cancellationToken) {
|
||||
IQueryable<Album> albumsQuery = context.Albums.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
@@ -62,31 +63,31 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
};
|
||||
|
||||
var pageOffset = page * pageSize;
|
||||
var albumIds = ordered
|
||||
var albumIds = await ordered
|
||||
.Skip(pageOffset)
|
||||
.Take(pageSize)
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (albumIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var pagedAlbums = context.Albums
|
||||
var pagedAlbums = await context.Albums
|
||||
.AsNoTracking()
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.CoverAsset)
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Compute visible asset count per album at the DB level
|
||||
Dictionary<Guid, int> assetCounts;
|
||||
if (accessLevel >= EAccessLevel.Curator) {
|
||||
assetCounts = context.Albums
|
||||
assetCounts = await context.Albums
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, Count = a.Assets!.Count() })
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken);
|
||||
} else {
|
||||
assetCounts = context.Albums
|
||||
assetCounts = await context.Albums
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.Select(a => new {
|
||||
a.Id,
|
||||
@@ -96,7 +97,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
))
|
||||
})
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken);
|
||||
}
|
||||
|
||||
var dtos = pagedAlbums.Select(a => {
|
||||
@@ -108,25 +109,34 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
var orderMap = albumIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save() => context.SaveChanges();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? Find(Guid id) => context.Albums.FirstOrDefault(a => a.Id == id);
|
||||
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? FindWithAssets(Guid id) => context.Albums
|
||||
public async Task<TResult?> ExecuteInTransactionAsync<TResult>(Func<Task<TResult?>> operation) {
|
||||
await using var tx = await context.Database.BeginTransactionAsync();
|
||||
var result = await operation();
|
||||
await tx.CommitAsync();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Album?> FindAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
context.Albums.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Album?> FindWithAssetsAsync(Guid id, CancellationToken cancellationToken) => context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Assets)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||
var album = context.Albums
|
||||
public async Task<Album?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
|
||||
var album = await context.Albums
|
||||
.AsNoTracking()
|
||||
.Include(a => a.PersonOwner)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
|
||||
if (album == null) return null;
|
||||
|
||||
@@ -134,8 +144,8 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
bool canSeeAlbum = accessLevel switch {
|
||||
< EAccessLevel.Maintainer => album.Visibility <= EVisibility.Protected,
|
||||
EAccessLevel.Maintainer when userId.HasValue => album.Visibility <= EVisibility.Protected
|
||||
|| (album.PersonOwnerId.HasValue && context.PersonMaintainers.Any(pm =>
|
||||
pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value)),
|
||||
|| (album.PersonOwnerId.HasValue && await context.PersonMaintainers.AnyAsync(pm =>
|
||||
pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value, cancellationToken)),
|
||||
EAccessLevel.Maintainer => album.Visibility <= EVisibility.Protected,
|
||||
_ => true
|
||||
};
|
||||
@@ -151,7 +161,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
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)
|
||||
&& await context.PersonMaintainers.AnyAsync(pm => pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value, cancellationToken)
|
||||
=> assetsQuery.Where(a => a.DeletedAt == null),
|
||||
_ => assetsQuery.Where(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
@@ -160,7 +170,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
))
|
||||
};
|
||||
|
||||
album.VisibleAssetPreviews = assetsQuery
|
||||
album.VisibleAssetPreviews = await assetsQuery
|
||||
.OrderBy(a => a.OriginalFilename)
|
||||
.Select(a => new AlbumAssetPreviewDto {
|
||||
Id = a.Id,
|
||||
@@ -168,38 +178,53 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
ResolutionHeight = a.ResolutionHeight,
|
||||
HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "",
|
||||
HasPreview = a.PreviewPath != null && a.PreviewPath != "",
|
||||
MimeType = a.ConvertedMimeType != null && a.ConvertedMimeType != "" ? a.ConvertedMimeType : a.MimeType,
|
||||
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();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return album;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Album> FindByPerson(Guid personId) => context.Albums.Where(a => a.PersonOwnerId == personId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Album> FindBulk(IEnumerable<Guid> ids) => context.Albums.Where(a => ids.Contains(a.Id));
|
||||
public Task<List<Album>> FindByPersonAsync(Guid personId, CancellationToken cancellationToken) =>
|
||||
context.Albums.Where(a => a.PersonOwnerId == personId).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to)
|
||||
=> context.Albums.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
|
||||
public Task<List<Album>> FindByPersonIdsAsync(IEnumerable<Guid> personIds, CancellationToken cancellationToken) =>
|
||||
context.Albums.Where(a => a.PersonOwnerId != null && personIds.Contains(a.PersonOwnerId.Value)).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void MergeAlbums(Guid destId, List<Guid> sourceIds) {
|
||||
var dest = context.Albums
|
||||
public Task<int> BulkSetVisibilityByPersonIdsAsync(IEnumerable<Guid> personIds, EVisibility visibility, CancellationToken cancellationToken) =>
|
||||
context.Albums
|
||||
.Where(a => a.PersonOwnerId != null && personIds.Contains(a.PersonOwnerId.Value))
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(a => a.Visibility, visibility)
|
||||
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<Album>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken) =>
|
||||
context.Albums.Where(a => ids.Contains(a.Id)).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<Album>> FindByDateRangeAsync(DateTime from, DateTime to, CancellationToken cancellationToken)
|
||||
=> context.Albums.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task MergeAlbumsAsync(Guid destId, List<Guid> sourceIds, CancellationToken cancellationToken) {
|
||||
var dest = await context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Assets)
|
||||
.FirstOrDefault(a => a.Id == destId);
|
||||
.FirstOrDefaultAsync(a => a.Id == destId, cancellationToken);
|
||||
if (dest == null) return;
|
||||
|
||||
var sources = context.Albums
|
||||
var sources = await context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Assets)
|
||||
.Where(a => sourceIds.Contains(a.Id))
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var existingAssetIds = dest.Assets?.Select(a => a.Id).ToHashSet() ?? [];
|
||||
|
||||
@@ -216,7 +241,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Remove(Album album) => context.Albums.Remove(album);
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => context.Dispose();
|
||||
}
|
||||
|
||||
@@ -8,68 +8,70 @@ using Microsoft.Extensions.Logging;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
using System.Collections;
|
||||
using System.Data;
|
||||
using System.Text;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository> logger) : IAssetRepository {
|
||||
|
||||
/// <inheritdoc />
|
||||
public Asset? Find(Guid id) => context.Assets.Find(id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||
public async Task<Asset?> FindAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
await context.Assets.FindAsync([id], cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Asset?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
|
||||
IQueryable<Asset> query = context.Assets
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Uploader)
|
||||
.Where(a => a.Id == id);
|
||||
|
||||
return accessLevel switch {
|
||||
>= EAccessLevel.Admin => query.FirstOrDefault(),
|
||||
EAccessLevel.Curator => query.FirstOrDefault(a =>
|
||||
a.DeletedAt == null || a.UploadedBy == userId),
|
||||
>= EAccessLevel.Admin => query.FirstOrDefaultAsync(cancellationToken),
|
||||
EAccessLevel.Curator => query.FirstOrDefaultAsync(a =>
|
||||
a.DeletedAt == null || a.UploadedBy == userId, cancellationToken),
|
||||
EAccessLevel.Maintainer when userId.HasValue => query
|
||||
.Include(a => a.Albums)
|
||||
.FirstOrDefault(a => a.DeletedAt == null && (
|
||||
.FirstOrDefaultAsync(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
a.Visibility == EVisibility.Protected ||
|
||||
a.UploadedBy == userId ||
|
||||
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
|
||||
context.PersonMaintainers.Any(pm =>
|
||||
pm.UserId == userId.Value && pm.PersonId == al.PersonOwnerId.Value))
|
||||
)),
|
||||
_ => query.FirstOrDefault(a =>
|
||||
), cancellationToken),
|
||||
_ => query.FirstOrDefaultAsync(a =>
|
||||
a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(a.Visibility == EVisibility.Private && userId.HasValue && a.UploadedBy == userId)
|
||||
))
|
||||
), cancellationToken)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Asset? FindWithAlbums(Guid id) => context.Assets
|
||||
public Task<Asset?> FindWithAlbumsAsync(Guid id, CancellationToken cancellationToken) => context.Assets
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to) =>
|
||||
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
|
||||
public Task<List<Asset>> FindByDateRangeAsync(DateTime from, DateTime to, CancellationToken cancellationToken) =>
|
||||
context.Assets.AsNoTracking().Where(x => x.CreatedAt >= from && x.UpdatedAt <= to).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to, int pageNumber, int pageSize) =>
|
||||
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to)
|
||||
public Task<List<Asset>> FindByDateRangeAsync(DateTime from, DateTime to, int pageNumber, int pageSize, CancellationToken cancellationToken) =>
|
||||
context.Assets.AsNoTracking().Where(x => x.CreatedAt >= from && x.UpdatedAt <= to)
|
||||
.Skip(pageNumber * pageSize)
|
||||
.Take(pageSize);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save() => context.SaveChanges();
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Insert(Asset asset) {
|
||||
if(!context.Assets.Any(x => x.OriginalPath == asset.OriginalPath))
|
||||
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InsertAsync(Asset asset, CancellationToken cancellationToken) {
|
||||
if(!await context.Assets.AnyAsync(x => x.OriginalPath == asset.OriginalPath, cancellationToken))
|
||||
context.Assets.Add(asset);
|
||||
else
|
||||
throw new DuplicateNameException("The required asset already exists in the database.");
|
||||
@@ -90,95 +92,186 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> FindByUploader(Guid uploaderId) => context.Assets.Where(a => a.UploadedBy == uploaderId);
|
||||
public Task<List<Asset>> FindByUploaderAsync(Guid uploaderId, CancellationToken cancellationToken) =>
|
||||
context.Assets.AsNoTracking().Where(a => a.UploadedBy == uploaderId).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids) => context.Assets
|
||||
public Task<int> BulkSetVisibilityByAlbumIdsAsync(IEnumerable<Guid> albumIds, EVisibility visibility, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
|
||||
// Scope the update to assets the caller is permitted to modify (R2). Curators/admins
|
||||
// see all non-deleted assets; maintainers additionally respect their uploads and
|
||||
// the albums of people they maintain.
|
||||
var query = context.Assets
|
||||
.Where(a => a.DeletedAt == null && a.Albums!.Any(al => albumIds.Contains(al.Id)));
|
||||
|
||||
query = accessLevel switch {
|
||||
EAccessLevel.Admin or EAccessLevel.Curator => query,
|
||||
EAccessLevel.Maintainer when userId.HasValue => query.Where(a =>
|
||||
a.Visibility == EVisibility.Public ||
|
||||
a.Visibility == EVisibility.Protected ||
|
||||
a.UploadedBy == userId ||
|
||||
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
|
||||
context.PersonMaintainers.Any(pm =>
|
||||
pm.UserId == userId && pm.PersonId == al.PersonOwnerId.Value))),
|
||||
_ => query.Where(a => false)
|
||||
};
|
||||
|
||||
return query.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(a => a.Visibility, visibility)
|
||||
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> BulkSetVisibilityByPersonIdsAsync(IEnumerable<Guid> personIds, EVisibility visibility, CancellationToken cancellationToken) =>
|
||||
context.Assets
|
||||
.Where(a => a.DeletedAt == null && a.Albums!.Any(al =>
|
||||
al.PersonOwnerId.HasValue && personIds.Contains(al.PersonOwnerId.Value)))
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(a => a.Visibility, visibility)
|
||||
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<Asset>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken) => context.Assets
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums)
|
||||
.Where(a => ids.Contains(a.Id));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Asset? FindByPath(string path) => context.Assets.FirstOrDefault(a => a.OriginalPath == path);
|
||||
.Where(a => ids.Contains(a.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int CountAssetsMissingPHash() {
|
||||
public Task<Asset?> FindByPathAsync(string path, CancellationToken cancellationToken) =>
|
||||
context.Assets.AsNoTracking().FirstOrDefaultAsync(a => a.OriginalPath == path, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountAssetsMissingPHashAsync(CancellationToken cancellationToken) {
|
||||
var empty = new BitArray(64);
|
||||
return context.Assets.Count(a => a.Hash == empty && a.DeletedAt == null);
|
||||
return context.Assets.CountAsync(a => a.Hash == empty && a.DeletedAt == null && a.ProcessFailedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetAssetsMissingPHash(int limit, int offset) {
|
||||
public Task<List<Asset>> GetAssetsMissingPHashAsync(int limit, int offset, CancellationToken cancellationToken) {
|
||||
var empty = new BitArray(64);
|
||||
return context.Assets.Where(a => a.Hash == empty && a.DeletedAt == null)
|
||||
return context.Assets.Where(a => a.Hash == empty && a.DeletedAt == null && a.ProcessFailedAt == null)
|
||||
.OrderBy(a => a.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit);
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<List<Asset>> GetDuplicates() => context.Assets
|
||||
public Task<List<List<Asset>>> GetDuplicatesAsync(CancellationToken cancellationToken) => context.Assets
|
||||
.GroupBy(a => new { a.Hash })
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.ToList());
|
||||
|
||||
.Select(g => g.ToList())
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, int distance) {
|
||||
if(distance < 0 || distance > 63)
|
||||
throw new ArgumentOutOfRangeException(nameof(distance), "Distance must be between 0 and 64.");
|
||||
|
||||
return context.Assets
|
||||
public async Task<List<Asset>> GetWithinHammingDistanceAsync(ulong phash, float distance, CancellationToken cancellationToken) {
|
||||
if(distance < 0 || distance > 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(distance), "Distance must be between 0 and 1.");
|
||||
|
||||
int intDistance = (int)(distance * 64);
|
||||
if(intDistance < 0 || intDistance > 63)
|
||||
throw new ArgumentOutOfRangeException(nameof(distance), "Distance must be between 0 and 1.");
|
||||
|
||||
return await context.Assets
|
||||
.OrderBy(a => a.HammingDistance(phash))
|
||||
.Where(a => a.Hash.HammingDistance(phash) <= distance);
|
||||
.Where(a => a.Hash.HammingDistance(phash) <= intDistance)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, float distance)
|
||||
=> GetWithinHammingDistance(phash, (int)(distance * 64));
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetAssetsMissingThumbnail(out int count) {
|
||||
count = context.Assets.Count(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null);
|
||||
return context.Assets.Where(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null);
|
||||
public async Task<(List<Asset> Assets, int Total)> GetAssetsMissingThumbnailAsync(CancellationToken cancellationToken) {
|
||||
var total = await context.Assets.CountAsync(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif", cancellationToken);
|
||||
var assets = await context.Assets.Where(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif").ToListAsync(cancellationToken);
|
||||
return (assets, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int CountAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat) =>
|
||||
context.Assets.Count(a => (a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize || a.ThumbnailFormat != expectedFormat) && a.DeletedAt == null);
|
||||
public Task<int> CountAssetsMissingOrWrongThumbnailAsync(int thumbnailSize, string expectedFormat, CancellationToken cancellationToken) =>
|
||||
context.Assets.CountAsync(a => (a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize || a.ThumbnailFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif", cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat, int limit, int offset) =>
|
||||
public Task<List<Asset>> GetAssetsMissingOrWrongThumbnailAsync(int thumbnailSize, string expectedFormat, int limit, int offset, CancellationToken cancellationToken) =>
|
||||
context.Assets
|
||||
.Where(a => (a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize || a.ThumbnailFormat != expectedFormat) && a.DeletedAt == null)
|
||||
.Where(a => (a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize || a.ThumbnailFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif")
|
||||
.OrderBy(a => a.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit);
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int CountAssetsMissingOrWrongPreview(int previewSize, string expectedFormat) =>
|
||||
context.Assets.Count(a => (a.PreviewPath == null || a.PreviewPath == "" || a.PreviewSize != previewSize || a.PreviewFormat != expectedFormat) && a.DeletedAt == null);
|
||||
public Task<int> CountAssetsMissingOrWrongPreviewAsync(int previewSize, string expectedFormat, CancellationToken cancellationToken) =>
|
||||
context.Assets.CountAsync(a => (a.PreviewPath == null || a.PreviewPath == "" || a.PreviewSize != previewSize || a.PreviewFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif", cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetAssetsMissingOrWrongPreview(int previewSize, string expectedFormat, int limit, int offset) =>
|
||||
public Task<List<Asset>> GetAssetsMissingOrWrongPreviewAsync(int previewSize, string expectedFormat, int limit, int offset, CancellationToken cancellationToken) =>
|
||||
context.Assets
|
||||
.Where(a => (a.PreviewPath == null || a.PreviewPath == "" || a.PreviewSize != previewSize || a.PreviewFormat != expectedFormat) && a.DeletedAt == null)
|
||||
.Where(a => (a.PreviewPath == null || a.PreviewPath == "" || a.PreviewSize != previewSize || a.PreviewFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif")
|
||||
.OrderBy(a => a.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int CountAssetsMissingMetadata() =>
|
||||
context.Assets.Count(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null);
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetAssetsMissingMetadata(int limit, int offset) =>
|
||||
context.Assets.Where(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null)
|
||||
public Task<int> CountAssetsMissingMetadataAsync(CancellationToken cancellationToken) =>
|
||||
context.Assets.CountAsync(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null && a.ProcessFailedAt == null, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<Asset>> GetAssetsMissingMetadataAsync(int limit, int offset, CancellationToken cancellationToken) =>
|
||||
context.Assets.Where(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null && a.ProcessFailedAt == null)
|
||||
.OrderBy(a => a.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit);
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
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) {
|
||||
public Task<int> CountAssetsNeedingConversionAsync(CancellationToken cancellationToken) =>
|
||||
context.Assets.CountAsync(a => a.MimeType == "image/gif" && a.DeletedAt == null && a.ProcessFailedAt == null && (
|
||||
a.ConvertedPath == null || a.ConvertedPath == "" ||
|
||||
a.ThumbnailPath == null || a.ThumbnailPath == ""), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<Asset>> GetAssetsNeedingConversionAsync(int limit, int offset, CancellationToken cancellationToken) =>
|
||||
context.Assets
|
||||
.Where(a => a.MimeType == "image/gif" && a.DeletedAt == null && a.ProcessFailedAt == null && (
|
||||
a.ConvertedPath == null || a.ConvertedPath == "" ||
|
||||
a.ThumbnailPath == null || a.ThumbnailPath == ""))
|
||||
.OrderBy(a => a.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountBrokenAssetsAsync(CancellationToken cancellationToken) =>
|
||||
context.Assets.CountAsync(a => a.ProcessFailedAt != null && a.DeletedAt == null, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(List<AssetPreviewDto> Assets, int Total)> GetBrokenAssetsAsync(int page, int pageSize, CancellationToken cancellationToken) {
|
||||
var total = await CountBrokenAssetsAsync(cancellationToken);
|
||||
|
||||
var assets = await context.Assets
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums!).ThenInclude(al => al.PersonOwner)
|
||||
.Where(a => a.ProcessFailedAt != null && a.DeletedAt == null)
|
||||
.OrderBy(a => a.ProcessFailedAt)
|
||||
.Skip(page * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (assets.Select(a => a.ToAssetPreviewDto(EAccessLevel.Admin, null)).ToList(), total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ClearProcessErrorAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken) {
|
||||
var assets = await context.Assets.Where(a => ids.Contains(a.Id)).ToListAsync(cancellationToken);
|
||||
foreach (var asset in assets) {
|
||||
asset.ProcessFailedAt = null;
|
||||
asset.ProcessErrorMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(List<AssetPreviewDto> Assets, int Total)> GetAssetsAsync(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, Guid? userId, EAccessLevel accessLevel, bool unlinked, Guid? folderId, Guid? uploadedBy, string? search, bool includeCount, bool deletedOnly, Guid? albumId, Guid? personId, CancellationToken cancellationToken) {
|
||||
var query = context.Assets.AsNoTracking().AsQueryable();
|
||||
if (type.HasValue)
|
||||
query = query.Where(a => a.Type == type.Value);
|
||||
@@ -188,6 +281,12 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
query = query.Where(a => a.UpdatedAt <= to.Value);
|
||||
if (unlinked)
|
||||
query = query.Where(a => a.Albums!.Count == 0);
|
||||
if (deletedOnly)
|
||||
query = query.Where(a => a.DeletedAt != null);
|
||||
if (albumId.HasValue)
|
||||
query = query.Where(a => a.Albums!.Any(al => al.Id == albumId.Value));
|
||||
if (personId.HasValue)
|
||||
query = query.Where(a => a.Albums!.Any(al => al.PersonOwnerId == personId.Value));
|
||||
if (folderId.HasValue)
|
||||
query = query.Where(a => a.FolderId == folderId.Value);
|
||||
if (uploadedBy.HasValue)
|
||||
@@ -214,7 +313,7 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
))
|
||||
};
|
||||
|
||||
total = includeCount ? query.Count() : 0;
|
||||
var total = includeCount ? await query.CountAsync(cancellationToken) : 0;
|
||||
|
||||
// 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.
|
||||
@@ -224,20 +323,20 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
false => query.OrderByDescending(a => a.CreatedAt)
|
||||
};
|
||||
|
||||
var pageIds = ordered
|
||||
var pageIds = await ordered
|
||||
.Skip(pageNumber * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (pageIds.Count == 0)
|
||||
return [];
|
||||
return ([], total);
|
||||
|
||||
var assets = query
|
||||
var assets = await query
|
||||
.Where(a => pageIds.Contains(a.Id))
|
||||
.Select(a => new AssetPreviewDto {
|
||||
Id = a.Id,
|
||||
MimeType = a.MimeType,
|
||||
MimeType = a.ConvertedMimeType != null && a.ConvertedMimeType != "" ? a.ConvertedMimeType : a.MimeType,
|
||||
ResolutionWidth = a.ResolutionWidth,
|
||||
ResolutionHeight = a.ResolutionHeight,
|
||||
HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "",
|
||||
@@ -246,14 +345,14 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? a.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || a.UploadedBy == userId ? a.DeletedAt : null
|
||||
})
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Album and cosplayer names per asset via a single grouped query
|
||||
var albumLinks = (from a in context.Assets
|
||||
var albumLinks = await (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();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var linksByAsset = albumLinks
|
||||
.GroupBy(x => x.AssetId)
|
||||
@@ -268,26 +367,26 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
}
|
||||
|
||||
var orderMap = pageIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
return [.. assets.OrderBy(a => orderMap.GetValueOrDefault(a.Id))];
|
||||
return ([.. assets.OrderBy(a => orderMap.GetValueOrDefault(a.Id))], total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => context.Dispose();
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<string> GetRandomPathsByFolder(Guid folderId, int count) =>
|
||||
context.Assets
|
||||
public Task<List<string>> GetRandomPathsByFolderAsync(Guid folderId, int count, CancellationToken cancellationToken) =>
|
||||
context.Assets.AsNoTracking()
|
||||
.Where(a => a.FolderId == folderId && a.DeletedAt == null)
|
||||
.OrderBy(_ => Guid.NewGuid())
|
||||
.Take(count)
|
||||
.Select(a => a.OriginalPath)
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<Butter.Dtos.Asset.AssetGroupDto> GetFolderGroups(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly = true) {
|
||||
public async Task<List<Butter.Dtos.Asset.AssetGroupDto>> GetFolderGroupsAsync(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly, CancellationToken cancellationToken) {
|
||||
logger.LogTrace("GetFolderGroups: userId={UserId}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", userId, accessLevel, unlinkedOnly);
|
||||
|
||||
var query = context.Assets
|
||||
var query = context.Assets.AsNoTracking()
|
||||
.Where(a => a.DeletedAt == null);
|
||||
|
||||
if (unlinkedOnly)
|
||||
@@ -295,7 +394,7 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
|
||||
if (accessLevel < EAccessLevel.Curator) {
|
||||
if (accessLevel == EAccessLevel.Maintainer && userId.HasValue) {
|
||||
var count = query.Count(a => a.UploadedBy == userId);
|
||||
var count = await query.CountAsync(a => a.UploadedBy == userId, cancellationToken);
|
||||
return [new Butter.Dtos.Asset.AssetGroupDto {
|
||||
FolderId = null,
|
||||
Name = "My Uploads",
|
||||
@@ -305,17 +404,17 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
return [];
|
||||
}
|
||||
|
||||
var groups = query
|
||||
var groups = await query
|
||||
.GroupBy(a => a.FolderId)
|
||||
.Select(g => new {
|
||||
FolderId = g.Key,
|
||||
AssetCount = g.Count()
|
||||
})
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var folderNames = context.Folders
|
||||
var folderNames = await context.Folders
|
||||
.Where(f => groups.Select(g => g.FolderId).Contains(f.Id))
|
||||
.ToDictionary(f => f.Id, f => f.BasePath);
|
||||
.ToDictionaryAsync(f => f.Id, f => f.BasePath, cancellationToken);
|
||||
|
||||
var result = groups
|
||||
.Where(g => g.AssetCount > 0)
|
||||
@@ -330,101 +429,4 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Butter.Dtos.Asset.AssetBrowseResultDto? BrowseAssets(Guid folderId, string currentPath, int page, int pageSize, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly = true) {
|
||||
var folder = context.Folders.Find(folderId);
|
||||
if (folder == null)
|
||||
return null;
|
||||
|
||||
var basePath = folder.BasePath.TrimEnd('/') + "/";
|
||||
var prefix = string.IsNullOrEmpty(currentPath) ? basePath : basePath + currentPath.TrimEnd('/') + "/";
|
||||
var likePattern = prefix + "%";
|
||||
|
||||
var baseQuery = context.Assets
|
||||
.Where(a => a.FolderId == folderId)
|
||||
.Where(a => a.DeletedAt == null);
|
||||
|
||||
if (unlinkedOnly)
|
||||
baseQuery = baseQuery.Where(a => a.Albums!.Count == 0);
|
||||
|
||||
baseQuery = accessLevel switch {
|
||||
EAccessLevel.Admin => baseQuery,
|
||||
EAccessLevel.Curator => baseQuery.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
|
||||
EAccessLevel.Maintainer => baseQuery.Where(a => a.UploadedBy == userId),
|
||||
_ => baseQuery.Where(a => false)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
baseQuery = baseQuery.Where(a => EF.Functions.ILike(a.OriginalFilename, $"%{search}%"));
|
||||
|
||||
var dirs = GetDirectoryNames(prefix, prefix.Length, folderId, userId, accessLevel, search, unlinkedOnly);
|
||||
|
||||
var levelQuery = baseQuery
|
||||
.Where(a => EF.Functions.Like(a.OriginalPath, likePattern))
|
||||
.Where(a => !EF.Functions.Like(a.OriginalPath, prefix + "%/%"));
|
||||
|
||||
var totalAtLevel = levelQuery.Count();
|
||||
|
||||
var assetEntities = levelQuery
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums!).ThenInclude(al => al.PersonOwner)
|
||||
.OrderBy(a => a.OriginalFilename)
|
||||
.Skip(page * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
var assetDtos = assetEntities.Select(a => a.ToAssetPreviewDto(accessLevel, userId)).ToList();
|
||||
|
||||
return new Butter.Dtos.Asset.AssetBrowseResultDto {
|
||||
Directories = dirs,
|
||||
Assets = assetDtos,
|
||||
CurrentPath = currentPath,
|
||||
TotalAssetCount = totalAtLevel
|
||||
};
|
||||
}
|
||||
|
||||
private List<Butter.Dtos.Asset.DirectoryEntryDto> GetDirectoryNames(string prefix, int prefixLen, Guid folderId, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly) {
|
||||
var sql = new System.Text.StringBuilder();
|
||||
sql.Append("SELECT DISTINCT SPLIT_PART(SUBSTRING(a.\"OriginalPath\", LENGTH({0}) + 1), '/', 1) AS \"Name\" ");
|
||||
sql.Append("FROM \"Assets\" a ");
|
||||
if (unlinkedOnly)
|
||||
sql.Append("LEFT JOIN \"AlbumAsset\" aa ON a.\"Id\" = aa.\"AssetsId\" ");
|
||||
sql.Append("WHERE a.\"FolderId\" = {1} ");
|
||||
sql.Append("AND a.\"DeletedAt\" IS NULL ");
|
||||
if (unlinkedOnly)
|
||||
sql.Append("AND aa.\"AssetsId\" IS NULL ");
|
||||
sql.Append("AND a.\"OriginalPath\" LIKE {2} ");
|
||||
sql.Append("AND a.\"OriginalPath\" LIKE {3} ");
|
||||
sql.Append("AND LENGTH(a.\"OriginalPath\") > LENGTH({0}) ");
|
||||
|
||||
var parms = new List<object> { prefix, folderId, prefix + "%", prefix + "%/%" };
|
||||
int p = 4;
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Curator:
|
||||
sql.Append($"AND (a.\"DeletedAt\" IS NULL OR a.\"UploadedBy\" = {{{p}}}) ");
|
||||
parms.Add(userId ?? Guid.Empty);
|
||||
p++;
|
||||
break;
|
||||
case EAccessLevel.Maintainer:
|
||||
sql.Append($"AND a.\"UploadedBy\" = {{{p}}} ");
|
||||
parms.Add(userId ?? Guid.Empty);
|
||||
p++;
|
||||
break;
|
||||
case < EAccessLevel.Curator:
|
||||
sql.Append("AND FALSE ");
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(search)) {
|
||||
sql.Append($"AND a.\"OriginalFilename\" ILIKE '%' || {{{p}}} || '%' ");
|
||||
parms.Add(search!);
|
||||
}
|
||||
|
||||
sql.Append("ORDER BY \"Name\"");
|
||||
|
||||
return context.Database
|
||||
.SqlQueryRaw<Butter.Dtos.Asset.DirectoryEntryDto>(sql.ToString(), parms.ToArray())
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -17,15 +18,15 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
|
||||
static void OnFolderRemoved(Folder e) => FolderRemoved?.Invoke(null, e);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Insert(Folder folder) {
|
||||
public async Task InsertAsync(Folder folder, CancellationToken cancellationToken) {
|
||||
context.Folders.Add(folder);
|
||||
context.SaveChanges();
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
if (folder.Active) OnFolderAdded(folder);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Update(Guid id, Folder folder) {
|
||||
var origFolder = context.Folders.Find(id);
|
||||
public async Task UpdateAsync(Guid id, Folder folder, CancellationToken cancellationToken) {
|
||||
var origFolder = await context.Folders.FindAsync([id], cancellationToken);
|
||||
|
||||
if (origFolder == null) { return; }
|
||||
|
||||
@@ -33,12 +34,12 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
|
||||
var hasChanged = origFolder.BasePath != folder.BasePath;
|
||||
var wasActive = origFolder.Active;
|
||||
|
||||
|
||||
|
||||
origFolder.BasePath = folder.BasePath;
|
||||
origFolder.Active = folder.Active;
|
||||
context.Folders.Update(origFolder);
|
||||
context.SaveChanges();
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (hasChanged && wasActive) OnFolderRemoved(oldPath);
|
||||
|
||||
if (hasChanged && folder.Active) {}
|
||||
@@ -48,24 +49,22 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Remove(Guid id) {
|
||||
var folder = context.Folders.Find(id);
|
||||
public async Task RemoveAsync(Guid id, CancellationToken cancellationToken) {
|
||||
var folder = await context.Folders.FindAsync([id], cancellationToken);
|
||||
|
||||
if (folder != null) {
|
||||
context.Folders.Remove(folder);
|
||||
OnFolderRemoved(folder);
|
||||
}
|
||||
context.SaveChanges();
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Folder> GetAllUntracked() => context.Folders.AsNoTracking();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Folder? Get(Guid id) => context.Folders.Find(id);
|
||||
|
||||
public async Task<Folder?> GetAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
await context.Folders.FindAsync([id], cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Folder> GetAll() => context.Folders;
|
||||
public Task<List<Folder>> GetAllAsync(CancellationToken cancellationToken) => context.Folders.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => context.Dispose();
|
||||
|
||||
@@ -10,41 +10,44 @@ namespace Lactose.Repositories;
|
||||
/// </summary>
|
||||
public interface IAlbumRepository : IDisposable {
|
||||
/// <summary>
|
||||
/// Inserts a new album into the database
|
||||
/// Inserts a new album into the database.
|
||||
/// </summary>
|
||||
/// <param name="album"></param>
|
||||
public void Insert(Album album);
|
||||
/// <param name="album">The album to insert.</param>
|
||||
void Insert(Album album);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an album in the database
|
||||
/// Updates an album in the database.
|
||||
/// </summary>
|
||||
/// <param name="album"></param>
|
||||
public void Update(Album album);
|
||||
/// <param name="album">The album to update.</param>
|
||||
void Update(Album album);
|
||||
|
||||
/// <summary>
|
||||
/// Updates multiple albums in the database.
|
||||
/// </summary>
|
||||
/// <param name="albums">The albums to update.</param>
|
||||
public void UpdateBulk(IEnumerable<Album> albums);
|
||||
void UpdateBulk(IEnumerable<Album> albums);
|
||||
|
||||
/// <summary>
|
||||
/// Saves changes to the database.
|
||||
/// </summary>
|
||||
public void Save();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task SaveAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an album by its ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the album.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The album if found; otherwise, null.</returns>
|
||||
public Album? Find(Guid id);
|
||||
Task<Album?> FindAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an album by its ID with its assets loaded for collection replacement.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the album.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The album with its asset collection loaded if found; otherwise, null.</returns>
|
||||
public Album? FindWithAssets(Guid id);
|
||||
Task<Album?> FindWithAssetsAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an album by its ID with assets filtered by the requesting user's access level.
|
||||
@@ -52,30 +55,51 @@ public interface IAlbumRepository : IDisposable {
|
||||
/// <param name="id">The ID of the album.</param>
|
||||
/// <param name="userId">The requesting user's ID for visibility-scoped asset filtering.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The album if found; otherwise, null.</returns>
|
||||
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel);
|
||||
Task<Album?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds albums by the person's ID.
|
||||
/// </summary>
|
||||
/// <param name="personId">The ID of the person.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A collection of albums associated with the specified person.</returns>
|
||||
public IEnumerable<Album> FindByPerson(Guid personId);
|
||||
Task<List<Album>> FindByPersonAsync(Guid personId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds albums owned by any of the specified people.
|
||||
/// </summary>
|
||||
/// <param name="personIds">The IDs of the people.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A collection of albums associated with the specified people.</returns>
|
||||
Task<List<Album>> FindByPersonIdsAsync(IEnumerable<Guid> personIds, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-updates the visibility of all albums owned by the specified people via a single SQL UPDATE.
|
||||
/// </summary>
|
||||
/// <param name="personIds">The IDs of the people.</param>
|
||||
/// <param name="visibility">The target visibility level.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The number of albums updated.</returns>
|
||||
Task<int> BulkSetVisibilityByPersonIdsAsync(IEnumerable<Guid> personIds, EVisibility visibility, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds multiple albums by their IDs.
|
||||
/// </summary>
|
||||
/// <param name="ids">The IDs of the albums.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A collection of albums with the specified IDs.</returns>
|
||||
public IEnumerable<Album> FindBulk(IEnumerable<Guid> ids);
|
||||
Task<List<Album>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds albums within a specified date range.
|
||||
/// </summary>
|
||||
/// <param name="from">The start date of the range.</param>
|
||||
/// <param name="to">The end date of the range.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A collection of albums created or updated within the specified date range.</returns>
|
||||
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to);
|
||||
Task<List<Album>> FindByDateRangeAsync(DateTime from, DateTime to, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple source albums into a destination album.
|
||||
@@ -83,13 +107,14 @@ public interface IAlbumRepository : IDisposable {
|
||||
/// </summary>
|
||||
/// <param name="destId">The ID of the destination album.</param>
|
||||
/// <param name="sourceIds">The IDs of the source albums to merge.</param>
|
||||
public void MergeAlbums(Guid destId, List<Guid> sourceIds);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task MergeAlbumsAsync(Guid destId, List<Guid> sourceIds, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an album from the database.
|
||||
/// </summary>
|
||||
/// <param name="album">The album to remove.</param>
|
||||
public void Remove(Album album);
|
||||
void Remove(Album album);
|
||||
|
||||
/// <summary>
|
||||
/// Searches for albums based on a query string with optional sorting, filtering, and access-level scoping.
|
||||
@@ -104,7 +129,16 @@ public interface IAlbumRepository : IDisposable {
|
||||
/// <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>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</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, Guid? personOwnerId = null);
|
||||
Task<List<AlbumPreviewDto>> SearchQueryAsync(string query, int page, int pageSize, string? sortBy, bool sortAsc, bool unassigned, Guid userId, EAccessLevel accessLevel, Guid? uploadedBy, Guid? personOwnerId, CancellationToken cancellationToken);
|
||||
|
||||
}
|
||||
/// <summary>
|
||||
/// Executes the given operation inside an explicit transaction on the shared database context,
|
||||
/// committing on success and rolling back on failure.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type returned by the operation.</typeparam>
|
||||
/// <param name="operation">The work to run inside the transaction.</param>
|
||||
/// <returns>The operation's result, or null if the operation returned null.</returns>
|
||||
Task<TResult?> ExecuteInTransactionAsync<TResult>(Func<Task<TResult?>> operation);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ public interface IAssetRepository : IDisposable {
|
||||
/// Finds an asset by its ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the asset.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Null if not found, otherwise the requested asset.</returns>
|
||||
public Asset? Find(Guid id);
|
||||
Task<Asset?> FindAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an asset by its ID, respecting the requesting user's access level.
|
||||
@@ -22,37 +23,64 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <param name="id">The ID of the asset.</param>
|
||||
/// <param name="userId">The requesting user's ID.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The asset if found and visible; otherwise null.</returns>
|
||||
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel);
|
||||
Task<Asset?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an asset by its ID with its album navigation properties loaded.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the asset.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Null if not found, otherwise the requested asset with albums.</returns>
|
||||
public Asset? FindWithAlbums(Guid id);
|
||||
Task<Asset?> FindWithAlbumsAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a set of assets by their IDs.
|
||||
/// </summary>
|
||||
/// <param name="ids">The IDs of the assets.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A collection of assets with the specified IDs.</returns>
|
||||
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids);
|
||||
Task<List<Asset>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds all assets uploaded by the user.
|
||||
/// </summary>
|
||||
/// <param name="uploaderId">The ID of the uploader.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A collection of assets uploaded by the specified user.</returns>
|
||||
public IEnumerable<Asset> FindByUploader(Guid uploaderId);
|
||||
Task<List<Asset>> FindByUploaderAsync(Guid uploaderId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-updates the visibility of non-deleted assets in the specified albums via a single SQL UPDATE,
|
||||
/// respecting the requesting user's access level.
|
||||
/// </summary>
|
||||
/// <param name="albumIds">The IDs of the albums.</param>
|
||||
/// <param name="visibility">The target visibility level.</param>
|
||||
/// <param name="userId">The requesting user's ID.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The number of assets updated.</returns>
|
||||
Task<int> BulkSetVisibilityByAlbumIdsAsync(IEnumerable<Guid> albumIds, EVisibility visibility, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-updates the visibility of non-deleted assets in any album owned by the specified people
|
||||
/// via a single SQL UPDATE.
|
||||
/// </summary>
|
||||
/// <param name="personIds">The IDs of the people whose album assets to update.</param>
|
||||
/// <param name="visibility">The target visibility level.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The number of assets updated.</returns>
|
||||
Task<int> BulkSetVisibilityByPersonIdsAsync(IEnumerable<Guid> personIds, EVisibility visibility, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds all assets created or updated between the given dates.
|
||||
/// </summary>
|
||||
/// <param name="from">The start date of the range.</param>
|
||||
/// <param name="to">The end date of the range.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A collection of assets created or updated within the specified date range.</returns>
|
||||
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to);
|
||||
Task<List<Asset>> FindByDateRangeAsync(DateTime from, DateTime to, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds all assets created or updated between the given dates with pagination.
|
||||
@@ -61,73 +89,88 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <param name="to">The end date of the range.</param>
|
||||
/// <param name="pageNumber">The page number for pagination.</param>
|
||||
/// <param name="pageSize">The number of items per page.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A collection of assets created or updated within the specified date range.</returns>
|
||||
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to, int pageNumber, int pageSize);
|
||||
Task<List<Asset>> FindByDateRangeAsync(DateTime from, DateTime to, int pageNumber, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Saves all the changes to the model.
|
||||
/// </summary>
|
||||
void Save();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task SaveAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a new asset.
|
||||
/// Inserts a new asset. Throws when an asset with the same original path already exists.
|
||||
/// </summary>
|
||||
/// <param name="asset">The asset to insert.</param>
|
||||
public void Insert(Asset asset);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task InsertAsync(Asset asset, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a given asset.
|
||||
/// </summary>
|
||||
/// <param name="asset">The asset to update.</param>
|
||||
public void Update(Asset asset);
|
||||
void Update(Asset asset);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a list of assets.
|
||||
/// </summary>
|
||||
/// <param name="assets">The list of assets to update.</param>
|
||||
public void UpdateBulk(IEnumerable<Asset> assets);
|
||||
void UpdateBulk(IEnumerable<Asset> assets);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an asset by its file path.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The file path of the asset.</param>
|
||||
/// <returns>Null if not found, otherwise the requested asset.</returns>
|
||||
Asset? FindByPath(string filePath);
|
||||
/// Gets an asset by its file path.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The file path of the asset.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Null if not found, otherwise the requested asset.</returns>
|
||||
Task<Asset?> FindByPathAsync(string filePath, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Counts all assets that are missing a perceptual hash (pHash).
|
||||
/// </summary>
|
||||
int CountAssetsMissingPHash();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task<int> CountAssetsMissingPHashAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a page of assets that are missing a perceptual hash (pHash).
|
||||
/// </summary>
|
||||
/// <param name="limit">Maximum number of assets to return.</param>
|
||||
/// <param name="offset">Number of assets to skip.</param>
|
||||
IEnumerable<Asset> GetAssetsMissingPHash(int limit, int offset);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task<List<Asset>> GetAssetsMissingPHashAsync(int limit, int offset, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Groups assets sharing an identical perceptual hash (pHash).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Groups of duplicate assets (only groups with more than one asset).</returns>
|
||||
Task<List<List<Asset>>> GetDuplicatesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds assets within a normalized Hamming distance (0 to 1).
|
||||
/// </summary>
|
||||
/// <param name="phash">The perceptual hash to compare against.</param>
|
||||
/// <param name="distance">The maximum distance as a fraction (0 to 1).</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Assets whose hash is within the specified distance.</returns>
|
||||
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, float distance);
|
||||
|
||||
Task<List<Asset>> GetWithinHammingDistanceAsync(ulong phash, float distance, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds all assets that are missing a thumbnail.
|
||||
/// Finds all assets that are missing a thumbnail, together with the total count.
|
||||
/// </summary>
|
||||
/// <param name="totalAssets">The total number of assets missing thumbnails.</param>
|
||||
/// <returns>Assets that do not have a thumbnail path set.</returns>
|
||||
IEnumerable<Asset> GetAssetsMissingThumbnail(out int totalAssets);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Assets that do not have a thumbnail path set, and the total count of such assets.</returns>
|
||||
Task<(List<Asset> Assets, int Total)> GetAssetsMissingThumbnailAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Counts all assets that need thumbnails — either missing entirely, with wrong dimensions, or wrong format.
|
||||
/// </summary>
|
||||
/// <param name="thumbnailSize">The expected longest-side size in pixels.</param>
|
||||
/// <param name="expectedFormat">The expected thumbnail format (e.g. "webp").</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The total count of assets needing thumbnails.</returns>
|
||||
int CountAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat);
|
||||
Task<int> CountAssetsMissingOrWrongThumbnailAsync(int thumbnailSize, string expectedFormat, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a page of assets that need thumbnails — either missing entirely, with wrong dimensions, or wrong format.
|
||||
@@ -136,16 +179,18 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <param name="expectedFormat">The expected thumbnail format (e.g. "webp").</param>
|
||||
/// <param name="limit">Maximum number of assets to return.</param>
|
||||
/// <param name="offset">Number of assets to skip.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A page of assets that need thumbnails regenerated.</returns>
|
||||
IEnumerable<Asset> GetAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat, int limit, int offset);
|
||||
Task<List<Asset>> GetAssetsMissingOrWrongThumbnailAsync(int thumbnailSize, string expectedFormat, int limit, int offset, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Counts all assets that need previews — either missing entirely, with wrong dimensions, or wrong format.
|
||||
/// </summary>
|
||||
/// <param name="previewSize">The expected longest-side size in pixels.</param>
|
||||
/// <param name="expectedFormat">The expected preview format (e.g. "webp").</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The total count of assets needing previews.</returns>
|
||||
int CountAssetsMissingOrWrongPreview(int previewSize, string expectedFormat);
|
||||
Task<int> CountAssetsMissingOrWrongPreviewAsync(int previewSize, string expectedFormat, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a page of assets that need previews — either missing entirely, with wrong dimensions, or wrong format.
|
||||
@@ -154,28 +199,71 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <param name="expectedFormat">The expected preview format (e.g. "webp").</param>
|
||||
/// <param name="limit">Maximum number of assets to return.</param>
|
||||
/// <param name="offset">Number of assets to skip.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A page of assets that need previews regenerated.</returns>
|
||||
IEnumerable<Asset> GetAssetsMissingOrWrongPreview(int previewSize, string expectedFormat, int limit, int offset);
|
||||
Task<List<Asset>> GetAssetsMissingOrWrongPreviewAsync(int previewSize, string expectedFormat, int limit, int offset, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Counts image assets that are missing resolution metadata.
|
||||
/// </summary>
|
||||
int CountAssetsMissingMetadata();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task<int> CountAssetsMissingMetadataAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a page of image assets missing resolution metadata.
|
||||
/// </summary>
|
||||
/// <param name="limit">Maximum number of assets to return.</param>
|
||||
/// <param name="offset">Number of assets to skip.</param>
|
||||
IEnumerable<Asset> GetAssetsMissingMetadata(int limit, int offset);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task<List<Asset>> GetAssetsMissingMetadataAsync(int limit, int offset, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Counts animated assets (GIF) that need a converted video and/or first-frame thumbnail.
|
||||
/// Excludes deleted and broken assets.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task<int> CountAssetsNeedingConversionAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a page of animated assets (GIF) that need a converted video and/or first-frame thumbnail.
|
||||
/// </summary>
|
||||
/// <param name="limit">Maximum number of assets to return.</param>
|
||||
/// <param name="offset">Number of assets to skip.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task<List<Asset>> GetAssetsNeedingConversionAsync(int limit, int offset, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Counts assets currently marked broken (processing failed, not deleted).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task<int> CountBrokenAssetsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a page of assets marked broken (processing failed, not deleted), most recently broken first,
|
||||
/// together with the total count.
|
||||
/// </summary>
|
||||
/// <param name="page">The zero-based page number.</param>
|
||||
/// <param name="pageSize">The number of assets per page.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A page of broken assets as preview DTOs and the total count.</returns>
|
||||
Task<(List<AssetPreviewDto> Assets, int Total)> GetBrokenAssetsAsync(int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the broken (processing failed) state for the given assets so they are picked up for
|
||||
/// re-processing on the next job run. Caller must invoke <see cref="SaveAsync"/> afterwards.
|
||||
/// </summary>
|
||||
/// <param name="ids">The IDs of the assets to retry.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task ClearProcessErrorAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a random sample of asset paths from the specified folder.
|
||||
/// </summary>
|
||||
/// <param name="folderId">The folder ID.</param>
|
||||
/// <param name="count">The number of random paths to return.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of asset original paths.</returns>
|
||||
List<string> GetRandomPathsByFolder(Guid folderId, int count);
|
||||
Task<List<string>> GetRandomPathsByFolderAsync(Guid folderId, int count, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Queries assets with optional type filter, date range, random ordering, and visibility scoping.
|
||||
@@ -187,16 +275,19 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <param name="seed">An optional seed for deterministic random ordering across paginated requests.</param>
|
||||
/// <param name="pageNumber">The zero-based page number.</param>
|
||||
/// <param name="pageSize">The number of items per page.</param>
|
||||
/// <param name="total">The total count of matching assets (after visibility filtering).</param>
|
||||
/// <param name="userId">The requesting user's ID for visibility filtering.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level for visibility filtering.</param>
|
||||
/// <param name="unlinked">If true, filters for assets not assigned to any album.</param>
|
||||
/// <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>
|
||||
/// <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);
|
||||
/// <param name="includeCount">If true (default), computes the total matching count. When false, the returned total is zero and the count query is skipped.</param>
|
||||
/// <param name="deletedOnly">If true, filters exclusively for soft-deleted assets. Results remain scoped by the requester's access level.</param>
|
||||
/// <param name="albumId">Optional album ID to filter assets belonging to that album.</param>
|
||||
/// <param name="personId">Optional person ID to filter assets in albums owned by that person.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A paginated collection of asset previews matching the filters, and the total matching count.</returns>
|
||||
Task<(List<AssetPreviewDto> Assets, int Total)> GetAssetsAsync(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, Guid? userId, EAccessLevel accessLevel, bool unlinked, Guid? folderId, Guid? uploadedBy, string? search, bool includeCount, bool deletedOnly, Guid? albumId, Guid? personId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
|
||||
@@ -206,21 +297,7 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <param name="userId">The requesting user's ID.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="unlinkedOnly">If true (default), only counts assets not assigned to any album.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
|
||||
List<Butter.Dtos.Asset.AssetGroupDto> GetFolderGroups(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly = true);
|
||||
|
||||
/// <summary>
|
||||
/// Browses assets at a specific directory level within a folder, returning immediate subdirectories
|
||||
/// and the assets located directly at that level with pagination.
|
||||
/// </summary>
|
||||
/// <param name="folderId">The root folder ID to browse within.</param>
|
||||
/// <param name="currentPath">The current path relative to the folder root (empty string for root).</param>
|
||||
/// <param name="page">The zero-based page number for asset pagination.</param>
|
||||
/// <param name="pageSize">The number of assets per page.</param>
|
||||
/// <param name="userId">The requesting user's ID for visibility filtering.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="search">Optional search term for ILike matching against OriginalFilename.</param>
|
||||
/// <param name="unlinkedOnly">If true (default), only includes assets not assigned to any album.</param>
|
||||
/// <returns>A browse result containing subdirectories and a page of assets at the current level.</returns>
|
||||
Butter.Dtos.Asset.AssetBrowseResultDto? BrowseAssets(Guid folderId, string currentPath, int page, int pageSize, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly = true);
|
||||
Task<List<Butter.Dtos.Asset.AssetGroupDto>> GetFolderGroupsAsync(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -10,35 +10,36 @@ public interface IFolderRepository : IDisposable {
|
||||
/// Creates a new folder.
|
||||
/// </summary>
|
||||
/// <param name="folder">The folder to create.</param>
|
||||
void Insert(Folder folder);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task InsertAsync(Folder folder, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing folder.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the folder to update.</param>
|
||||
/// <param name="folder">The updated folder data.</param>
|
||||
void Update(Guid id, Folder folder);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task UpdateAsync(Guid id, Folder folder, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a folder by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the folder to remove.</param>
|
||||
void Remove(Guid id);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task RemoveAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a folder by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the folder to retrieve.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The folder with the specified ID.</returns>
|
||||
Folder? Get(Guid id);
|
||||
Task<Folder?> GetAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all folders.
|
||||
/// </summary>
|
||||
IEnumerable<Folder> GetAll();
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all folders without tracking them in the change tracker.
|
||||
/// </summary>
|
||||
IEnumerable<Folder> GetAllUntracked();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The list of all folders.</returns>
|
||||
Task<List<Folder>> GetAllAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -19,38 +19,45 @@ public interface IJobRecordRepository : IDisposable {
|
||||
/// <summary>
|
||||
/// Retrieves all job records, ordered by most recent.
|
||||
/// </summary>
|
||||
List<JobRecord> GetAll();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task<List<JobRecord>> GetAllAsync(CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Retrieves a page of past (completed/failed/canceled) root job records.
|
||||
/// Retrieves a page of past (completed/failed/canceled) root job records with the total count.
|
||||
/// </summary>
|
||||
/// <param name="page">The page number.</param>
|
||||
/// <param name="pageSize">The number of records per page.</param>
|
||||
/// <param name="total">The total number of past root jobs.</param>
|
||||
List<JobRecord> GetPastRootJobs(int page, int pageSize, out int total);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The page of records and the total number of past root jobs.</returns>
|
||||
Task<(List<JobRecord> Jobs, int Total)> GetPastRootJobsAsync(int page, int pageSize, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Retrieves child job records for a given parent job.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The parent job ID.</param>
|
||||
List<JobRecord> GetChildren(Guid parentId);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task<List<JobRecord>> GetChildrenAsync(Guid parentId, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Retrieves child job records for a given parent job that have been modified since the specified timestamp.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The parent job ID.</param>
|
||||
/// <param name="since">Only return records modified after this timestamp.</param>
|
||||
List<JobRecord> GetChildrenModifiedSince(Guid parentId, DateTime since);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task<List<JobRecord>> GetChildrenModifiedSinceAsync(Guid parentId, DateTime since, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Retrieves a job record by its ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The job record ID.</param>
|
||||
JobRecord? GetById(Guid id);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task<JobRecord?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Deletes job records that finished before the specified cutoff date.
|
||||
/// </summary>
|
||||
/// <param name="cutoff">The cutoff date.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The number of deleted records.</returns>
|
||||
int DeleteOldJobs(DateTime cutoff);
|
||||
Task<int> DeleteOldJobsAsync(DateTime cutoff, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Persists changes to the database.
|
||||
/// </summary>
|
||||
void Save();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task SaveAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ public interface IMediaRepository : IDisposable {
|
||||
/// <param name="id">The ID of the media.</param>
|
||||
/// <param name="userId">The ID of the user requesting the media.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
||||
public MediaDto? GetOriginalData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
Task<MediaDto?> GetOriginalDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the thumbnail media data.
|
||||
@@ -22,8 +23,9 @@ public interface IMediaRepository : IDisposable {
|
||||
/// <param name="id">The ID of the media.</param>
|
||||
/// <param name="userId">The ID of the user requesting the media.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
||||
public MediaDto? GetThumbData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
Task<MediaDto?> GetThumbDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the preview media data.
|
||||
@@ -31,6 +33,17 @@ public interface IMediaRepository : IDisposable {
|
||||
/// <param name="id">The ID of the media.</param>
|
||||
/// <param name="userId">The ID of the user requesting the media.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
||||
public MediaDto? GetPreviewData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
}
|
||||
Task<MediaDto?> GetPreviewDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the converted media data (e.g. the mp4 for an animated GIF).
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the media.</param>
|
||||
/// <param name="userId">The ID of the user requesting the media.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A <see cref="MediaDto"/> containing the converted media data, or null if the asset has no converted file or is not visible.</returns>
|
||||
Task<MediaDto?> GetConvertedDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -18,14 +18,16 @@ public interface IPersonRepository : IDisposable {
|
||||
/// <summary>
|
||||
/// Saves all pending changes to the database.
|
||||
/// </summary>
|
||||
void Save();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task SaveAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a person by their ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the person.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The person if found; otherwise, null.</returns>
|
||||
Person? Find(Guid id);
|
||||
Task<Person?> FindAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a person by their ID with albums and assets filtered by the requesting user's access level.
|
||||
@@ -38,29 +40,34 @@ public interface IPersonRepository : IDisposable {
|
||||
/// <param name="albumSortAsc">Whether to sort albums ascending.</param>
|
||||
/// <param name="albumPage">Zero-based page number for album pagination.</param>
|
||||
/// <param name="albumPageSize">Number of albums per page (default 150, max 250).</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The person if found; otherwise, null.</returns>
|
||||
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);
|
||||
Task<Person?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel,
|
||||
string? albumSearch, string? albumSortBy, bool albumSortAsc,
|
||||
int albumPage, int albumPageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a person by their name (case-sensitive).
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the person.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The person if found; otherwise, null.</returns>
|
||||
Person? FindByName(string name);
|
||||
Task<Person?> FindByNameAsync(string name, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds all people whose names are in the given set.
|
||||
/// </summary>
|
||||
/// <param name="names">The names to search for.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A collection of people with matching names (non-deleted only).</returns>
|
||||
IEnumerable<Person> FindByNames(IEnumerable<string> names);
|
||||
Task<List<Person>> FindByNamesAsync(IEnumerable<string> names, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all non-deleted people.
|
||||
/// </summary>
|
||||
IEnumerable<Person> GetAll();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The list of all people.</returns>
|
||||
Task<List<Person>> GetAllAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all non-deleted people that have at least one album with visible assets for the given user.
|
||||
@@ -68,7 +75,8 @@ public interface IPersonRepository : IDisposable {
|
||||
/// </summary>
|
||||
/// <param name="userId">The current user's ID.</param>
|
||||
/// <param name="accessLevel">The current user's access level.</param>
|
||||
IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task<List<Person>> GetAllVisibleAsync(Guid userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Searches for people based on a query string with optional sorting and pagination.
|
||||
@@ -81,15 +89,17 @@ public interface IPersonRepository : IDisposable {
|
||||
/// <param name="sortAsc">Whether to sort ascending. Default is <c>true</c>.</param>
|
||||
/// <param name="userId">The current user's ID for access-level filtering.</param>
|
||||
/// <param name="accessLevel">The current user's access level.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A paginated list of person previews matching the query.</returns>
|
||||
IEnumerable<PersonPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
Task<List<PersonPreviewDto>> SearchQueryAsync(string query, int page, int pageSize, string? sortBy, bool sortAsc, Guid userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds multiple people by their IDs.
|
||||
/// </summary>
|
||||
/// <param name="ids">The IDs to find.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A collection of matching people.</returns>
|
||||
IEnumerable<Person> FindBulk(IEnumerable<Guid> ids);
|
||||
Task<List<Person>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple source people into a destination person.
|
||||
@@ -97,7 +107,8 @@ public interface IPersonRepository : IDisposable {
|
||||
/// </summary>
|
||||
/// <param name="destId">The ID of the destination person.</param>
|
||||
/// <param name="sourceIds">The IDs of the source people to merge.</param>
|
||||
void MergePeople(Guid destId, List<Guid> sourceIds);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task MergePeopleAsync(Guid destId, List<Guid> sourceIds, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a person from the database.
|
||||
@@ -110,8 +121,9 @@ public interface IPersonRepository : IDisposable {
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID to check.</param>
|
||||
/// <param name="personId">The person ID to check.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns><see langword="true"/> if the user is a maintainer of the person.</returns>
|
||||
bool IsMaintainerOf(Guid userId, Guid personId);
|
||||
Task<bool> IsMaintainerOfAsync(Guid userId, Guid personId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the list of user IDs that maintain this person.
|
||||
@@ -119,5 +131,15 @@ public interface IPersonRepository : IDisposable {
|
||||
/// </summary>
|
||||
/// <param name="personId">The person ID.</param>
|
||||
/// <param name="userIds">The user IDs to assign as maintainers.</param>
|
||||
void SetMaintainers(Guid personId, IEnumerable<Guid> userIds);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task SetMaintainersAsync(Guid personId, IEnumerable<Guid> userIds, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Executes the given operation inside an explicit transaction on the shared database context,
|
||||
/// committing on success and rolling back on failure.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type returned by the operation.</typeparam>
|
||||
/// <param name="operation">The work to run inside the transaction.</param>
|
||||
/// <returns>The operation's result, or null if the operation returned null.</returns>
|
||||
Task<TResult?> ExecuteInTransactionAsync<TResult>(Func<Task<TResult?>> operation);
|
||||
}
|
||||
|
||||
@@ -10,41 +10,48 @@ public interface ISettingsRepository : IDisposable {
|
||||
/// Creates a settings entry in the repository. If a settings entry already exists, it will be overwritten.
|
||||
/// </summary>
|
||||
/// <param name="setting">The settings to create.</param>
|
||||
public void Create(Setting setting);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task CreateAsync(Setting setting, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the settings from the repository.
|
||||
/// Retrieves all settings from the repository.
|
||||
/// </summary>
|
||||
/// <returns>The settings if found; otherwise, null.</returns>
|
||||
public IEnumerable<Setting> Get();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The list of all settings.</returns>
|
||||
Task<List<Setting>> GetAllAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Try to get a specific setting by its name.
|
||||
/// </summary>
|
||||
/// <param name="name">name key of the setting</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns> The setting if found; otherwise, null.</returns>
|
||||
public Setting? Get(string name);
|
||||
Task<Setting?> GetAsync(string name, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the settings in the repository.
|
||||
/// </summary>
|
||||
/// <param name="setting">The settings to update.</param>
|
||||
public void Update(Setting setting);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task UpdateAsync(Setting setting, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the settings from the repository.
|
||||
/// Deletes all settings from the repository.
|
||||
/// </summary>
|
||||
public void Delete();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task DeleteAllAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a specific setting by its name.
|
||||
/// </summary>
|
||||
/// <param name="name">name key of the setting to remove</param>
|
||||
public void Delete(string name);
|
||||
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task DeleteAsync(string name, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a specific setting from the repository.
|
||||
/// </summary>
|
||||
/// <param name="setting">Setting instance to delete</param>
|
||||
public void Delete(Setting setting);
|
||||
|
||||
}
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task DeleteAsync(Setting setting, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -12,51 +12,56 @@ public interface ITagRepository : IDisposable {
|
||||
/// <param name="searchQuery">the search query</param>
|
||||
/// <param name="page"> the number of the page for the given query</param>
|
||||
/// <param name="pageSize">the size of the page</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>list of tags of type <see cref="Tag"/> with the size <paramref name="pageSize"/></returns>
|
||||
List<Tag> Search(string searchQuery, int page, int pageSize);
|
||||
Task<List<Tag>> SearchAsync(string searchQuery, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all the tags
|
||||
/// Returns all the tags.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
IEnumerable<Tag> GetAll();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The list of all tags.</returns>
|
||||
Task<List<Tag>> GetAllAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Find a specific Tag given the id
|
||||
/// Find a specific Tag given the id.
|
||||
/// </summary>
|
||||
/// <param name="id">identifier of the tag</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>the <see cref="Tag"/> or null</returns>
|
||||
Tag? Find(Guid id);
|
||||
|
||||
Task<Tag?> FindAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Find a specific Tag given the name
|
||||
/// Find a specific Tag given the name.
|
||||
/// </summary>
|
||||
/// <param name="name">the exact name of the tag</param>
|
||||
/// <returns></returns>
|
||||
Tag? FindByName(string name);
|
||||
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>the <see cref="Tag"/> or null</returns>
|
||||
Task<Tag?> FindByNameAsync(string name, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Return the list of the children of the given tag
|
||||
/// Return the list of the children of the given tag.
|
||||
/// </summary>
|
||||
/// <param name="id">identifier of the tag</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>the list of <see cref="Tag"/>, could be empty</returns>
|
||||
List<Tag> GetChildren(Guid id);
|
||||
Task<List<Tag>> GetChildrenAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Saves all changes to the repository.
|
||||
/// </summary>
|
||||
void Save();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task SaveAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the given tag from the repository.
|
||||
/// </summary>
|
||||
/// <param name="tag"></param>
|
||||
/// <param name="tag">The tag to delete.</param>
|
||||
void Delete(Tag tag);
|
||||
|
||||
/// <summary>
|
||||
/// Insert a new Tag
|
||||
/// Insert a new Tag.
|
||||
/// </summary>
|
||||
/// <param name="tag"></param>
|
||||
/// <param name="tag">The tag to insert.</param>
|
||||
void Insert(Tag tag);
|
||||
}
|
||||
|
||||
@@ -7,49 +7,55 @@ namespace Lactose.Repositories;
|
||||
/// </summary>
|
||||
public interface IUserRepository : IDisposable {
|
||||
/// <summary>
|
||||
/// Return the list of all users
|
||||
/// Return the list of all users.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
IEnumerable<User> GetAll();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The list of all users.</returns>
|
||||
Task<List<User>> GetAllAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Insert a new uer
|
||||
/// Insert a new user.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="user">The user to insert.</param>
|
||||
void Insert(User user);
|
||||
|
||||
/// <summary>
|
||||
/// Saves all the changes to the Model
|
||||
/// Saves all the changes to the Model.
|
||||
/// </summary>
|
||||
void Save();
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task SaveAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Get the user from the database.
|
||||
/// Get the user from the database.
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <returns>Returns the user if exist</returns>
|
||||
User? Find(Guid id);
|
||||
/// <param name="id">The user ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Returns the user if exist.</returns>
|
||||
Task<User?> FindAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Find a user by email, expects that the email is unique
|
||||
/// Find a user by email, expects that the email is unique.
|
||||
/// </summary>
|
||||
/// <param name="email"></param>
|
||||
/// <returns>Returns the user if found </returns>
|
||||
User? FindByEmail(string email);
|
||||
/// <param name="email">The email address.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Returns the user if found.</returns>
|
||||
Task<User?> FindByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Find a user by username, expects that the username is unique
|
||||
/// Find a user by username, expects that the username is unique.
|
||||
/// </summary>
|
||||
/// <param name="username"></param>
|
||||
/// <returns>Returns the user if found </returns>
|
||||
User? FindByUsername(string username);
|
||||
/// <param name="username">The username.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Returns the user if found.</returns>
|
||||
Task<User?> FindByUsernameAsync(string username, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Get the user with their maintained persons eagerly loaded.
|
||||
/// </summary>
|
||||
/// <param name="id">The user ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>Returns the user with maintained persons if found.</returns>
|
||||
User? FindWithMaintainedPersons(Guid id);
|
||||
Task<User?> FindWithMaintainedPersonsAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the list of person (cosplayer) IDs that a user maintains.
|
||||
@@ -57,5 +63,6 @@ public interface IUserRepository : IDisposable {
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID.</param>
|
||||
/// <param name="personIds">The person IDs to assign.</param>
|
||||
void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds);
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
Task SetMaintainedPersonsAsync(Guid userId, IEnumerable<Guid> personIds, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -19,50 +20,52 @@ public class JobRecordRepository(LactoseDbContext context) : IJobRecordRepositor
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<JobRecord> GetAll() =>
|
||||
public Task<List<JobRecord>> GetAllAsync(CancellationToken cancellationToken) =>
|
||||
context.JobRecords
|
||||
.OrderByDescending(j => j.Finished ?? j.Created)
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
static readonly EJobStatus[] PastStatuses = [EJobStatus.Completed, EJobStatus.CompletedWithErrors, EJobStatus.Failed, EJobStatus.Canceled];
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<JobRecord> GetPastRootJobs(int page, int pageSize, out int total) {
|
||||
public async Task<(List<JobRecord> Jobs, int Total)> GetPastRootJobsAsync(int page, int pageSize, CancellationToken cancellationToken) {
|
||||
var query = context.JobRecords
|
||||
.Where(j => j.ParentJobId == null && PastStatuses.Contains(j.Status));
|
||||
|
||||
total = query.Count();
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
return query
|
||||
var jobs = await query
|
||||
.OrderByDescending(j => j.Finished ?? j.Created)
|
||||
.Skip(page * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (jobs, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<JobRecord> GetChildren(Guid parentId) =>
|
||||
public Task<List<JobRecord>> GetChildrenAsync(Guid parentId, CancellationToken cancellationToken) =>
|
||||
context.JobRecords
|
||||
.Where(j => j.ParentJobId == parentId)
|
||||
.OrderBy(j => j.Created)
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<JobRecord> GetChildrenModifiedSince(Guid parentId, DateTime since) =>
|
||||
public Task<List<JobRecord>> GetChildrenModifiedSinceAsync(Guid parentId, DateTime since, CancellationToken cancellationToken) =>
|
||||
context.JobRecords
|
||||
.Where(j => j.ParentJobId == parentId && j.ModifiedAt != null && j.ModifiedAt > since)
|
||||
.OrderBy(j => j.Created)
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public JobRecord? GetById(Guid id) =>
|
||||
context.JobRecords.Find(id);
|
||||
public async Task<JobRecord?> GetByIdAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
await context.JobRecords.FindAsync([id], cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int DeleteOldJobs(DateTime cutoff) {
|
||||
var old = context.JobRecords
|
||||
public async Task<int> DeleteOldJobsAsync(DateTime cutoff, CancellationToken cancellationToken) {
|
||||
var old = await context.JobRecords
|
||||
.Where(j => j.Finished != null && j.Finished < cutoff)
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
var count = old.Count;
|
||||
if (count > 0) {
|
||||
context.JobRecords.RemoveRange(old);
|
||||
@@ -71,7 +74,7 @@ public class JobRecordRepository(LactoseDbContext context) : IJobRecordRepositor
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save() => context.SaveChanges();
|
||||
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => context.Dispose();
|
||||
|
||||
@@ -2,6 +2,7 @@ using Butter.Dtos;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -16,47 +17,51 @@ public class MediaRepository(LactoseDbContext context, IPersonRepository personR
|
||||
/// <param name="id">Asset ID</param>
|
||||
/// <param name="userId">UserID we are trying to get this as</param>
|
||||
/// <param name="accessLevel">The requesting user's access level</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns></returns>
|
||||
Asset? GetVisibleAsset(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||
async Task<Asset?> GetVisibleAsset(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
|
||||
var query = context.Assets.Where(asset => asset.Id == id);
|
||||
|
||||
if (accessLevel >= EAccessLevel.Admin)
|
||||
return query.FirstOrDefault();
|
||||
return await query.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (accessLevel >= EAccessLevel.Curator)
|
||||
return query.FirstOrDefault(asset =>
|
||||
asset.DeletedAt == null || asset.UploadedBy == userId);
|
||||
return await query.FirstOrDefaultAsync(asset =>
|
||||
asset.DeletedAt == null || asset.UploadedBy == userId, cancellationToken);
|
||||
|
||||
if (accessLevel == EAccessLevel.Maintainer && userId.HasValue) {
|
||||
var asset = query
|
||||
var asset = await query
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums)
|
||||
.FirstOrDefault();
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (asset == null || asset.DeletedAt != null) return null;
|
||||
if (asset.Visibility == EVisibility.Public) return asset;
|
||||
if (asset.Visibility == EVisibility.Protected) return asset;
|
||||
if (asset.UploadedBy == userId) return asset;
|
||||
if (asset.Albums != null && asset.Albums.Any(a =>
|
||||
a.PersonOwnerId.HasValue && personRepository.IsMaintainerOf(userId.Value, a.PersonOwnerId.Value)))
|
||||
return asset;
|
||||
if (asset.Albums != null) {
|
||||
foreach (var album in asset.Albums) {
|
||||
if (album.PersonOwnerId.HasValue
|
||||
&& await personRepository.IsMaintainerOfAsync(userId.Value, album.PersonOwnerId.Value, cancellationToken))
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return query.FirstOrDefault(asset =>
|
||||
return await query.FirstOrDefaultAsync(asset =>
|
||||
asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && userId.HasValue && asset.UploadedBy == userId)
|
||||
)
|
||||
);
|
||||
), cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public MediaDto? GetOriginalData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
var media = GetVisibleAsset(id, userId, accessLevel);
|
||||
public async Task<MediaDto?> GetOriginalDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
|
||||
var media = await GetVisibleAsset(id, userId, accessLevel, cancellationToken);
|
||||
|
||||
if (media == null) return null;
|
||||
|
||||
|
||||
return new MediaDto {
|
||||
MimeType = media.MimeType,
|
||||
Path = media.OriginalPath
|
||||
@@ -65,13 +70,13 @@ public class MediaRepository(LactoseDbContext context, IPersonRepository personR
|
||||
|
||||
//TODO: for Thumbnails the MimeType should be the correct one (depending on which format is used for thumbnails)
|
||||
/// <inheritdoc />
|
||||
public MediaDto? GetThumbData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
var media = GetVisibleAsset(id, userId, accessLevel);
|
||||
|
||||
public async Task<MediaDto?> GetThumbDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
|
||||
var media = await GetVisibleAsset(id, userId, accessLevel, cancellationToken);
|
||||
|
||||
if(media == null) return null;
|
||||
|
||||
if (string.IsNullOrEmpty(media.ThumbnailPath)) return null;
|
||||
|
||||
|
||||
return new MediaDto {
|
||||
MimeType = "image/webp",
|
||||
Path = media.ThumbnailPath
|
||||
@@ -80,22 +85,36 @@ public class MediaRepository(LactoseDbContext context, IPersonRepository personR
|
||||
|
||||
//TODO: for Previews the MimeType should be the correct one (depending on which format is used for previews and if the asset is a video)
|
||||
/// <inheritdoc />
|
||||
public MediaDto? GetPreviewData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
var media = GetVisibleAsset(id, userId, accessLevel);
|
||||
|
||||
public async Task<MediaDto?> GetPreviewDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
|
||||
var media = await GetVisibleAsset(id, userId, accessLevel, cancellationToken);
|
||||
|
||||
if(media == null) return null;
|
||||
|
||||
|
||||
if (string.IsNullOrEmpty(media.PreviewPath)) return null;
|
||||
|
||||
|
||||
return new MediaDto {
|
||||
MimeType = "image/webp",
|
||||
Path = media.PreviewPath
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MediaDto?> GetConvertedDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
|
||||
var media = await GetVisibleAsset(id, userId, accessLevel, cancellationToken);
|
||||
|
||||
if (media == null) return null;
|
||||
|
||||
if (string.IsNullOrEmpty(media.ConvertedPath) || string.IsNullOrEmpty(media.ConvertedMimeType)) return null;
|
||||
|
||||
return new MediaDto {
|
||||
MimeType = media.ConvertedMimeType,
|
||||
Path = media.ConvertedPath
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() {
|
||||
context.Dispose();
|
||||
personRepository.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Mapper;
|
||||
using Lactose.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -13,19 +14,28 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
public void Insert(Person person) => context.People.Add(person);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save() => context.SaveChanges();
|
||||
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person? Find(Guid id) => context.People.FirstOrDefault(p => p.Id == id);
|
||||
public async Task<TResult?> ExecuteInTransactionAsync<TResult>(Func<Task<TResult?>> operation) {
|
||||
await using var tx = await context.Database.BeginTransactionAsync();
|
||||
var result = await operation();
|
||||
await tx.CommitAsync();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <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
|
||||
public Task<Person?> FindAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
context.People.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Person?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel,
|
||||
string? albumSearch, string? albumSortBy, bool albumSortAsc,
|
||||
int albumPage, int albumPageSize, CancellationToken cancellationToken) {
|
||||
var person = await context.People
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Maintainers)
|
||||
.FirstOrDefault(p => p.Id == id);
|
||||
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||
|
||||
if (person == null) return null;
|
||||
|
||||
@@ -33,7 +43,7 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
bool canSeePerson = accessLevel switch {
|
||||
< EAccessLevel.Maintainer => person.Visibility <= EVisibility.Protected,
|
||||
EAccessLevel.Maintainer when userId.HasValue => person.Visibility <= EVisibility.Protected
|
||||
|| context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id),
|
||||
|| await context.PersonMaintainers.AnyAsync(pm => pm.UserId == userId.Value && pm.PersonId == id, cancellationToken),
|
||||
EAccessLevel.Maintainer => person.Visibility <= EVisibility.Protected,
|
||||
_ => true
|
||||
};
|
||||
@@ -41,7 +51,7 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
if (!canSeePerson) return null;
|
||||
|
||||
bool isMaintainerOfPerson = accessLevel == EAccessLevel.Maintainer && userId.HasValue
|
||||
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id);
|
||||
&& await context.PersonMaintainers.AnyAsync(pm => pm.UserId == userId.Value && pm.PersonId == id, cancellationToken);
|
||||
|
||||
IQueryable<Album> albumsQuery = context.Albums
|
||||
.AsNoTracking()
|
||||
@@ -97,47 +107,47 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
}
|
||||
|
||||
// Total album count (after search and visibility filters, before pagination)
|
||||
person.AlbumTotalCount = ordered.Count();
|
||||
person.AlbumTotalCount = await ordered.CountAsync(cancellationToken);
|
||||
|
||||
// Total visible assets across all visible albums of the person (before search and pagination)
|
||||
if (seesAllAlbums) {
|
||||
person.TotalAssetCount = context.Assets
|
||||
person.TotalAssetCount = await context.Assets
|
||||
.Where(a => a.Albums!.Any(al => al.PersonOwnerId == id))
|
||||
.Count(asset => asset.DeletedAt == null);
|
||||
.CountAsync(asset => asset.DeletedAt == null, cancellationToken);
|
||||
} else {
|
||||
person.TotalAssetCount = context.Assets
|
||||
person.TotalAssetCount = await context.Assets
|
||||
.Where(a => a.Albums!.Any(al => al.PersonOwnerId == id))
|
||||
.Count(asset => asset.DeletedAt == null && (
|
||||
.CountAsync(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)));
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)), cancellationToken);
|
||||
}
|
||||
|
||||
var albumIds = ordered
|
||||
var albumIds = await ordered
|
||||
.Skip(albumPage * albumPageSize)
|
||||
.Take(albumPageSize)
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (albumIds.Count == 0)
|
||||
return person;
|
||||
|
||||
person.Albums = context.Albums
|
||||
person.Albums = await context.Albums
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.CoverAsset)
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Per-album visible asset counts at the database level
|
||||
if (seesAllAlbums) {
|
||||
person.AlbumAssetCounts = context.Albums
|
||||
person.AlbumAssetCounts = await 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);
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken);
|
||||
} else {
|
||||
person.AlbumAssetCounts = context.Albums
|
||||
person.AlbumAssetCounts = await context.Albums
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.Select(a => new {
|
||||
a.Id,
|
||||
@@ -146,7 +156,7 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)))
|
||||
})
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken);
|
||||
}
|
||||
|
||||
var orderMap = albumIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
@@ -156,46 +166,46 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Person> GetAll() =>
|
||||
context.People.Include(p => p.Albums);
|
||||
public Task<List<Person>> GetAllAsync(CancellationToken cancellationToken) =>
|
||||
context.People.Include(p => p.Albums).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel) =>
|
||||
public async Task<List<Person>> GetAllVisibleAsync(Guid userId, EAccessLevel accessLevel, CancellationToken cancellationToken) =>
|
||||
accessLevel switch {
|
||||
// Admin and curator.
|
||||
>= EAccessLevel.Curator => GetAll(),
|
||||
>= EAccessLevel.Curator => await GetAllAsync(cancellationToken),
|
||||
|
||||
EAccessLevel.Maintainer => context.People
|
||||
EAccessLevel.Maintainer => await context.People
|
||||
.Include(p => p.Albums)
|
||||
.Where(p => p.Visibility <= EVisibility.Protected
|
||||
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == p.Id))
|
||||
.ToList(),
|
||||
.ToListAsync(cancellationToken),
|
||||
|
||||
EAccessLevel.User => context.People
|
||||
EAccessLevel.User => await context.People
|
||||
.Include(p => p.Albums)
|
||||
.Where(p => p.Visibility <= EVisibility.Protected)
|
||||
.ToList(),
|
||||
|
||||
.ToListAsync(cancellationToken),
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessLevel), accessLevel, null)
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person? FindByName(string name) =>
|
||||
context.People.FirstOrDefault(p => p.Name == name);
|
||||
public Task<Person?> FindByNameAsync(string name, CancellationToken cancellationToken) =>
|
||||
context.People.FirstOrDefaultAsync(p => p.Name == name, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Person> FindByNames(IEnumerable<string> names) =>
|
||||
context.People.Where(p => names.Contains(p.Name));
|
||||
public Task<List<Person>> FindByNamesAsync(IEnumerable<string> names, CancellationToken cancellationToken) =>
|
||||
context.People.Where(p => names.Contains(p.Name)).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Person> FindBulk(IEnumerable<Guid> ids) =>
|
||||
context.People.Where(p => ids.Contains(p.Id));
|
||||
public Task<List<Person>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken) =>
|
||||
context.People.Where(p => ids.Contains(p.Id)).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Remove(Person person) => context.People.Remove(person);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PersonPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
public async Task<List<PersonPreviewDto>> SearchQueryAsync(string query, int page, int pageSize, string? sortBy, bool sortAsc, Guid userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
|
||||
IQueryable<Person> peopleQuery = context.People;
|
||||
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
@@ -204,12 +214,12 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
// Apply visibility filter
|
||||
peopleQuery = accessLevel switch {
|
||||
EAccessLevel.Admin or EAccessLevel.Curator => peopleQuery,
|
||||
|
||||
|
||||
EAccessLevel.Maintainer => peopleQuery.Where(p => p.Visibility <= EVisibility.Protected
|
||||
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == p.Id)),
|
||||
|
||||
EAccessLevel.User => peopleQuery.Where(p => p.Visibility <= EVisibility.Protected),
|
||||
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessLevel), accessLevel, null)
|
||||
};
|
||||
|
||||
@@ -224,25 +234,25 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
};
|
||||
|
||||
var pageOffset = page * pageSize;
|
||||
var personIds = ordered
|
||||
var personIds = await ordered
|
||||
.Skip(pageOffset)
|
||||
.Take(pageSize)
|
||||
.Select(p => p.Id)
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (personIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var pagedPeople = context.People
|
||||
var pagedPeople = await context.People
|
||||
.AsNoTracking()
|
||||
.Where(p => personIds.Contains(p.Id))
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Album counts via a single grouped query instead of loading every album row
|
||||
var albumCounts = context.People
|
||||
var albumCounts = await context.People
|
||||
.Where(p => personIds.Contains(p.Id))
|
||||
.Select(p => new { p.Id, Count = p.Albums!.Count })
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken);
|
||||
|
||||
var dtos = pagedPeople.Select(p => {
|
||||
var dto = p.ToPersonPreviewDto(accessLevel);
|
||||
@@ -255,12 +265,14 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsMaintainerOf(Guid userId, Guid personId) =>
|
||||
context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == personId);
|
||||
public Task<bool> IsMaintainerOfAsync(Guid userId, Guid personId, CancellationToken cancellationToken) =>
|
||||
context.PersonMaintainers.AnyAsync(pm => pm.UserId == userId && pm.PersonId == personId, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetMaintainers(Guid personId, IEnumerable<Guid> userIds) {
|
||||
var existing = context.PersonMaintainers.Where(pm => pm.PersonId == personId);
|
||||
public async Task SetMaintainersAsync(Guid personId, IEnumerable<Guid> userIds, CancellationToken cancellationToken) {
|
||||
var existing = await context.PersonMaintainers
|
||||
.Where(pm => pm.PersonId == personId)
|
||||
.ToListAsync(cancellationToken);
|
||||
context.PersonMaintainers.RemoveRange(existing);
|
||||
|
||||
foreach (var userId in userIds) {
|
||||
@@ -269,35 +281,35 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void MergePeople(Guid destId, List<Guid> sourceIds) {
|
||||
var dest = context.People
|
||||
public async Task MergePeopleAsync(Guid destId, List<Guid> sourceIds, CancellationToken cancellationToken) {
|
||||
var dest = await context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.Maintainers)
|
||||
.FirstOrDefault(p => p.Id == destId);
|
||||
.FirstOrDefaultAsync(p => p.Id == destId, cancellationToken);
|
||||
if (dest == null) return;
|
||||
|
||||
// Reassign albums
|
||||
var albums = context.Albums.Where(a => a.PersonOwnerId != null && sourceIds.Contains(a.PersonOwnerId.Value)).ToList();
|
||||
var albums = await context.Albums.Where(a => a.PersonOwnerId != null && sourceIds.Contains(a.PersonOwnerId.Value)).ToListAsync(cancellationToken);
|
||||
foreach (var album in albums)
|
||||
album.PersonOwnerId = destId;
|
||||
|
||||
// Reassign faces
|
||||
var faces = context.Faces.Where(f => f.PersonId.HasValue && sourceIds.Contains(f.PersonId.Value)).ToList();
|
||||
var faces = await context.Faces.Where(f => f.PersonId.HasValue && sourceIds.Contains(f.PersonId.Value)).ToListAsync(cancellationToken);
|
||||
foreach (var face in faces)
|
||||
face.PersonId = destId;
|
||||
|
||||
// Reassign maintainers
|
||||
var existingMaintainerIds = dest.Maintainers?.Select(m => m.Id).ToHashSet() ?? [];
|
||||
var sourceMaintainers = context.PersonMaintainers
|
||||
var sourceMaintainers = await context.PersonMaintainers
|
||||
.Where(pm => sourceIds.Contains(pm.PersonId))
|
||||
.ToList();
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var pm in sourceMaintainers) {
|
||||
if (!existingMaintainerIds.Contains(pm.UserId))
|
||||
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = destId, UserId = pm.UserId });
|
||||
}
|
||||
|
||||
// Hard-delete source people
|
||||
var people = context.People.Where(p => sourceIds.Contains(p.Id)).ToList();
|
||||
var people = await context.People.Where(p => sourceIds.Contains(p.Id)).ToListAsync(cancellationToken);
|
||||
context.People.RemoveRange(people);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Data;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
@@ -7,32 +8,33 @@ namespace Lactose.Repositories;
|
||||
/// <inheritdoc />
|
||||
public class SettingsRepository(LactoseDbContext context) : ISettingsRepository {
|
||||
/// <inheritdoc />
|
||||
public void Create(Setting setting) {
|
||||
public async Task CreateAsync(Setting setting, CancellationToken cancellationToken) {
|
||||
//Check if the settings already exist
|
||||
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == setting.Name);
|
||||
if (existingSettings != null)
|
||||
var existingSettings = await context.Settings.FirstOrDefaultAsync(s => s.Name == setting.Name, cancellationToken);
|
||||
if (existingSettings != null)
|
||||
throw new DuplicateNameException("A setting with the same key already exists: " + setting.Name);
|
||||
|
||||
SettingChange(setting);
|
||||
//Adds the new settings
|
||||
context.Settings.Add(setting);
|
||||
context.SaveChanges();
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Setting> Get() => context.Settings.AsEnumerable();
|
||||
public Task<List<Setting>> GetAllAsync(CancellationToken cancellationToken) => context.Settings.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Setting? Get(string name) => context.Settings.FirstOrDefault(s => s.Name == name);
|
||||
public Task<Setting?> GetAsync(string name, CancellationToken cancellationToken) =>
|
||||
context.Settings.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Update(Setting setting) {
|
||||
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == setting.Name);
|
||||
public async Task UpdateAsync(Setting setting, CancellationToken cancellationToken) {
|
||||
var existingSettings = await context.Settings.FirstOrDefaultAsync(s => s.Name == setting.Name, cancellationToken);
|
||||
if (existingSettings != null) {
|
||||
if (existingSettings.Value != setting.Value) SettingChange(setting);
|
||||
existingSettings.Value = setting.Value;
|
||||
context.Settings.Update(existingSettings);
|
||||
context.SaveChanges();
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
} else {
|
||||
// If the settings entry does not exist
|
||||
throw new KeyNotFoundException("The setting with the specified name does not exist: " + setting.Name);
|
||||
@@ -40,37 +42,38 @@ public class SettingsRepository(LactoseDbContext context) : ISettingsRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Delete() {
|
||||
public async Task DeleteAllAsync(CancellationToken cancellationToken) {
|
||||
context.Settings.RemoveRange(context.Settings);
|
||||
context.SaveChanges();
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Delete(Setting setting) {
|
||||
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == setting.Name);
|
||||
public async Task DeleteAsync(Setting setting, CancellationToken cancellationToken) {
|
||||
var existingSettings = await context.Settings.FirstOrDefaultAsync(s => s.Name == setting.Name, cancellationToken);
|
||||
if (existingSettings != null) {
|
||||
context.Settings.Remove(existingSettings);
|
||||
context.SaveChanges();
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
} else {
|
||||
// If the settings entry does not exist
|
||||
throw new KeyNotFoundException("The setting with the specified name does not exist: " + setting.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Delete(string name) {
|
||||
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == name);
|
||||
public async Task DeleteAsync(string name, CancellationToken cancellationToken) {
|
||||
var existingSettings = await context.Settings.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
|
||||
if (existingSettings == null) throw new KeyNotFoundException("The setting with the specified name does not exist: " + name);
|
||||
Delete(existingSettings);
|
||||
context.SaveChanges();
|
||||
context.Settings.Remove(existingSettings);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when a setting value changes.
|
||||
/// </summary>
|
||||
public static event EventHandler<Setting>? SettingChanged;
|
||||
static void OnSettingChanged(Setting e) => SettingChanged?.Invoke(null, e);
|
||||
|
||||
|
||||
|
||||
|
||||
//TODO: May need to be change on a Event based system, because adding here all the Setting Actions could be too much ramification
|
||||
void SettingChange(Setting setting) {
|
||||
OnSettingChanged(setting);
|
||||
@@ -91,4 +94,4 @@ public class SettingsRepository(LactoseDbContext context) : ISettingsRepository
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => context.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ public class StatsRepository(
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StatsDto> GetStatsAsync(CancellationToken cancellationToken) {
|
||||
var thumbnailSizeSetting = settingsRepo.Get(Settings.ThumbnailSize.AsString());
|
||||
var previewSizeSetting = settingsRepo.Get(Settings.PreviewSize.AsString());
|
||||
var thumbnailSizeSetting = await settingsRepo.GetAsync(Settings.ThumbnailSize.AsString(), cancellationToken);
|
||||
var previewSizeSetting = await settingsRepo.GetAsync(Settings.PreviewSize.AsString(), cancellationToken);
|
||||
|
||||
var cacheKey = $"stats:{thumbnailSizeSetting?.Value}:{previewSizeSetting?.Value}";
|
||||
if (cache.TryGetValue(cacheKey, out StatsDto? cached) && cached is not null)
|
||||
@@ -63,8 +63,10 @@ public class StatsRepository(
|
||||
MissingPreviewStale = expectedPreviewSize > 0
|
||||
? g.Count(a => a.PreviewPath != null && a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize)
|
||||
: 0,
|
||||
MissingPhash = g.Count(a => a.Hash == emptyHash)
|
||||
MissingPhash = g.Count(a => a.Hash == emptyHash),
|
||||
Broken = g.Count(a => a.ProcessFailedAt != null)
|
||||
})
|
||||
.OrderBy(x => 1)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (assetStats is not null) {
|
||||
@@ -82,6 +84,7 @@ public class StatsRepository(
|
||||
dto.AssetsMissingPreviews = assetStats.MissingPreview;
|
||||
dto.AssetsMissingPreviewsStale = assetStats.MissingPreviewStale;
|
||||
dto.AssetsMissingPhash = assetStats.MissingPhash;
|
||||
dto.TotalBrokenAssets = assetStats.Broken;
|
||||
}
|
||||
|
||||
var byType = await context.Assets
|
||||
@@ -109,17 +112,26 @@ public class StatsRepository(
|
||||
dto.AlbumsMissingCover = await context.Albums.CountAsync(a => a.CoverAssetId == null, cancellationToken);
|
||||
dto.CosplayersMissingProfile = await context.People.CountAsync(p => p.ProfileAssetId == null, cancellationToken);
|
||||
|
||||
var albumAssetIds = context.Albums.SelectMany(al => al.Assets!).Select(a => a.Id).Distinct();
|
||||
dto.AssetsWithNoAlbum = await context.Assets.CountAsync(
|
||||
a => a.DeletedAt == null && !albumAssetIds.Contains(a.Id), cancellationToken);
|
||||
var albumAsset = context.Set<Dictionary<string, object>>("AlbumAsset");
|
||||
var liveAssetIds = context.Assets.Where(a => a.DeletedAt == null).Select(a => a.Id);
|
||||
|
||||
var personAlbumAssetIds = context.Albums
|
||||
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)
|
||||
.SelectMany(al => al.Assets!)
|
||||
.Select(a => a.Id)
|
||||
.Distinct();
|
||||
dto.AssetsWithNoPerson = await context.Assets.CountAsync(
|
||||
a => a.DeletedAt == null && !personAlbumAssetIds.Contains(a.Id), cancellationToken);
|
||||
.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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -9,25 +10,27 @@ public class TagRepository(LactoseDbContext context) : ITagRepository {
|
||||
public void Dispose() => context.Dispose();
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<Tag> Search(string searchQuery, int page = 0, int pageSize = 200) =>
|
||||
public Task<List<Tag>> SearchAsync(string searchQuery, int page, int pageSize, CancellationToken cancellationToken) =>
|
||||
context.Tags.Where(t => EF.Functions.Like(t.Name, $"%{searchQuery}%"))
|
||||
.OrderByDescending(t => t.Name)
|
||||
.Skip((page) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Tag> GetAll() => context.Tags;
|
||||
public Task<List<Tag>> GetAllAsync(CancellationToken cancellationToken) => context.Tags.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Tag? Find(Guid id) => context.Tags.Find(id);
|
||||
public async Task<Tag?> FindAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
await context.Tags.FindAsync([id], cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Tag? FindByName(string name) => context.Tags.FirstOrDefault(t => t.Name == name);
|
||||
public Task<Tag?> FindByNameAsync(string name, CancellationToken cancellationToken) =>
|
||||
context.Tags.FirstOrDefaultAsync(t => t.Name == name, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<Tag> GetChildren(Guid id) => context.Tags.Where(t=>t.ParentId==id).ToList();
|
||||
public Task<List<Tag>> GetChildrenAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
context.Tags.Where(t => t.ParentId == id).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Delete(Tag tag) => context.Tags.Remove(tag);
|
||||
@@ -36,5 +39,5 @@ public class TagRepository(LactoseDbContext context) : ITagRepository {
|
||||
public void Insert(Tag tag) => context.Tags.Add(tag);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save() => context.SaveChanges();
|
||||
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -9,30 +10,36 @@ public class UserRepository(LactoseDbContext context) : IUserRepository {
|
||||
public void Dispose() => context.Dispose();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<User> GetAll() => context.Users.Include(u => u.MaintainedPersons).AsEnumerable();
|
||||
public Task<List<User>> GetAllAsync(CancellationToken cancellationToken) =>
|
||||
context.Users.Include(u => u.MaintainedPersons).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Insert(User user) => context.Users.Add(user);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save() => context.SaveChanges();
|
||||
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public User? Find(Guid id) => context.Users.Find(id);
|
||||
public async Task<User?> FindAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
await context.Users.FindAsync([id], cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public User? FindByEmail(string email) => context.Users.FirstOrDefault(u => u.Email == email);
|
||||
public Task<User?> FindByEmailAsync(string email, CancellationToken cancellationToken) =>
|
||||
context.Users.FirstOrDefaultAsync(u => u.Email == email, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public User? FindByUsername(string username) => context.Users.FirstOrDefault(u => u.Username == username);
|
||||
public Task<User?> FindByUsernameAsync(string username, CancellationToken cancellationToken) =>
|
||||
context.Users.FirstOrDefaultAsync(u => u.Username == username, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public User? FindWithMaintainedPersons(Guid id) =>
|
||||
context.Users.Include(u => u.MaintainedPersons).FirstOrDefault(u => u.Id == id);
|
||||
public Task<User?> FindWithMaintainedPersonsAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
context.Users.Include(u => u.MaintainedPersons).FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds) {
|
||||
var existing = context.PersonMaintainers.Where(pm => pm.UserId == userId);
|
||||
public async Task SetMaintainedPersonsAsync(Guid userId, IEnumerable<Guid> personIds, CancellationToken cancellationToken) {
|
||||
var existing = await context.PersonMaintainers
|
||||
.Where(pm => pm.UserId == userId)
|
||||
.ToListAsync(cancellationToken);
|
||||
context.PersonMaintainers.RemoveRange(existing);
|
||||
|
||||
foreach (var personId in personIds) {
|
||||
|
||||
@@ -70,6 +70,9 @@ internal class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> p
|
||||
settings.First(s => s.Name == Settings.PreviewPath.AsString()).Value
|
||||
= Path.Combine(Environment.CurrentDirectory, "previews");
|
||||
|
||||
settings.First(s => s.Name == Settings.ConvertedPath.AsString()).Value
|
||||
= Path.Combine(Environment.CurrentDirectory, "converted");
|
||||
|
||||
// set max concurrent jobs to number of processors - 2, at least one. (who the fuck is using a single core cpu?)
|
||||
settings.First(s => s.Name == Settings.MaxConcurrentJobs.AsString()).Value
|
||||
= (Math.Max(Environment.ProcessorCount - 2, 1)).ToString();
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using Butter.Dtos.Asset;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Mapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Lactose.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class FilesystemBrowseService(
|
||||
LactoseDbContext context,
|
||||
ILogger<FilesystemBrowseService> logger
|
||||
) : IFilesystemBrowseService {
|
||||
|
||||
const int LookupChunkSize = 200;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Butter.Dtos.Asset.AssetBrowseResultDto?> BrowseAsync(Guid folderId, string relativePath, int page, int pageSize, bool unlinkedOnly, string? search, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
|
||||
var folder = await context.Folders.FindAsync([folderId], cancellationToken);
|
||||
if (folder == null || string.IsNullOrWhiteSpace(folder.BasePath))
|
||||
return null;
|
||||
|
||||
string root = Path.GetFullPath(folder.BasePath);
|
||||
string normalizedRelative = (relativePath ?? "").Replace('/', Path.DirectorySeparatorChar);
|
||||
string full = Path.GetFullPath(Path.Combine(root, normalizedRelative));
|
||||
|
||||
if (!IsWithinRoot(root, full) || !Directory.Exists(full))
|
||||
return null;
|
||||
|
||||
var directories = Directory.EnumerateDirectories(full)
|
||||
.Select(d => Path.GetFileName(d) ?? "")
|
||||
.Where(n => string.IsNullOrEmpty(search) || n.Contains(search, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(n => new DirectoryEntryDto {
|
||||
Name = n,
|
||||
Path = CombineRelative(relativePath ?? "", n)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var files = Directory.EnumerateFiles(full)
|
||||
.Where(f => string.IsNullOrEmpty(search) || Path.GetFileName(f).Contains(search, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
int total = files.Count;
|
||||
var pageFiles = files.Skip(page * pageSize).Take(pageSize).ToList();
|
||||
|
||||
var found = await LookupAssetsAsync(pageFiles, unlinkedOnly, accessLevel, userId, cancellationToken);
|
||||
var byPath = found.ToDictionary(a => a.Path, a => a.Dto);
|
||||
|
||||
// Preserve the on-disk ordering and only surface files with a database record.
|
||||
var assets = pageFiles
|
||||
.Where(byPath.ContainsKey)
|
||||
.Select(a => byPath[a])
|
||||
.ToList();
|
||||
|
||||
var (unassignedSubdirs, hasUnassignedFilesHere) = await FindUnassignedDirectoriesAsync(full, directories, cancellationToken);
|
||||
foreach (var dir in directories)
|
||||
dir.HasUnassignedAssets = unassignedSubdirs.Contains(dir.Name);
|
||||
|
||||
logger.LogTrace(
|
||||
"directory: folderId={FolderId}, path={Path}, dirs={DirCount}, files={FileCount}, page={Page}, size={PageSize}, matched={Matched}, unassignedDirs={UnassignedDirs}, unassignedHere={UnassignedHere}",
|
||||
folderId, relativePath, directories.Count, files.Count, page, pageSize, assets.Count, unassignedSubdirs.Count, hasUnassignedFilesHere
|
||||
);
|
||||
|
||||
return new Butter.Dtos.Asset.AssetBrowseResultDto {
|
||||
Directories = directories,
|
||||
Assets = assets,
|
||||
TotalAssetCount = total,
|
||||
HasUnassignedFilesHere = hasUnassignedFilesHere
|
||||
};
|
||||
}
|
||||
|
||||
static bool IsWithinRoot(string root, string candidate) {
|
||||
var prefix = root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return candidate == root || candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
static string CombineRelative(string current, string name)
|
||||
=> string.IsNullOrEmpty(current) ? name : $"{current}/{name}";
|
||||
|
||||
/// <summary>
|
||||
/// Determines which of the given immediate subdirectories contain at least one non-deleted asset that is
|
||||
/// not assigned to any album, and whether the current directory itself contains such assets directly.
|
||||
/// Executed as a single parameterized query so each prefix check is an index range scan that short-circuits
|
||||
/// at the first unassigned asset.
|
||||
/// </summary>
|
||||
/// <param name="full">The full on-disk path of the current directory.</param>
|
||||
/// <param name="directories">The immediate subdirectory entries at the current level.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The set of subdirectory names containing unassigned assets, and whether the current level has unassigned files directly.</returns>
|
||||
async Task<(HashSet<string>, bool)> FindUnassignedDirectoriesAsync(string full, List<DirectoryEntryDto> directories, CancellationToken cancellationToken) {
|
||||
HashSet<string> unassignedSubdirs = [];
|
||||
bool hasUnassignedFilesHere = false;
|
||||
|
||||
char sep = Path.DirectorySeparatorChar;
|
||||
string curPrefix = full.TrimEnd(sep) + sep;
|
||||
|
||||
var items = new List<(string Kind, string Path)>();
|
||||
foreach (var dir in directories)
|
||||
items.Add(("dir", curPrefix + dir.Name + sep));
|
||||
items.Add(("here", ""));
|
||||
|
||||
var sql = new System.Text.StringBuilder();
|
||||
sql.Append("WITH items(kind, path) AS (VALUES ");
|
||||
var parms = new List<object>();
|
||||
for (int i = 0; i < items.Count; i++) {
|
||||
if (i > 0) sql.Append(", ");
|
||||
sql.Append($"({{{i * 2}}}, {{{i * 2 + 1}}})");
|
||||
parms.Add(items[i].Kind);
|
||||
parms.Add(items[i].Path);
|
||||
}
|
||||
int k = items.Count * 2;
|
||||
sql.Append(") SELECT i.kind AS \"Kind\", i.path AS \"Path\" FROM items i ");
|
||||
sql.Append("WHERE EXISTS (");
|
||||
sql.Append("SELECT 1 FROM \"Assets\" a ");
|
||||
sql.Append("WHERE a.\"DeletedAt\" IS NULL ");
|
||||
sql.Append("AND NOT EXISTS (SELECT 1 FROM \"AlbumAsset\" aa WHERE aa.\"AssetsId\" = a.\"Id\") ");
|
||||
sql.Append("AND CASE WHEN i.kind = 'dir' THEN a.\"OriginalPath\" LIKE i.path || '%' ESCAPE '' ");
|
||||
sql.Append($"ELSE a.\"OriginalPath\" LIKE {{{k}}} || '%' ESCAPE '' AND a.\"OriginalPath\" NOT LIKE {{{k}}} || '%' || {{{k + 1}}} || '%' ESCAPE '' END)");
|
||||
parms.Add(curPrefix);
|
||||
parms.Add(sep.ToString());
|
||||
|
||||
var rows = await context.Database
|
||||
.SqlQueryRaw<UnassignedFlagRow>(sql.ToString(), parms.ToArray())
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var row in rows) {
|
||||
if (row.Kind == "here") {
|
||||
hasUnassignedFilesHere = true;
|
||||
} else {
|
||||
string name = row.Path.Substring(curPrefix.Length).TrimEnd(sep);
|
||||
unassignedSubdirs.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return (unassignedSubdirs, hasUnassignedFilesHere);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Row shape for the unassigned-asset highlight query.
|
||||
/// </summary>
|
||||
public sealed class UnassignedFlagRow {
|
||||
/// <summary>
|
||||
/// Gets or sets the item kind: <c>dir</c> for a subdirectory prefix, or <c>here</c> for the current level.
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = "";
|
||||
/// <summary>
|
||||
/// Gets or sets the full on-disk path prefix for a <c>dir</c> item (unused for <c>here</c>).
|
||||
/// </summary>
|
||||
public string Path { get; set; } = "";
|
||||
}
|
||||
|
||||
async Task<List<(string Path, AssetPreviewDto Dto)>> LookupAssetsAsync(List<string> paths, bool unlinkedOnly, EAccessLevel accessLevel, Guid? userId, CancellationToken cancellationToken) {
|
||||
var result = new List<(string, AssetPreviewDto)>();
|
||||
foreach (var chunk in paths.Chunk(LookupChunkSize)) {
|
||||
var found = await context.Assets
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums!).ThenInclude(al => al.PersonOwner)
|
||||
.Where(a => chunk.Contains(a.OriginalPath) && a.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (unlinkedOnly)
|
||||
found = found.Where(a => a.Albums == null || a.Albums.Count == 0).ToList();
|
||||
|
||||
result.AddRange(found.Select(a => (a.OriginalPath, a.ToAssetPreviewDto(accessLevel, userId))));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Butter.Dtos.Asset;
|
||||
using Butter.Types;
|
||||
|
||||
namespace Lactose.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Browses the filesystem directly for maintenance purposes, looking up database
|
||||
/// asset records for the files found on disk.
|
||||
/// </summary>
|
||||
public interface IFilesystemBrowseService {
|
||||
/// <summary>
|
||||
/// Browses a folder's filesystem at the given relative path, returning immediate
|
||||
/// subdirectories and the assets recorded in the database for the files at that level.
|
||||
/// </summary>
|
||||
/// <param name="folderId">The ID of the scan folder to browse within.</param>
|
||||
/// <param name="relativePath">The path relative to the folder root (empty for root, forward slashes).</param>
|
||||
/// <param name="page">The zero-based page number for asset pagination.</param>
|
||||
/// <param name="pageSize">The number of assets per page.</param>
|
||||
/// <param name="unlinkedOnly">If true, only assets not assigned to any album are returned.</param>
|
||||
/// <param name="search">Optional filename search term.</param>
|
||||
/// <param name="userId">The requesting user's ID (unused for admins/curators).</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A browse result, or null if the folder does not exist or the path escapes the folder root.</returns>
|
||||
Task<Butter.Dtos.Asset.AssetBrowseResultDto?> BrowseAsync(Guid folderId, string relativePath, int page, int pageSize, bool unlinkedOnly, string? search, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -17,44 +17,47 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
||||
JobManager? jobManager;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||
var lifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
|
||||
|
||||
var service = new Task(() => {
|
||||
logger?.LogInformation("Preparing JobScheduler...");
|
||||
jobManager = serviceProvider.GetRequiredService<JobManager>();
|
||||
logger?.LogInformation("JobScheduler started!");
|
||||
// fetch settings on startup
|
||||
FetchSettings();
|
||||
// listen for settings changes
|
||||
SettingsRepository.SettingChanged += OnSettingsChanged;
|
||||
// keep the task alive until the service is stopping
|
||||
stoppingToken.WaitHandle.WaitOne();
|
||||
}, stoppingToken
|
||||
);
|
||||
|
||||
lifetime.ApplicationStarted.Register(() => service.Start());
|
||||
return service;
|
||||
|
||||
// wait until the application has started before preparing the scheduler
|
||||
var startedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
lifetime.ApplicationStarted.Register(() => startedTcs.SetResult());
|
||||
|
||||
try {
|
||||
await startedTcs.Task;
|
||||
logger?.LogInformation("Preparing JobScheduler...");
|
||||
jobManager = serviceProvider.GetRequiredService<JobManager>();
|
||||
logger?.LogInformation("JobScheduler started!");
|
||||
// fetch settings on startup
|
||||
await FetchSettingsAsync(stoppingToken);
|
||||
// listen for settings changes
|
||||
SettingsRepository.SettingChanged += OnSettingsChanged;
|
||||
// keep the service alive until the host is stopping
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
} catch (OperationCanceledException) {
|
||||
// normal shutdown
|
||||
}
|
||||
}
|
||||
|
||||
void FetchSettings() {
|
||||
async Task FetchSettingsAsync(CancellationToken cancellationToken = default) {
|
||||
logger?.LogInformation("Fetching settings...");
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var settingsRepository = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
|
||||
var prevFolderScanEnabled = folderScanEnabled;
|
||||
// check if folder scanning is enabled
|
||||
folderScanEnabled = bool.Parse(settingsRepository.Get(Settings.FolderScanEnabled.AsString())?.Value ?? "false");
|
||||
|
||||
folderScanEnabled = bool.Parse((await settingsRepository.GetAsync(Settings.FolderScanEnabled.AsString(), cancellationToken))?.Value ?? "false");
|
||||
|
||||
if (folderScanEnabled && !prevFolderScanEnabled) {
|
||||
FolderRepository.FolderAdded += OnFolderAdded;
|
||||
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));
|
||||
int.TryParse((await settingsRepository.GetAsync(Settings.FolderScanInterval.AsString(), cancellationToken))?.Value ?? "30", out var 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>();
|
||||
folders = folderRepository.GetAll().Where(f => f.Active).ToList();
|
||||
folders = (await folderRepository.GetAllAsync(cancellationToken)).Where(f => f.Active).ToList();
|
||||
} else if(!folderScanEnabled && prevFolderScanEnabled) {
|
||||
FolderRepository.FolderAdded -= OnFolderAdded;
|
||||
FolderRepository.FolderRemoved -= OnFolderRemoved;
|
||||
@@ -63,13 +66,13 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
||||
logger?.LogInformation("Periodic folder scanning is now disabled.");
|
||||
} else if (folderScanEnabled && prevFolderScanEnabled) {
|
||||
// update interval
|
||||
int.TryParse(settingsRepository.Get(Settings.FolderScanInterval.AsString())?.Value ?? "30", out var interval);
|
||||
int.TryParse((await settingsRepository.GetAsync(Settings.FolderScanInterval.AsString(), cancellationToken))?.Value ?? "30", out var interval);
|
||||
fileSystemCrawl?.Change(TimeSpan.Zero, TimeSpan.FromMinutes(interval));
|
||||
logger?.LogInformation($"Periodic folder scanning interval updated to {interval} minutes.");
|
||||
}
|
||||
|
||||
// Job cleanup timer
|
||||
int.TryParse(settingsRepository.Get(Settings.JobRetentionDays.AsString())?.Value, out var retentionDays);
|
||||
int.TryParse((await settingsRepository.GetAsync(Settings.JobRetentionDays.AsString(), cancellationToken))?.Value, out var retentionDays);
|
||||
if (retentionDays > 0) {
|
||||
if (jobCleanup == null) {
|
||||
jobCleanup = new Timer(RunJobCleanup, null, TimeSpan.Zero, TimeSpan.FromHours(1));
|
||||
@@ -81,34 +84,70 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
||||
logger?.LogInformation("Job cleanup is now disabled (retention set to 0 or invalid).");
|
||||
}
|
||||
|
||||
EnsureJobSettings(settingsRepository);
|
||||
await EnsureJobSettingsAsync(settingsRepository, cancellationToken);
|
||||
}
|
||||
|
||||
void EnsureJobSettings(ISettingsRepository settingsRepository) {
|
||||
async Task EnsureJobSettingsAsync(ISettingsRepository settingsRepository, CancellationToken cancellationToken) {
|
||||
var batchSizeName = Settings.JobBatchSize.AsString();
|
||||
if (settingsRepository.Get(batchSizeName) == null) {
|
||||
settingsRepository.Create(new Setting {
|
||||
if (await settingsRepository.GetAsync(batchSizeName, cancellationToken) == null) {
|
||||
await settingsRepository.CreateAsync(new Setting {
|
||||
Name = batchSizeName,
|
||||
Value = "200",
|
||||
Description = "Number of assets to process per sub-job batch.",
|
||||
Type = EType.Integer,
|
||||
DisplayType = DisplayType.IntNumber
|
||||
});
|
||||
}, cancellationToken);
|
||||
logger?.LogInformation("Seeded default setting: {Name} = 200.", batchSizeName);
|
||||
}
|
||||
|
||||
var convertedPathName = Settings.ConvertedPath.AsString();
|
||||
if (await settingsRepository.GetAsync(convertedPathName, cancellationToken) == null) {
|
||||
await settingsRepository.CreateAsync(new Setting {
|
||||
Name = convertedPathName,
|
||||
Value = Path.Combine(Environment.CurrentDirectory, "converted"),
|
||||
Description = "Path where converted videos for animated assets (GIF -> mp4) are stored.",
|
||||
Type = EType.String,
|
||||
DisplayType = DisplayType.Text
|
||||
}, cancellationToken);
|
||||
logger?.LogInformation("Seeded default setting: {Name} = {Value}.", convertedPathName, Path.Combine(Environment.CurrentDirectory, "converted"));
|
||||
}
|
||||
|
||||
var convertedFormatName = Settings.ConvertedFormat.AsString();
|
||||
if (await settingsRepository.GetAsync(convertedFormatName, cancellationToken) == null) {
|
||||
await settingsRepository.CreateAsync(new Setting {
|
||||
Name = convertedFormatName,
|
||||
Value = "mp4",
|
||||
Description = "Container format for converted videos (e.g. mp4).",
|
||||
Type = EType.String,
|
||||
DisplayType = DisplayType.Text
|
||||
}, cancellationToken);
|
||||
logger?.LogInformation("Seeded default setting: {Name} = mp4.", convertedFormatName);
|
||||
}
|
||||
|
||||
var ffmpegPathName = Settings.FfmpegPath.AsString();
|
||||
if (await settingsRepository.GetAsync(ffmpegPathName, cancellationToken) == null) {
|
||||
await settingsRepository.CreateAsync(new Setting {
|
||||
Name = ffmpegPathName,
|
||||
Value = "ffmpeg",
|
||||
Description = "Path to the ffmpeg executable. Defaults to 'ffmpeg' on PATH.",
|
||||
Type = EType.String,
|
||||
DisplayType = DisplayType.Text
|
||||
}, cancellationToken);
|
||||
logger?.LogInformation("Seeded default setting: {Name} = ffmpeg.", ffmpegPathName);
|
||||
}
|
||||
}
|
||||
|
||||
void RunJobCleanup(object? state) {
|
||||
async void RunJobCleanup(object? state) {
|
||||
try {
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var settingsRepository = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
|
||||
int.TryParse(settingsRepository.Get(Settings.JobRetentionDays.AsString())?.Value, out var retentionDays);
|
||||
int.TryParse((await settingsRepository.GetAsync(Settings.JobRetentionDays.AsString(), CancellationToken.None))?.Value, out var retentionDays);
|
||||
if (retentionDays <= 0) return;
|
||||
|
||||
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
|
||||
var deleted = repo.DeleteOldJobs(cutoff);
|
||||
repo.Save();
|
||||
var deleted = await repo.DeleteOldJobsAsync(cutoff, CancellationToken.None);
|
||||
await repo.SaveAsync(CancellationToken.None);
|
||||
|
||||
if (jobManager != null) {
|
||||
jobManager.CleanupPastJobs(cutoff);
|
||||
@@ -131,24 +170,30 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
||||
logger?.LogInformation($"Folder removed: {e.Id}");
|
||||
}
|
||||
|
||||
void OnSettingsChanged(object? sender, Setting e) {
|
||||
var name = e.Name;
|
||||
if (name == Settings.FolderScanEnabled.AsString()
|
||||
|| name == Settings.FolderScanInterval.AsString()
|
||||
|| name == Settings.JobRetentionDays.AsString()) {
|
||||
FetchSettings();
|
||||
}
|
||||
if (name == Settings.MaxConcurrentJobs.AsString() && jobManager != null && int.TryParse(e.Value, out var maxJobs) && maxJobs > 0) {
|
||||
jobManager.MaxConcurrentJobs = maxJobs;
|
||||
logger?.LogInformation("MaxConcurrentJobs updated to {Value}.", maxJobs);
|
||||
async void OnSettingsChanged(object? sender, Setting e) {
|
||||
try {
|
||||
var name = e.Name;
|
||||
if (name == Settings.FolderScanEnabled.AsString()
|
||||
|| name == Settings.FolderScanInterval.AsString()
|
||||
|| name == Settings.JobRetentionDays.AsString()) {
|
||||
await FetchSettingsAsync();
|
||||
}
|
||||
if (name == Settings.MaxConcurrentJobs.AsString() && jobManager != null && int.TryParse(e.Value, out var maxJobs) && maxJobs > 0) {
|
||||
jobManager.MaxConcurrentJobs = maxJobs;
|
||||
logger?.LogInformation("MaxConcurrentJobs updated to {Value}.", maxJobs);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
logger?.LogWarning(ex, "Failed to apply settings change for {SettingName}.", e.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
internal async Task QueueFileSystemCrawlAsync(bool deep, IReadOnlyList<Guid>? folderIds = null, CancellationToken cancellationToken = default) {
|
||||
if (jobManager == null) {
|
||||
logger?.LogWarning("JobManager is not available, cannot queue file system crawl.");
|
||||
return;
|
||||
@@ -158,19 +203,42 @@ 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;
|
||||
}
|
||||
// if the scheduled scan list isn't populated, fetch active folders directly from DB
|
||||
var targetFolders = folders;
|
||||
if (targetFolders == null || targetFolders.Count == 0) {
|
||||
|
||||
List<Folder>? targetFolders;
|
||||
if (folderIds is { Count: > 0 }) {
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
|
||||
targetFolders = folderRepo.GetAll().Where(f => f.Active).ToList();
|
||||
targetFolders = (await folderRepo.GetAllAsync(cancellationToken)).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 = (await folderRepo.GetAllAsync(cancellationToken)).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>
|
||||
async void QueueFileSystemCrawlTimer(object? state) {
|
||||
try {
|
||||
await QueueFileSystemCrawlAsync(false);
|
||||
} catch (Exception ex) {
|
||||
logger?.LogWarning(ex, "Scheduled file system crawl failed to queue.");
|
||||
}
|
||||
// queue a crawl job for each active folder
|
||||
targetFolders.ForEach(f => { jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath)); });
|
||||
if (targetFolders.Count > 0)
|
||||
logger?.LogInformation("Queued {Count} file system crawl job(s).", targetFolders.Count);
|
||||
else
|
||||
logger?.LogWarning("No active folders to scan.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -246,27 +314,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;
|
||||
@@ -275,8 +327,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>
|
||||
@@ -296,4 +348,22 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
||||
jobManager.EnqueueJob(jobManager.CreateJob<PHashJob>());
|
||||
logger?.LogInformation("Queued pHash job.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues an animated asset conversion job (GIF → video + first-frame thumbnail).
|
||||
/// </summary>
|
||||
/// <param name="o">Unused state parameter.</param>
|
||||
internal void QueueAnimatedConversion(object? o) {
|
||||
if (jobManager == null) {
|
||||
logger?.LogWarning("JobManager is not available, cannot queue animated conversion.");
|
||||
return;
|
||||
}
|
||||
// don't queue if there's already an animated conversion job in progress
|
||||
if (jobManager.Jobs.Any(j => j is ConvertAnimatedJob && j.JobStatus.Status is EJobStatus.Running or EJobStatus.Queued)) {
|
||||
logger?.LogInformation("Animated conversion job is already in progress, cannot queue another at this moment.");
|
||||
return;
|
||||
}
|
||||
jobManager.EnqueueJob(jobManager.CreateJob<ConvertAnimatedJob>());
|
||||
logger?.LogInformation("Queued animated conversion job.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -939,6 +939,123 @@ GET {{WepApiTest_HostAddress}}/api/asset?page=0&pageSize=5
|
||||
});
|
||||
%}
|
||||
|
||||
# =============================================================================
|
||||
# MAINTENANCE TESTS — broken report, retry, filesystem browse, animated job
|
||||
# =============================================================================
|
||||
|
||||
### 64.1 Get broken assets as admin
|
||||
GET {{WepApiTest_HostAddress}}/api/asset/broken?page=0&pageSize=5
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
client.test("Get broken assets as admin", function () {
|
||||
client.assert(response.status === 200);
|
||||
client.assert(response.headers.get("X-Total-Count") !== null, "X-Total-Count header should be present");
|
||||
});
|
||||
%}
|
||||
|
||||
### 64.2 Get broken assets as regular user (should 403)
|
||||
GET {{WepApiTest_HostAddress}}/api/asset/broken?page=0&pageSize=5
|
||||
Authorization: Bearer {{user_token}}
|
||||
|
||||
> {%
|
||||
client.test("Get broken assets as regular user (should 403)", function () {
|
||||
client.assert(response.status === 403)
|
||||
});
|
||||
%}
|
||||
|
||||
### 64.3 Get broken assets with invalid pagination (should 400)
|
||||
GET {{WepApiTest_HostAddress}}/api/asset/broken?page=-1&pageSize=5
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
client.test("Get broken assets with invalid pagination (should 400)", function () {
|
||||
client.assert(response.status === 400)
|
||||
});
|
||||
%}
|
||||
|
||||
### 64.4 Retry broken assets with empty list (should 400)
|
||||
POST {{WepApiTest_HostAddress}}/api/asset/broken/retry
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
|
||||
> {%
|
||||
client.test("Retry broken assets with empty list (should 400)", function () {
|
||||
client.assert(response.status === 400)
|
||||
});
|
||||
%}
|
||||
|
||||
### 64.5 Retry broken assets as regular user (should 403)
|
||||
POST {{WepApiTest_HostAddress}}/api/asset/broken/retry
|
||||
Authorization: Bearer {{user_token}}
|
||||
Content-Type: application/json
|
||||
|
||||
["00000000-0000-0000-0000-000000000001"]
|
||||
|
||||
> {%
|
||||
client.test("Retry broken assets as regular user (should 403)", function () {
|
||||
client.assert(response.status === 403)
|
||||
});
|
||||
%}
|
||||
|
||||
### 64.6 Filesystem browse non-existent folder (should 404)
|
||||
GET {{WepApiTest_HostAddress}}/api/asset/directory?folderId=00000000-0000-0000-0000-000000000001&path=&page=0&pageSize=5
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
client.test("Filesystem browse non-existent folder (should 404)", function () {
|
||||
client.assert(response.status === 404)
|
||||
});
|
||||
%}
|
||||
|
||||
### 64.7 Filesystem browse invalid pagination (should 400)
|
||||
GET {{WepApiTest_HostAddress}}/api/asset/directory?folderId=00000000-0000-0000-0000-000000000001&path=&page=0&pageSize=0
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
client.test("Filesystem browse invalid pagination (should 400)", function () {
|
||||
client.assert(response.status === 400)
|
||||
});
|
||||
%}
|
||||
|
||||
### 64.8 Filesystem browse as regular user (should 403)
|
||||
GET {{WepApiTest_HostAddress}}/api/asset/directory?folderId=00000000-0000-0000-0000-000000000001&path=&page=0&pageSize=5
|
||||
Authorization: Bearer {{user_token}}
|
||||
|
||||
> {%
|
||||
client.test("Filesystem browse as regular user (should 403)", function () {
|
||||
client.assert(response.status === 403)
|
||||
});
|
||||
%}
|
||||
|
||||
### 64.9 Enqueue animated conversion job as admin
|
||||
PUT {{WepApiTest_HostAddress}}/api/jobs
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
|
||||
{ "jobType": "AnimatedConversion", "parameters": [] }
|
||||
|
||||
> {%
|
||||
client.test("Enqueue animated conversion job as admin", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
%}
|
||||
|
||||
### 64.10 Enqueue animated conversion job as regular user (should 403)
|
||||
PUT {{WepApiTest_HostAddress}}/api/jobs
|
||||
Authorization: Bearer {{user_token}}
|
||||
Content-Type: application/json
|
||||
|
||||
{ "jobType": "AnimatedConversion", "parameters": [] }
|
||||
|
||||
> {%
|
||||
client.test("Enqueue animated conversion job as regular user (should 403)", function () {
|
||||
client.assert(response.status === 403)
|
||||
});
|
||||
%}
|
||||
|
||||
# =============================================================================
|
||||
# STATS TESTS
|
||||
# =============================================================================
|
||||
@@ -2061,6 +2178,150 @@ GET {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
|
||||
}
|
||||
%}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Restore soft-deleted assets (issue #198)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
### 131.1 Admin soft-deletes the test asset (IsDeleted = true)
|
||||
POST {{WepApiTest_HostAddress}}/api/asset
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"ids": ["{{testAssetId}}"],
|
||||
"data": { "isDeleted": true }
|
||||
}
|
||||
|
||||
> {%
|
||||
var hasAssets = client.global.get("hasAssets");
|
||||
if (hasAssets === "true") {
|
||||
client.test("Soft-delete asset via IsDeleted flag", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
} else {
|
||||
client.test("No assets — skipping soft-delete test", function () {
|
||||
client.assert(true)
|
||||
});
|
||||
}
|
||||
%}
|
||||
|
||||
### 131.2 Admin: deletedOnly search lists the soft-deleted asset
|
||||
GET {{WepApiTest_HostAddress}}/api/asset?deletedOnly=true&page=0&pageSize=50&includeCount=true
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
var hasAssets = client.global.get("hasAssets");
|
||||
if (hasAssets === "true") {
|
||||
client.test("Admin sees deleted assets in deletedOnly search", function () {
|
||||
client.assert(response.status === 200);
|
||||
var total = parseInt(response.headers.valueOf("X-Total-Count"));
|
||||
client.assert(total >= 1, "Expected at least one deleted asset but got total " + total);
|
||||
});
|
||||
} else {
|
||||
client.test("No assets — skipping deletedOnly search test", function () {
|
||||
client.assert(true)
|
||||
});
|
||||
}
|
||||
%}
|
||||
|
||||
### 131.3 Regular user: deletedOnly search returns nothing (R7 ? users never see deleted)
|
||||
GET {{WepApiTest_HostAddress}}/api/asset?deletedOnly=true&page=0&pageSize=50&includeCount=true
|
||||
Authorization: Bearer {{user_token}}
|
||||
|
||||
> {%
|
||||
client.test("Regular user gets empty deletedOnly results", function () {
|
||||
client.assert(response.status === 200);
|
||||
client.assert(response.body.length === 0, "Expected no deleted assets for regular user");
|
||||
});
|
||||
%}
|
||||
|
||||
### 131.4 Curator: deletedOnly search succeeds (scoped to own uploads by R7)
|
||||
GET {{WepApiTest_HostAddress}}/api/asset?deletedOnly=true&page=0&pageSize=50&includeCount=true
|
||||
Authorization: Bearer {{curator_token}}
|
||||
|
||||
> {%
|
||||
client.test("Curator can query deletedOnly search", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
%}
|
||||
|
||||
### 131.5 Anonymous: restore attempt should 401
|
||||
POST {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"isDeleted": false
|
||||
}
|
||||
|
||||
> {%
|
||||
client.test("Anonymous restore attempt is rejected", function () {
|
||||
client.assert([401, 403].indexOf(response.status) !== -1, "Expected 401/403 but got " + response.status);
|
||||
});
|
||||
%}
|
||||
|
||||
### 131.6 Regular user: restore attempt should 403
|
||||
POST {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
|
||||
Authorization: Bearer {{user_token}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"isDeleted": false
|
||||
}
|
||||
|
||||
> {%
|
||||
client.test("Regular user restore attempt is rejected", function () {
|
||||
client.assert(response.status === 403, "Expected 403 but got " + response.status);
|
||||
});
|
||||
%}
|
||||
|
||||
### 131.7 Admin: bulk restore via update endpoint (IsDeleted = false)
|
||||
# Assets whose source files are missing on disk are reported in skippedMissingFile
|
||||
# instead of being restored; a bare 200 therefore always indicates a valid outcome.
|
||||
POST {{WepApiTest_HostAddress}}/api/asset
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"ids": ["{{testAssetId}}"],
|
||||
"data": { "isDeleted": false }
|
||||
}
|
||||
|
||||
> {%
|
||||
var hasAssets = client.global.get("hasAssets");
|
||||
if (hasAssets === "true") {
|
||||
client.test("Bulk restore reports restored/skipped lists", function () {
|
||||
client.assert(response.status === 200);
|
||||
client.assert(jsonPath(response.body, "$.restored") != null, "Missing 'restored' list");
|
||||
client.assert(jsonPath(response.body, "$.skippedMissingFile") != null, "Missing 'skippedMissingFile' list");
|
||||
});
|
||||
var restored = response.body.restored;
|
||||
client.global.set("assetRestored", restored != null && restored.indexOf(client.global.get("testAssetId")) !== -1 ? "true" : "false");
|
||||
} else {
|
||||
client.global.set("assetRestored", "false");
|
||||
client.test("No assets — skipping bulk restore test", function () {
|
||||
client.assert(true)
|
||||
});
|
||||
}
|
||||
%}
|
||||
|
||||
### 131.8 Verify restored asset is live again (skipped when its file was missing on disk)
|
||||
GET {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
var assetRestored = client.global.get("assetRestored");
|
||||
if (assetRestored === "true") {
|
||||
client.test("Restored asset no longer reports DeletedAt", function () {
|
||||
client.assert(response.status === 200);
|
||||
client.assert(response.body.deletedAt === null, "deletedAt should be cleared after restore");
|
||||
});
|
||||
} else {
|
||||
client.test("Asset not restored (missing file) — skipping verification", function () {
|
||||
client.assert(true)
|
||||
});
|
||||
}
|
||||
%}
|
||||
|
||||
# =============================================================================
|
||||
# CLEANUP
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@inject LoginService loginService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@if (_initialized) {
|
||||
@if (initialized) {
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
|
||||
@@ -16,11 +16,11 @@
|
||||
}
|
||||
|
||||
@code {
|
||||
bool _initialized;
|
||||
bool initialized;
|
||||
|
||||
protected async override Task OnInitializedAsync() {
|
||||
await loginService.InitializeAsync();
|
||||
_initialized = true;
|
||||
initialized = true;
|
||||
|
||||
if (loginService.ForceLogoutReason != null)
|
||||
NavigationManager.NavigateToLoginForce();
|
||||
|
||||
@@ -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,41 +37,48 @@
|
||||
<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)
|
||||
@if (patternExpanded)
|
||||
{
|
||||
<hr class="my-2"/>
|
||||
<div class="regex-section">
|
||||
<div class="mb-1 small text-muted">Regex Pattern</div>
|
||||
|
||||
@* Syntax-highlighted preview *@
|
||||
<div class="regex-highlight mb-1">@_highlightedPattern</div>
|
||||
<div class="regex-highlight mb-1">@highlightedPattern</div>
|
||||
|
||||
@* Regex input *@
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input class="form-control form-control-sm font-monospace"
|
||||
type="text"
|
||||
placeholder="e.g. (?<person>\w+)/(?<group>\d{4})"
|
||||
@bind="_localPattern"
|
||||
@bind="localPattern"
|
||||
@bind:event="oninput"/>
|
||||
<button class="btn btn-sm btn-success"
|
||||
@onclick="OnSavePattern"
|
||||
disabled="@(_localPattern == Folder.RegexPattern)">
|
||||
disabled="@(localPattern == Folder.RegexPattern)">
|
||||
<i class="bi bi-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* Sample paths *@
|
||||
@if (_samplePaths != null && _samplePaths.Count > 0)
|
||||
@if (samplePaths != null && samplePaths.Count > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="small text-muted">Sample paths (@_samplePaths.Count)</span>
|
||||
<span class="small text-muted">Sample paths (@samplePaths.Count)</span>
|
||||
<button class="btn btn-sm btn-secondary py-0 px-1" @onclick="LoadSamplePaths" title="Refresh samples">
|
||||
<i class="bi bi-arrow-repeat small"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sample-paths">
|
||||
@foreach (var path in _samplePaths)
|
||||
@foreach (var path in samplePaths)
|
||||
{
|
||||
var matchResult = TryMatch(path);
|
||||
<div class="sample-path-row mb-1 @(matchResult == null ? "no-match" : "")">
|
||||
@@ -91,7 +112,7 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (_samplePaths != null)
|
||||
else if (samplePaths != null)
|
||||
{
|
||||
<div class="small text-muted mb-1">No assets in this folder yet. Sample paths appear after scanning.</div>
|
||||
}
|
||||
@@ -101,15 +122,17 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
bool _editing;
|
||||
bool _patternExpanded;
|
||||
string _localPattern = string.Empty;
|
||||
List<string>? _samplePaths;
|
||||
string Display => _editing ? "d-none" : "";
|
||||
string Edit => _editing ? "" : "d-none";
|
||||
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";
|
||||
string PatternBadgeClass => !string.IsNullOrEmpty(Folder.RegexPattern) ? "pattern-active" : "";
|
||||
|
||||
MarkupString _highlightedPattern => RegexHighlighter.Highlight(_localPattern);
|
||||
MarkupString highlightedPattern => RegexHighlighter.Highlight(localPattern);
|
||||
|
||||
[Parameter]
|
||||
public FolderFullDto Folder { get; set; } = new FolderFullDto();
|
||||
@@ -119,24 +142,24 @@
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!_patternExpanded)
|
||||
_localPattern = Folder.RegexPattern ?? string.Empty;
|
||||
if (!patternExpanded)
|
||||
localPattern = Folder.RegexPattern ?? string.Empty;
|
||||
}
|
||||
|
||||
void TogglePatternSection()
|
||||
{
|
||||
_patternExpanded = !_patternExpanded;
|
||||
if (_patternExpanded)
|
||||
patternExpanded = !patternExpanded;
|
||||
if (patternExpanded)
|
||||
{
|
||||
_localPattern = Folder.RegexPattern ?? string.Empty;
|
||||
if (_samplePaths == null)
|
||||
localPattern = Folder.RegexPattern ?? string.Empty;
|
||||
if (samplePaths == null)
|
||||
_ = LoadSamplePaths();
|
||||
}
|
||||
}
|
||||
|
||||
void OnEdit()
|
||||
{
|
||||
_editing = true;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
async Task OnConfirmEdit()
|
||||
@@ -149,7 +172,7 @@
|
||||
RegexPattern = Folder.RegexPattern
|
||||
}
|
||||
);
|
||||
_editing = false;
|
||||
editing = false;
|
||||
}
|
||||
|
||||
async Task OnDelete()
|
||||
@@ -157,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(
|
||||
@@ -171,7 +215,7 @@
|
||||
|
||||
async Task OnSavePattern()
|
||||
{
|
||||
Folder.RegexPattern = _localPattern;
|
||||
Folder.RegexPattern = localPattern;
|
||||
await FoldersService.UpdateFolderAsync(
|
||||
Folder.Id,
|
||||
new FolderUpdateDto() {
|
||||
@@ -185,17 +229,17 @@
|
||||
async Task LoadSamplePaths()
|
||||
{
|
||||
var result = await FoldersService.GetSamplePathsAsync(Folder.Id);
|
||||
_samplePaths = result?.Paths ?? new List<string>();
|
||||
samplePaths = result?.Paths ?? new List<string>();
|
||||
}
|
||||
|
||||
MatchResult? TryMatch(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_localPattern))
|
||||
if (string.IsNullOrEmpty(localPattern))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var regex = new Regex(_localPattern, RegexOptions.None, TimeSpan.FromMilliseconds(500));
|
||||
var regex = new Regex(localPattern, RegexOptions.None, TimeSpan.FromMilliseconds(500));
|
||||
var match = regex.Match(path);
|
||||
if (!match.Success) return null;
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
<span class="text-truncate text-muted small text-end d-none d-sm-inline">@(Job.Started?.ToLocalTime().ToString("g")) — @(Job.Finished?.ToLocalTime().ToString("g"))</span>
|
||||
} else {
|
||||
<span class="flex-shrink-0" style="width: 90px;">
|
||||
@if (_segments is not null) {
|
||||
<ProgressBar Segments="@_segments" />
|
||||
@if (segments is not null) {
|
||||
<ProgressBar Segments="@segments" />
|
||||
} else {
|
||||
<ProgressBar Value="@Job.Progress" Color="@StatusColor" />
|
||||
}
|
||||
@@ -65,8 +65,8 @@
|
||||
<span class="d-inline-block rounded-circle flex-shrink-0" style="width: 6px; height: 6px; background: @DotColor"></span>
|
||||
<span class="small text-muted">@StatusLabel</span>
|
||||
<span class="flex-shrink-0" style="width: 70px;">
|
||||
@if (_segments is not null) {
|
||||
<ProgressBar Segments="@_segments" />
|
||||
@if (segments is not null) {
|
||||
<ProgressBar Segments="@segments" />
|
||||
} else {
|
||||
<ProgressBar Value="@Job.Progress" Color="@StatusColor" />
|
||||
}
|
||||
@@ -105,7 +105,7 @@
|
||||
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
|
||||
[Parameter] public IReadOnlyList<JobStatusDto>? Children { get; set; }
|
||||
|
||||
static readonly Dictionary<EJobStatus, string> _statusColors = new() {
|
||||
static readonly Dictionary<EJobStatus, string> statusColors = new() {
|
||||
[EJobStatus.Queued] = "var(--status-queued)",
|
||||
[EJobStatus.Running] = "var(--status-running)",
|
||||
[EJobStatus.Waiting] = "var(--status-waiting)",
|
||||
@@ -115,7 +115,7 @@
|
||||
[EJobStatus.Canceled] = "var(--status-canceled)",
|
||||
};
|
||||
|
||||
static readonly EJobStatus[] _segmentOrder = [
|
||||
static readonly EJobStatus[] segmentOrder = [
|
||||
EJobStatus.Completed,
|
||||
EJobStatus.CompletedWithErrors,
|
||||
EJobStatus.Failed,
|
||||
@@ -125,20 +125,20 @@
|
||||
EJobStatus.Queued,
|
||||
];
|
||||
|
||||
List<(float Width, string Color)>? _segments;
|
||||
List<(float Width, string Color)>? segments;
|
||||
|
||||
protected override void OnParametersSet() {
|
||||
_segments = ComputeSegments();
|
||||
segments = ComputeSegments();
|
||||
}
|
||||
|
||||
List<(float Width, string Color)>? ComputeSegments() {
|
||||
if (Children is not { Count: > 0 }) return null;
|
||||
|
||||
var total = (float)Children.Count;
|
||||
var ordered = _segmentOrder
|
||||
var ordered = segmentOrder
|
||||
.Select(s => (Status: s, Children.Where(c => c.Status == s).ToList()))
|
||||
.Where(g => g.Item2.Count > 0)
|
||||
.Select(g => (g.Item2.Count / total, _statusColors[g.Status]))
|
||||
.Select(g => (g.Item2.Count / total, statusColors[g.Status]))
|
||||
.ToList();
|
||||
|
||||
return ordered;
|
||||
@@ -153,6 +153,7 @@
|
||||
EJobType.PHashGeneration => "<i class=\"bi bi-hash\" style=\"color: var(--job-phash)\"></i>",
|
||||
EJobType.CreatePersons => "<i class=\"bi bi-person-plus\" style=\"color: var(--job-persons)\"></i>",
|
||||
EJobType.CreateAlbums => "<i class=\"bi bi-collection\" style=\"color: var(--job-create-albums)\"></i>",
|
||||
EJobType.AnimatedConversion => "<i class=\"bi bi-film\" style=\"color: var(--job-animated)\"></i>",
|
||||
_ => "<i class=\"bi bi-question-circle text-muted\"></i>"
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="text-muted small p-2 text-center">@EmptyText</div>
|
||||
} else {
|
||||
@foreach (var root in Roots) {
|
||||
var hasFetched = _childrenCache.TryGetValue(root.Id, out var children);
|
||||
var hasFetched = childrenCache.TryGetValue(root.Id, out var children);
|
||||
var hasChildren = hasFetched ? children!.Count > 0 : true;
|
||||
|
||||
<JobRow @key="root.Id"
|
||||
@@ -14,14 +14,14 @@
|
||||
Level="@Level"
|
||||
ChildSummaryText="@ChildSummary(root, hasFetched, children)"
|
||||
Children="@(hasFetched ? children : null)"
|
||||
IsExpanded="@(_expandedParents.Contains(root.Id) && hasFetched)"
|
||||
IsExpanded="@(expandedParents.Contains(root.Id) && hasFetched)"
|
||||
ExpandedChanged="(bool e) => OnExpanded(root, e)" />
|
||||
|
||||
@if (_expandedParents.Contains(root.Id) && hasFetched && hasChildren) {
|
||||
@if (expandedParents.Contains(root.Id) && hasFetched && hasChildren) {
|
||||
var groups = children!.GroupBy(j => j.Status).OrderBy(g => (int)g.Key);
|
||||
@foreach (var group in groups) {
|
||||
var groupKey = $"{root.Id}-{(int)group.Key}";
|
||||
var isGroupExpanded = _expandedGroups.Contains(groupKey);
|
||||
var isGroupExpanded = expandedGroups.Contains(groupKey);
|
||||
<div @key="groupKey" class="job-tree-group ms-@((Level + 1) * 2)">
|
||||
<div class="small fw-semibold py-0 d-flex align-items-center gap-2 user-select-none"
|
||||
@onclick="() => ToggleGroup(groupKey)" role="button">
|
||||
@@ -56,11 +56,11 @@
|
||||
? Jobs?.Where(j => j.ParentJobId is null).OrderByDescending(j => j.Finished ?? j.Created).ToList() ?? []
|
||||
: Jobs?.OrderBy(j => j.Created).ToList() ?? [];
|
||||
|
||||
readonly HashSet<Guid> _expandedParents = [];
|
||||
readonly HashSet<string> _expandedGroups = [];
|
||||
readonly Dictionary<Guid, List<JobStatusDto>?> _childrenCache = [];
|
||||
readonly Dictionary<Guid, DateTime> _sinceTimestamps = [];
|
||||
CancellationTokenSource? _childRefreshCts;
|
||||
readonly HashSet<Guid> expandedParents = [];
|
||||
readonly HashSet<string> expandedGroups = [];
|
||||
readonly Dictionary<Guid, List<JobStatusDto>?> childrenCache = [];
|
||||
readonly Dictionary<Guid, DateTime> sinceTimestamps = [];
|
||||
CancellationTokenSource? childRefreshCts;
|
||||
|
||||
protected override async Task OnParametersSetAsync() {
|
||||
if (Level == 0 && FetchChildren is not null) {
|
||||
@@ -71,11 +71,11 @@
|
||||
|
||||
async Task EagerFetchMissing() {
|
||||
foreach (var root in Roots) {
|
||||
if (_childrenCache.ContainsKey(root.Id) && _sinceTimestamps.ContainsKey(root.Id)) continue;
|
||||
if (childrenCache.ContainsKey(root.Id) && sinceTimestamps.ContainsKey(root.Id)) continue;
|
||||
try {
|
||||
var (children, since) = await FetchChildren!(root.Id, null);
|
||||
_childrenCache[root.Id] = children;
|
||||
_sinceTimestamps[root.Id] = since ?? DateTime.UtcNow;
|
||||
childrenCache[root.Id] = children;
|
||||
sinceTimestamps[root.Id] = since ?? DateTime.UtcNow;
|
||||
} catch {
|
||||
// refresh loop will retry
|
||||
}
|
||||
@@ -145,20 +145,20 @@
|
||||
|
||||
async Task OnExpanded(JobStatusDto job, bool expanded) {
|
||||
if (expanded) {
|
||||
_expandedParents.Add(job.Id);
|
||||
expandedParents.Add(job.Id);
|
||||
bool shouldFetch = FetchChildren is not null && (
|
||||
!_childrenCache.ContainsKey(job.Id) ||
|
||||
!childrenCache.ContainsKey(job.Id) ||
|
||||
job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting
|
||||
);
|
||||
if (shouldFetch) {
|
||||
var (children, since) = await FetchChildren!(job.Id, null);
|
||||
_childrenCache[job.Id] = children;
|
||||
_sinceTimestamps[job.Id] = since ?? DateTime.UtcNow;
|
||||
childrenCache[job.Id] = children;
|
||||
sinceTimestamps[job.Id] = since ?? DateTime.UtcNow;
|
||||
}
|
||||
StartChildRefresh();
|
||||
} else {
|
||||
_expandedParents.Remove(job.Id);
|
||||
if (_expandedParents.Count == 0 && !HasActiveRoots) {
|
||||
expandedParents.Remove(job.Id);
|
||||
if (expandedParents.Count == 0 && !HasActiveRoots) {
|
||||
StopChildRefresh();
|
||||
}
|
||||
}
|
||||
@@ -167,24 +167,24 @@
|
||||
bool HasActiveRoots => Level == 0 && Roots.Any(j => j.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting);
|
||||
|
||||
void StartChildRefresh() {
|
||||
if (_childRefreshCts is not null) return;
|
||||
if (childRefreshCts is not null) return;
|
||||
bool hasActive = HasActiveRoots;
|
||||
if (!hasActive) {
|
||||
hasActive = _expandedParents.Any(id => {
|
||||
hasActive = expandedParents.Any(id => {
|
||||
var job = Roots?.FirstOrDefault(j => j.Id == id);
|
||||
return job is not null && job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting;
|
||||
});
|
||||
}
|
||||
if (!hasActive) return;
|
||||
|
||||
_childRefreshCts = new CancellationTokenSource();
|
||||
_ = ChildRefreshLoop(_childRefreshCts.Token);
|
||||
childRefreshCts = new CancellationTokenSource();
|
||||
_ = ChildRefreshLoop(childRefreshCts.Token);
|
||||
}
|
||||
|
||||
void StopChildRefresh() {
|
||||
_childRefreshCts?.Cancel();
|
||||
_childRefreshCts?.Dispose();
|
||||
_childRefreshCts = null;
|
||||
childRefreshCts?.Cancel();
|
||||
childRefreshCts?.Dispose();
|
||||
childRefreshCts = null;
|
||||
}
|
||||
|
||||
async Task ChildRefreshLoop(CancellationToken ct) {
|
||||
@@ -210,7 +210,7 @@
|
||||
}
|
||||
|
||||
// Expanded parents (any level): poll children for tree display
|
||||
foreach (var id in _expandedParents.ToArray()) {
|
||||
foreach (var id in expandedParents.ToArray()) {
|
||||
if (visited.Contains(id)) continue;
|
||||
var job = Roots?.FirstOrDefault(j => j.Id == id);
|
||||
if (job is null) continue;
|
||||
@@ -232,9 +232,9 @@
|
||||
async void PollChildren(Guid parentId) {
|
||||
if (FetchChildren is null) return;
|
||||
try {
|
||||
DateTime? since = _sinceTimestamps.TryGetValue(parentId, out var ts) ? ts : null;
|
||||
DateTime? since = sinceTimestamps.TryGetValue(parentId, out var ts) ? ts : null;
|
||||
var (children, newSince) = await FetchChildren(parentId, since);
|
||||
_sinceTimestamps[parentId] = newSince ?? DateTime.UtcNow;
|
||||
sinceTimestamps[parentId] = newSince ?? DateTime.UtcNow;
|
||||
if (children.Count > 0) {
|
||||
MergeChildren(parentId, children);
|
||||
}
|
||||
@@ -244,21 +244,21 @@
|
||||
}
|
||||
|
||||
void MergeChildren(Guid parentId, List<JobStatusDto> incoming) {
|
||||
if (!_childrenCache.TryGetValue(parentId, out var existing) || existing is null) {
|
||||
_childrenCache[parentId] = incoming;
|
||||
if (!childrenCache.TryGetValue(parentId, out var existing) || existing is null) {
|
||||
childrenCache[parentId] = incoming;
|
||||
return;
|
||||
}
|
||||
var dict = existing.ToDictionary(c => c.Id);
|
||||
foreach (var child in incoming) {
|
||||
dict[child.Id] = child;
|
||||
}
|
||||
_childrenCache[parentId] = dict.Values.ToList();
|
||||
childrenCache[parentId] = dict.Values.ToList();
|
||||
}
|
||||
|
||||
public void Dispose() => StopChildRefresh();
|
||||
|
||||
void ToggleGroup(string groupKey) {
|
||||
if (!_expandedGroups.Remove(groupKey))
|
||||
_expandedGroups.Add(groupKey);
|
||||
if (!expandedGroups.Remove(groupKey))
|
||||
expandedGroups.Add(groupKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
CancellationTokenSource? _keepaliveCts;
|
||||
CancellationTokenSource? keepaliveCts;
|
||||
string AppVersion => System.Reflection.Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion ?? "unknown";
|
||||
@@ -44,14 +44,14 @@
|
||||
}
|
||||
|
||||
void StartKeepalive() {
|
||||
_keepaliveCts = new CancellationTokenSource();
|
||||
_ = KeepAliveLoop(_keepaliveCts.Token);
|
||||
keepaliveCts = new CancellationTokenSource();
|
||||
_ = KeepAliveLoop(keepaliveCts.Token);
|
||||
}
|
||||
|
||||
void StopKeepalive() {
|
||||
_keepaliveCts?.Cancel();
|
||||
_keepaliveCts?.Dispose();
|
||||
_keepaliveCts = null;
|
||||
keepaliveCts?.Cancel();
|
||||
keepaliveCts?.Dispose();
|
||||
keepaliveCts = null;
|
||||
}
|
||||
|
||||
async Task KeepAliveLoop(CancellationToken ct) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
<PageTitle>Users</PageTitle>
|
||||
|
||||
@if (_isLoading) {
|
||||
@if (isLoading) {
|
||||
<LoadSpinner />
|
||||
} else if (_users == null) {
|
||||
} else if (users == null) {
|
||||
<div class="alert alert-danger">Failed to load users.</div>
|
||||
} else {
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||
@@ -24,16 +24,16 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_error)) {
|
||||
@if (!string.IsNullOrEmpty(error)) {
|
||||
<div class="alert alert-danger py-1 alert-dismissible fade show">
|
||||
@_error
|
||||
<button type="button" class="btn-close" @onclick="() => _error = null"></button>
|
||||
@error
|
||||
<button type="button" class="btn-close" @onclick="() => error = null"></button>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(_successMessage)) {
|
||||
@if (!string.IsNullOrEmpty(successMessage)) {
|
||||
<div class="alert alert-success alert-dismissible fade show py-1">
|
||||
@_successMessage
|
||||
<button type="button" class="btn-close" @onclick="() => _successMessage = null"></button>
|
||||
@successMessage
|
||||
<button type="button" class="btn-close" @onclick="() => successMessage = null"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -41,12 +41,12 @@
|
||||
<div class="input-group input-group-sm" style="max-width: 280px;">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" placeholder="Search by name or email..."
|
||||
@bind="_searchQuery" @bind:event="oninput" />
|
||||
@bind="searchQuery" @bind:event="oninput" />
|
||||
</div>
|
||||
<small class="text-muted">@_users.Count user@(_users.Count != 1 ? "s" : "") —
|
||||
<span class="text-success">@_users.Count(u => u.DeletedAt == null && !u.IsBanned) active</span>,
|
||||
<span class="text-warning">@_users.Count(u => u.IsBanned) banned</span>,
|
||||
<span class="text-secondary">@_users.Count(u => u.DeletedAt != null) deleted</span>
|
||||
<small class="text-muted">@users.Count user@(users.Count != 1 ? "s" : "") —
|
||||
<span class="text-success">@users.Count(u => u.DeletedAt == null && !u.IsBanned) active</span>,
|
||||
<span class="text-warning">@users.Count(u => u.IsBanned) banned</span>,
|
||||
<span class="text-secondary">@users.Count(u => u.DeletedAt != null) deleted</span>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var user in _filteredSorted) {
|
||||
@foreach (var user in filteredSorted) {
|
||||
var isSelf = user.Id == LoginService.LoggedUser?.Id;
|
||||
<tr class="@(user.DeletedAt != null ? "table-secondary" : user.IsBanned ? "table-warning" : "")">
|
||||
<td>
|
||||
@@ -109,7 +109,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (!_filteredSorted.Any()) {
|
||||
@if (!filteredSorted.Any()) {
|
||||
<tr><td colspan="7" class="text-center text-muted py-3">No users match your search.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -118,7 +118,7 @@
|
||||
|
||||
@* Mobile cards *@
|
||||
<div class="d-md-none">
|
||||
@foreach (var user in _filteredSorted) {
|
||||
@foreach (var user in filteredSorted) {
|
||||
var isSelf = user.Id == LoginService.LoggedUser?.Id;
|
||||
<div class="card mb-2 @(user.DeletedAt != null ? "border-secondary" : user.IsBanned ? "border-warning" : "")">
|
||||
<div class="card-body py-2">
|
||||
@@ -146,38 +146,38 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!_filteredSorted.Any()) {
|
||||
@if (!filteredSorted.Any()) {
|
||||
<div class="text-center text-muted py-3">No users match your search.</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ModalFrame Title="Create User" Show="_showCreateModal" OnClose="CloseCreateModal">
|
||||
<ModalFrame Title="Create User" Show="showCreateModal" OnClose="CloseCreateModal">
|
||||
<HeaderActions><i class="bi bi-person-plus me-1"></i></HeaderActions>
|
||||
<Body>
|
||||
@if (!string.IsNullOrEmpty(_createError)) {
|
||||
<div class="alert alert-danger py-1">@_createError</div>
|
||||
@if (!string.IsNullOrEmpty(createError)) {
|
||||
<div class="alert alert-danger py-1">@createError</div>
|
||||
}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" @bind="_createModel.Username" @bind:event="oninput" />
|
||||
<input type="text" class="form-control" @bind="createModel.Username" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" @bind="_createModel.Email" @bind:event="oninput" />
|
||||
<input type="email" class="form-control" @bind="createModel.Email" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<input type="@(_showCreatePassword ? "text" : "password")" class="form-control border-end-0" @bind="_createModel.Password" @bind:event="oninput" />
|
||||
<button class="btn btn-outline-secondary" type="button" @onclick="() => _showCreatePassword = !_showCreatePassword" tabindex="-1">
|
||||
<i class="bi @(_showCreatePassword ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
<input type="@(showCreatePassword ? "text" : "password")" class="form-control border-end-0" @bind="createModel.Password" @bind:event="oninput" />
|
||||
<button class="btn btn-outline-secondary" type="button" @onclick="() => showCreatePassword = !showCreatePassword" tabindex="-1">
|
||||
<i class="bi @(showCreatePassword ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access Level</label>
|
||||
<select class="form-select" @bind="_createModel.AccessLevel">
|
||||
<select class="form-select" @bind="createModel.AccessLevel">
|
||||
@foreach (var level in Enum.GetValues<EAccessLevel>()) {
|
||||
<option value="@level">@level</option>
|
||||
}
|
||||
@@ -190,27 +190,27 @@
|
||||
</Footer>
|
||||
</ModalFrame>
|
||||
|
||||
<ModalFrame Title="" Show="_editUser != null" OnClose="CloseEditModal">
|
||||
<ModalFrame Title="" Show="editUser != null" OnClose="CloseEditModal">
|
||||
<HeaderActions><i class="bi bi-pencil me-1"></i></HeaderActions>
|
||||
<Body>
|
||||
@if (_editUser != null)
|
||||
@if (editUser != null)
|
||||
{
|
||||
<h5>Edit @_editUser.Username</h5>
|
||||
@if (!string.IsNullOrEmpty(_editError)) {
|
||||
<div class="alert alert-danger py-1">@_editError</div>
|
||||
<h5>Edit @editUser.Username</h5>
|
||||
@if (!string.IsNullOrEmpty(editError)) {
|
||||
<div class="alert alert-danger py-1">@editError</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" @bind="_editUsername" @bind:event="oninput" />
|
||||
<input type="text" class="form-control" @bind="editUsername" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" @bind="_editEmail" @bind:event="oninput" />
|
||||
<input type="email" class="form-control" @bind="editEmail" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access Level</label>
|
||||
<select class="form-select" @bind="_editAccessLevel">
|
||||
<select class="form-select" @bind="editAccessLevel">
|
||||
@foreach (var level in Enum.GetValues<EAccessLevel>()) {
|
||||
<option value="@level">@level</option>
|
||||
}
|
||||
@@ -222,10 +222,10 @@
|
||||
<div class="mb-3">
|
||||
<SearchAssignBadges TItem="PersonPreviewDto"
|
||||
Placeholder="Search cosplayers..."
|
||||
SearchQuery="@_personSearchQuery"
|
||||
SearchQuery="@personSearchQuery"
|
||||
OnSearch="HandlePersonSearchInput"
|
||||
SearchResults="@_personSearchResults"
|
||||
SelectedItems="@_assignedPersons"
|
||||
SearchResults="@personSearchResults"
|
||||
SelectedItems="@assignedPersons"
|
||||
OnSelect="SelectPerson"
|
||||
OnRemove="RemoveAssignedPerson">
|
||||
<ItemDisplay Context="person">
|
||||
@@ -233,10 +233,10 @@
|
||||
</ItemDisplay>
|
||||
</SearchAssignBadges>
|
||||
|
||||
@if (_maintainedPersonIds.Count > 0)
|
||||
@if (maintainedPersonIds.Count > 0)
|
||||
{
|
||||
<div class="mt-1">
|
||||
<small class="text-muted">@_maintainedPersonIds.Count cosplayer@(_maintainedPersonIds.Count != 1 ? "s" : "") selected</small>
|
||||
<small class="text-muted">@maintainedPersonIds.Count cosplayer@(maintainedPersonIds.Count != 1 ? "s" : "") selected</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -244,19 +244,24 @@
|
||||
<hr />
|
||||
<h6>Danger Zone</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@if (_editUser.IsBanned) {
|
||||
<button class="btn btn-success btn-sm" @onclick="() => ToggleBan(_editUser, false)">
|
||||
@if (editUser.IsBanned) {
|
||||
<button class="btn btn-success btn-sm" @onclick="() => ToggleBan(editUser, false)">
|
||||
<i class="bi bi-unlock"></i> Unban
|
||||
</button>
|
||||
} else {
|
||||
<button class="btn btn-warning btn-sm" @onclick="() => ToggleBan(_editUser, true)">
|
||||
<button class="btn btn-warning btn-sm" @onclick="() => ToggleBan(editUser, true)">
|
||||
<i class="bi bi-lock"></i> Ban
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-primary btn-sm" @onclick="() => _passwordUser = _editUser">
|
||||
<button class="btn btn-primary btn-sm" @onclick="() => passwordUser = editUser">
|
||||
<i class="bi bi-key"></i> Change Password
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" disabled="@(_editUser.DeletedAt != null)" @onclick="ConfirmDelete">
|
||||
@if (editUser.DeletedAt != null) {
|
||||
<button class="btn btn-success btn-sm" @onclick="() => ToggleDeleted(editUser, false)">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Restore
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-danger btn-sm" disabled="@(editUser.DeletedAt != null)" @onclick="ConfirmDelete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -264,39 +269,39 @@
|
||||
</Body>
|
||||
<Footer>
|
||||
<button class="btn btn-secondary" @onclick="CloseEditModal">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="SubmitEditUser" disabled="@(string.IsNullOrWhiteSpace(_editUsername) || string.IsNullOrWhiteSpace(_editEmail))">
|
||||
<button class="btn btn-primary" @onclick="SubmitEditUser" disabled="@(string.IsNullOrWhiteSpace(editUsername) || string.IsNullOrWhiteSpace(editEmail))">
|
||||
Save
|
||||
</button>
|
||||
</Footer>
|
||||
</ModalFrame>
|
||||
|
||||
<ModalFrame Title="Change Password" Show="_passwordUser != null" OnClose="ClosePasswordModal">
|
||||
<ModalFrame Title="Change Password" Show="passwordUser != null" OnClose="ClosePasswordModal">
|
||||
<HeaderActions><i class="bi bi-key me-1"></i></HeaderActions>
|
||||
<Body>
|
||||
@if (_passwordUser != null)
|
||||
@if (passwordUser != null)
|
||||
{
|
||||
<p class="text-muted small">Changing password for <strong>@_passwordUser.Username</strong></p>
|
||||
@if (!string.IsNullOrEmpty(_passwordError)) {
|
||||
<div class="alert alert-danger py-1">@_passwordError</div>
|
||||
<p class="text-muted small">Changing password for <strong>@passwordUser.Username</strong></p>
|
||||
@if (!string.IsNullOrEmpty(passwordError)) {
|
||||
<div class="alert alert-danger py-1">@passwordError</div>
|
||||
}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New Password</label>
|
||||
<div class="input-group">
|
||||
<input type="@(_showNewPassword ? "text" : "password")" class="form-control border-end-0" @bind="_adminNewPassword" @bind:event="oninput" />
|
||||
<button class="btn btn-outline-secondary" type="button" @onclick="() => _showNewPassword = !_showNewPassword" tabindex="-1">
|
||||
<i class="bi @(_showNewPassword ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
<input type="@(showNewPassword ? "text" : "password")" class="form-control border-end-0" @bind="adminNewPassword" @bind:event="oninput" />
|
||||
<button class="btn btn-outline-secondary" type="button" @onclick="() => showNewPassword = !showNewPassword" tabindex="-1">
|
||||
<i class="bi @(showNewPassword ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm New Password</label>
|
||||
<div class="input-group">
|
||||
<input type="@(_showConfirmPassword ? "text" : "password")" class="form-control border-end-0" @bind="_adminConfirmPassword" @bind:event="oninput" />
|
||||
<button class="btn btn-outline-secondary" type="button" @onclick="() => _showConfirmPassword = !_showConfirmPassword" tabindex="-1">
|
||||
<i class="bi @(_showConfirmPassword ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
<input type="@(showConfirmPassword ? "text" : "password")" class="form-control border-end-0" @bind="adminConfirmPassword" @bind:event="oninput" />
|
||||
<button class="btn btn-outline-secondary" type="button" @onclick="() => showConfirmPassword = !showConfirmPassword" tabindex="-1">
|
||||
<i class="bi @(showConfirmPassword ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(_adminConfirmPassword) && _adminNewPassword != _adminConfirmPassword) {
|
||||
@if (!string.IsNullOrEmpty(adminConfirmPassword) && adminNewPassword != adminConfirmPassword) {
|
||||
<small class="text-danger">Passwords do not match</small>
|
||||
}
|
||||
</div>
|
||||
@@ -304,72 +309,72 @@
|
||||
</Body>
|
||||
<Footer>
|
||||
<button class="btn btn-secondary" @onclick="ClosePasswordModal">Cancel</button>
|
||||
<button class="btn btn-primary" disabled="@(string.IsNullOrWhiteSpace(_adminNewPassword) || _adminNewPassword != _adminConfirmPassword)"
|
||||
<button class="btn btn-primary" disabled="@(string.IsNullOrWhiteSpace(adminNewPassword) || adminNewPassword != adminConfirmPassword)"
|
||||
@onclick="SubmitAdminPasswordChange">
|
||||
Update Password
|
||||
</button>
|
||||
</Footer>
|
||||
</ModalFrame>
|
||||
|
||||
<ConfirmDialog Show="_showDeleteConfirm"
|
||||
<ConfirmDialog Show="showDeleteConfirm"
|
||||
Title="Confirm Deletion"
|
||||
Message="@($"Delete user '{_editUser?.Username}'? This can be undone.")"
|
||||
Message="@($"Delete user '{editUser?.Username}'? This can be undone.")"
|
||||
ConfirmText="Delete"
|
||||
ConfirmButtonClass="btn-danger"
|
||||
OnConfirm="ExecuteDelete"
|
||||
OnCancel="() => _showDeleteConfirm = false" />
|
||||
OnCancel="() => showDeleteConfirm = false" />
|
||||
|
||||
@code {
|
||||
private List<UserInfoDto>? _users;
|
||||
private bool _isLoading = true;
|
||||
private string? _error;
|
||||
private string? _successMessage;
|
||||
private string _searchQuery = "";
|
||||
private List<UserInfoDto>? users;
|
||||
private bool isLoading = true;
|
||||
private string? error;
|
||||
private string? successMessage;
|
||||
private string searchQuery = "";
|
||||
|
||||
private string? _sortColumn = nameof(UserInfoDto.AccessLevel);
|
||||
private bool _sortAsc = false;
|
||||
private string? sortColumn = nameof(UserInfoDto.AccessLevel);
|
||||
private bool sortAsc = false;
|
||||
|
||||
private bool _showCreateModal;
|
||||
private readonly UserCreateDto _createModel = new() {
|
||||
private bool showCreateModal;
|
||||
private readonly UserCreateDto createModel = new() {
|
||||
Username = "",
|
||||
Email = "",
|
||||
Password = "",
|
||||
AccessLevel = EAccessLevel.User
|
||||
};
|
||||
private string? _createError;
|
||||
private string? createError;
|
||||
|
||||
private UserInfoDto? _editUser;
|
||||
bool _showDeleteConfirm;
|
||||
private string _editUsername = "";
|
||||
private string _editEmail = "";
|
||||
private EAccessLevel _editAccessLevel;
|
||||
private string? _editError;
|
||||
private UserInfoDto? editUser;
|
||||
bool showDeleteConfirm;
|
||||
private string editUsername = "";
|
||||
private string editEmail = "";
|
||||
private EAccessLevel editAccessLevel;
|
||||
private string? editError;
|
||||
|
||||
private UserInfoDto? _passwordUser;
|
||||
private string _adminNewPassword = "";
|
||||
private string _adminConfirmPassword = "";
|
||||
private string? _passwordError;
|
||||
private bool _showCreatePassword;
|
||||
private bool _showNewPassword;
|
||||
private bool _showConfirmPassword;
|
||||
private UserInfoDto? passwordUser;
|
||||
private string adminNewPassword = "";
|
||||
private string adminConfirmPassword = "";
|
||||
private string? passwordError;
|
||||
private bool showCreatePassword;
|
||||
private bool showNewPassword;
|
||||
private bool showConfirmPassword;
|
||||
|
||||
private HashSet<Guid> _maintainedPersonIds = [];
|
||||
private List<PersonPreviewDto> _assignedPersons = [];
|
||||
private string _personSearchQuery = "";
|
||||
private List<PersonPreviewDto>? _personSearchResults;
|
||||
private CancellationTokenSource? _searchCts;
|
||||
private HashSet<Guid> maintainedPersonIds = [];
|
||||
private List<PersonPreviewDto> assignedPersons = [];
|
||||
private string personSearchQuery = "";
|
||||
private List<PersonPreviewDto>? personSearchResults;
|
||||
private CancellationTokenSource? searchCts;
|
||||
|
||||
private IEnumerable<UserInfoDto> _filteredSorted {
|
||||
private IEnumerable<UserInfoDto> filteredSorted {
|
||||
get {
|
||||
if (_users == null) return [];
|
||||
if (users == null) return [];
|
||||
|
||||
var query = _searchQuery?.Trim().ToLowerInvariant() ?? "";
|
||||
var filtered = _users
|
||||
var query = searchQuery?.Trim().ToLowerInvariant() ?? "";
|
||||
var filtered = users
|
||||
.Where(u => string.IsNullOrEmpty(query)
|
||||
|| (u.Username?.ToLowerInvariant().Contains(query) ?? false)
|
||||
|| (u.Email?.ToLowerInvariant().Contains(query) ?? false));
|
||||
|
||||
return (_sortColumn, _sortAsc) switch {
|
||||
return (sortColumn, sortAsc) switch {
|
||||
(nameof(UserInfoDto.Username), true) => filtered.OrderBy(u => u.Username),
|
||||
(nameof(UserInfoDto.Username), false) => filtered.OrderByDescending(u => u.Username),
|
||||
(nameof(UserInfoDto.Email), true) => filtered.OrderBy(u => u.Email),
|
||||
@@ -400,107 +405,107 @@
|
||||
}
|
||||
|
||||
private async Task LoadUsers() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
isLoading = true;
|
||||
error = null;
|
||||
StateHasChanged();
|
||||
|
||||
_users = await UserService.GetAllUsersAsync();
|
||||
_isLoading = false;
|
||||
users = await UserService.GetAllUsersAsync();
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
private void ToggleSort(string column) {
|
||||
if (_sortColumn == column) {
|
||||
_sortAsc = !_sortAsc;
|
||||
if (sortColumn == column) {
|
||||
sortAsc = !sortAsc;
|
||||
} else {
|
||||
_sortColumn = column;
|
||||
_sortAsc = true;
|
||||
sortColumn = column;
|
||||
sortAsc = true;
|
||||
}
|
||||
}
|
||||
|
||||
private MarkupString SortArrow(string column) {
|
||||
if (_sortColumn != column) return (MarkupString)"";
|
||||
return (MarkupString)(_sortAsc
|
||||
if (sortColumn != column) return (MarkupString)"";
|
||||
return (MarkupString)(sortAsc
|
||||
? "<i class=\"bi bi-sort-up\"></i>"
|
||||
: "<i class=\"bi bi-sort-down\"></i>");
|
||||
}
|
||||
|
||||
private void OpenCreateModal() {
|
||||
_createModel.Username = "";
|
||||
_createModel.Email = "";
|
||||
_createModel.Password = "";
|
||||
_createModel.AccessLevel = EAccessLevel.User;
|
||||
_createError = null;
|
||||
_showCreateModal = true;
|
||||
createModel.Username = "";
|
||||
createModel.Email = "";
|
||||
createModel.Password = "";
|
||||
createModel.AccessLevel = EAccessLevel.User;
|
||||
createError = null;
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
private async Task CloseCreateModal() {
|
||||
_showCreateModal = false;
|
||||
showCreateModal = false;
|
||||
}
|
||||
|
||||
private async Task CreateUser() {
|
||||
_createError = null;
|
||||
var result = await UserService.CreateUserAsync(_createModel);
|
||||
createError = null;
|
||||
var result = await UserService.CreateUserAsync(createModel);
|
||||
if (result != null) {
|
||||
_showCreateModal = false;
|
||||
_successMessage = $"User '{_createModel.Username}' created.";
|
||||
showCreateModal = false;
|
||||
successMessage = $"User '{createModel.Username}' created.";
|
||||
await LoadUsers();
|
||||
} else {
|
||||
_createError = "Failed to create user. Email or username may already exist.";
|
||||
createError = "Failed to create user. Email or username may already exist.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenEditModal(UserInfoDto user) {
|
||||
_editUser = user;
|
||||
_editUsername = user.Username ?? "";
|
||||
_editEmail = user.Email ?? "";
|
||||
_editAccessLevel = user.AccessLevel;
|
||||
_editError = null;
|
||||
_maintainedPersonIds = [];
|
||||
_assignedPersons = [];
|
||||
_personSearchResults = null;
|
||||
_personSearchQuery = "";
|
||||
editUser = user;
|
||||
editUsername = user.Username ?? "";
|
||||
editEmail = user.Email ?? "";
|
||||
editAccessLevel = user.AccessLevel;
|
||||
editError = null;
|
||||
maintainedPersonIds = [];
|
||||
assignedPersons = [];
|
||||
personSearchResults = null;
|
||||
personSearchQuery = "";
|
||||
|
||||
var fullUser = await UserService.GetUserByIdAsync(user.Id);
|
||||
if (fullUser?.MaintainedPersonIds != null) {
|
||||
_maintainedPersonIds = [.. fullUser.MaintainedPersonIds];
|
||||
foreach (var pid in _maintainedPersonIds) {
|
||||
maintainedPersonIds = [.. fullUser.MaintainedPersonIds];
|
||||
foreach (var pid in maintainedPersonIds) {
|
||||
var person = await PersonService.GetByIdAsync(pid);
|
||||
if (person != null) {
|
||||
_assignedPersons.Add(new PersonPreviewDto { Id = person.Id, Name = person.Name });
|
||||
assignedPersons.Add(new PersonPreviewDto { Id = person.Id, Name = person.Name });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseEditModal() {
|
||||
_editUser = null;
|
||||
_editError = null;
|
||||
editUser = null;
|
||||
editError = null;
|
||||
}
|
||||
|
||||
private async Task SubmitEditUser() {
|
||||
if (_editUser == null) return;
|
||||
_editError = null;
|
||||
if (editUser == null) return;
|
||||
editError = null;
|
||||
|
||||
var isSelf = _editUser.Id == LoginService.LoggedUser?.Id;
|
||||
if (isSelf && _editAccessLevel != EAccessLevel.Admin) {
|
||||
_editError = "Cannot demote yourself from Admin.";
|
||||
var isSelf = editUser.Id == LoginService.LoggedUser?.Id;
|
||||
if (isSelf && editAccessLevel != EAccessLevel.Admin) {
|
||||
editError = "Cannot demote yourself from Admin.";
|
||||
return;
|
||||
}
|
||||
|
||||
var dto = new UserUpdateDto {
|
||||
Id = _editUser.Id,
|
||||
Username = _editUsername,
|
||||
Email = _editEmail,
|
||||
AccessLevel = _editAccessLevel,
|
||||
MaintainedPersonIds = _maintainedPersonIds.ToList()
|
||||
Id = editUser.Id,
|
||||
Username = editUsername,
|
||||
Email = editEmail,
|
||||
AccessLevel = editAccessLevel,
|
||||
MaintainedPersonIds = maintainedPersonIds.ToList()
|
||||
};
|
||||
var (result, error) = await UserService.UpdateUserAsync(dto);
|
||||
if (result != null) {
|
||||
_successMessage = $"'{result.Username}' updated.";
|
||||
successMessage = $"'{result.Username}' updated.";
|
||||
await CloseEditModal();
|
||||
await LoadUsers();
|
||||
} else {
|
||||
_editError = error ?? "Failed to update user.";
|
||||
editError = error ?? "Failed to update user.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,78 +513,93 @@
|
||||
var dto = new UserUpdateDto { Id = user.Id, IsBanned = ban };
|
||||
var (result, _) = await UserService.UpdateUserAsync(dto);
|
||||
if (result != null) {
|
||||
_successMessage = ban
|
||||
successMessage = ban
|
||||
? $"'{user.Username}' has been banned."
|
||||
: $"'{user.Username}' has been unbanned.";
|
||||
var idx = _users?.FindIndex(u => u.Id == user.Id) ?? -1;
|
||||
if (idx >= 0) _users![idx] = result;
|
||||
if (_editUser?.Id == user.Id) _editUser = result;
|
||||
var idx = users?.FindIndex(u => u.Id == user.Id) ?? -1;
|
||||
if (idx >= 0) users![idx] = result;
|
||||
if (editUser?.Id == user.Id) editUser = result;
|
||||
} else {
|
||||
_error = $"Failed to {(ban ? "ban" : "unban")} user.";
|
||||
error = $"Failed to {(ban ? "ban" : "unban")} user.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleDeleted(UserInfoDto user, bool deleted) {
|
||||
var dto = new UserUpdateDto { Id = user.Id, IsDeleted = deleted };
|
||||
var (result, _) = await UserService.UpdateUserAsync(dto);
|
||||
if (result != null) {
|
||||
successMessage = deleted
|
||||
? $"'{user.Username}' has been deleted."
|
||||
: $"'{user.Username}' has been restored.";
|
||||
var idx = users?.FindIndex(u => u.Id == user.Id) ?? -1;
|
||||
if (idx >= 0) users![idx] = result;
|
||||
if (editUser?.Id == user.Id) editUser = result;
|
||||
} else {
|
||||
error = $"Failed to {(deleted ? "delete" : "restore")} user.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConfirmDelete() {
|
||||
if (_editUser == null) return;
|
||||
_showDeleteConfirm = true;
|
||||
if (editUser == null) return;
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async Task ExecuteDelete() {
|
||||
if (_editUser == null) return;
|
||||
_showDeleteConfirm = false;
|
||||
var success = await UserService.DeleteUserAsync(_editUser.Id);
|
||||
if (editUser == null) return;
|
||||
showDeleteConfirm = false;
|
||||
var success = await UserService.DeleteUserAsync(editUser.Id);
|
||||
if (success) {
|
||||
_successMessage = $"User '{_editUser.Username}' deleted.";
|
||||
successMessage = $"User '{editUser.Username}' deleted.";
|
||||
await CloseEditModal();
|
||||
await LoadUsers();
|
||||
} else {
|
||||
_editError = "Failed to delete user.";
|
||||
editError = "Failed to delete user.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClosePasswordModal() {
|
||||
_passwordUser = null;
|
||||
_adminNewPassword = "";
|
||||
_adminConfirmPassword = "";
|
||||
_passwordError = null;
|
||||
passwordUser = null;
|
||||
adminNewPassword = "";
|
||||
adminConfirmPassword = "";
|
||||
passwordError = null;
|
||||
}
|
||||
|
||||
private async Task SubmitAdminPasswordChange() {
|
||||
if (_passwordUser == null) return;
|
||||
_passwordError = null;
|
||||
if (passwordUser == null) return;
|
||||
passwordError = null;
|
||||
|
||||
var dto = new UserUpdateDto {
|
||||
Id = _passwordUser.Id,
|
||||
Password = _adminNewPassword
|
||||
Id = passwordUser.Id,
|
||||
Password = adminNewPassword
|
||||
};
|
||||
var (result, error) = await UserService.UpdateUserAsync(dto);
|
||||
if (result != null) {
|
||||
_successMessage = $"Password changed for '{_passwordUser.Username}'.";
|
||||
successMessage = $"Password changed for '{passwordUser.Username}'.";
|
||||
await ClosePasswordModal();
|
||||
} else {
|
||||
_passwordError = error ?? "Failed to change password.";
|
||||
passwordError = error ?? "Failed to change password.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandlePersonSearchInput(ChangeEventArgs e) {
|
||||
_personSearchQuery = e.Value?.ToString() ?? "";
|
||||
await SearchPersons(_personSearchQuery);
|
||||
personSearchQuery = e.Value?.ToString() ?? "";
|
||||
await SearchPersons(personSearchQuery);
|
||||
}
|
||||
|
||||
private async Task SearchPersons(string query) {
|
||||
_searchCts?.Cancel();
|
||||
_searchCts = new CancellationTokenSource();
|
||||
var token = _searchCts.Token;
|
||||
searchCts?.Cancel();
|
||||
searchCts = new CancellationTokenSource();
|
||||
var token = searchCts.Token;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query) || query.Length < 2) {
|
||||
_personSearchResults = null;
|
||||
personSearchResults = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var results = await PersonService.GetAllAsync(search: query, pageSize: 250);
|
||||
if (!token.IsCancellationRequested) {
|
||||
_personSearchResults = results;
|
||||
personSearchResults = results;
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// ignore
|
||||
@@ -587,21 +607,21 @@
|
||||
}
|
||||
|
||||
private void SelectPerson(PersonPreviewDto person) {
|
||||
if (_maintainedPersonIds.Add(person.Id)) {
|
||||
_assignedPersons.Add(person);
|
||||
if (maintainedPersonIds.Add(person.Id)) {
|
||||
assignedPersons.Add(person);
|
||||
}
|
||||
_personSearchQuery = "";
|
||||
_personSearchResults = null;
|
||||
personSearchQuery = "";
|
||||
personSearchResults = null;
|
||||
}
|
||||
|
||||
private void RemoveAssignedPerson(PersonPreviewDto person) {
|
||||
_maintainedPersonIds.Remove(person.Id);
|
||||
_assignedPersons.RemoveAll(p => p.Id == person.Id);
|
||||
maintainedPersonIds.Remove(person.Id);
|
||||
assignedPersons.RemoveAll(p => p.Id == person.Id);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose() {
|
||||
_searchCts?.Cancel();
|
||||
_searchCts?.Dispose();
|
||||
searchCts?.Cancel();
|
||||
searchCts?.Dispose();
|
||||
}
|
||||
|
||||
private static string AccessLevelBadge(EAccessLevel level) => level switch {
|
||||
|
||||
@@ -18,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">
|
||||
@@ -44,100 +44,32 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="album-detail-actions">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetMasonry" title="Masonry">
|
||||
<i class="bi bi-grid-3x2-gap"></i>
|
||||
</button>
|
||||
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetGrid" title="Grid">
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
</button>
|
||||
</div>
|
||||
<ViewModeToggle ViewMode="@viewMode" ViewModeChanged="@((string v) => viewMode = v)" />
|
||||
@if (loginService.CanEdit(album?.Person)) {
|
||||
@* Desktop: inline buttons *@
|
||||
<div class="d-none d-md-flex gap-1 align-items-center">
|
||||
<ResponsiveActionBar>
|
||||
@if (selectMode) {
|
||||
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedImages" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedImageIds.Count
|
||||
</button>
|
||||
<button class="btn btn-sm btn-warning" @onclick="UnlinkSelectedImages" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Unlink @selectedImageIds.Count
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAll">
|
||||
@(allSelected ? "Deselect All" : "Select All")
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
|
||||
Done
|
||||
</button>
|
||||
} else {
|
||||
<button class="btn btn-sm btn-success" @onclick="OpenAssetPicker">
|
||||
<i class="bi bi-plus-circle"></i> Add Assets
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" @onclick="EnterSelectMode">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-danger" @onclick="ConfirmDelete" title="Delete album">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
@* Mobile: dropdown *@
|
||||
<div class="btn-group btn-group-sm d-md-none">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
@if (selectMode) {
|
||||
<li>
|
||||
<button class="dropdown-item text-danger" @onclick="DeleteSelectedImages" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedImageIds.Count
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item text-warning" @onclick="UnlinkSelectedImages" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Unlink @selectedImageIds.Count
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedImageIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="ToggleSelectAll">
|
||||
<i class="bi @(allSelected ? "bi-x-square" : "bi-check2-square")"></i> @(allSelected ? "Deselect All" : "Select All")
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="CancelSelectMode">
|
||||
<i class="bi bi-x-circle"></i> Done
|
||||
</button>
|
||||
</li>
|
||||
} else {
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenAssetPicker">
|
||||
<i class="bi bi-plus-circle"></i> Add Assets
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="EnterSelectMode">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
</li>
|
||||
<ActionButton Icon="bi-trash" Label="@($"Delete {selectedImages.Count}")" Class="btn-danger" MobileClass="text-danger"
|
||||
Disabled="@(selectedImages.Count == 0)" OnClick="DeleteSelectedImages" />
|
||||
@if (selectedDeletedCount > 0) {
|
||||
<ActionButton Icon="bi-arrow-counterclockwise" Label="@($"Restore {selectedDeletedCount}")" Class="btn-success" MobileClass="text-success"
|
||||
OnClick="RestoreSelectedImages" />
|
||||
}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button class="dropdown-item text-danger" @onclick="ConfirmDelete">
|
||||
<i class="bi bi-trash"></i> Delete album
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ActionButton Icon="bi-link-45deg" Label="@($"Unlink {selectedImages.Count}")" Class="btn-warning" MobileClass="text-warning"
|
||||
Disabled="@(selectedImages.Count == 0)" OnClick="UnlinkSelectedImages" />
|
||||
<ActionButton Icon="bi-eye" Label="Visibility" Class="btn-secondary"
|
||||
Disabled="@(selectedImages.Count == 0)" OnClick="OpenVisibilityModal" />
|
||||
<li class="dropdown-divider d-md-none"></li>
|
||||
<ActionButton Icon="@(allSelected ? "bi-x-square" : "bi-check2-square")" Label="@(allSelected ? "Deselect All" : "Select All")"
|
||||
Class="btn-outline-secondary" OnClick="ToggleSelectAll" />
|
||||
<ActionButton Label="Done" Class="btn-primary" MobileIcon="bi-x-circle" OnClick="CancelSelectMode" />
|
||||
} else {
|
||||
<ActionButton Icon="bi-plus-circle" Label="Add Assets" Class="btn-success" OnClick="OpenAssetPicker" />
|
||||
<ActionButton Icon="bi-check2-square" Label="Select" Class="btn-secondary" OnClick="EnterSelectMode" />
|
||||
}
|
||||
<li class="dropdown-divider d-md-none"></li>
|
||||
<ActionButton Icon="bi-trash" Label="" MobileLabel="Delete album" Class="btn-danger" MobileClass="text-danger"
|
||||
Title="Delete album" OnClick="ConfirmDelete" />
|
||||
</ResponsiveActionBar>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,10 +83,10 @@
|
||||
var preview = album.AssetPreviews.Count > i ? album.AssetPreviews[i] : null;
|
||||
if (!selectMode && preview != null && (preview.DeletedAt != null || (preview.Visibility.HasValue && preview.Visibility.Value == EVisibility.Private))) continue;
|
||||
var index = i;
|
||||
var isSelected = selectedImageIds.Contains(assetId);
|
||||
var isSelected = selectedImages.Contains(assetId);
|
||||
<div class="@GetTileClass(isSelected, preview)" @key="assetId"
|
||||
style="aspect-ratio:@GetAspectRatio(preview)"
|
||||
@onclick="() => HandleTileClick(index, assetId)">
|
||||
@onclick="(e) => HandleTileClick(index, assetId, e.ShiftKey)">
|
||||
@if (preview == null || preview.HasThumbnail) {
|
||||
<img src="@mediaService.ThumbUrl(assetId)"
|
||||
loading="lazy" alt=""
|
||||
@@ -186,17 +118,18 @@
|
||||
@if (showForm && album != null) {
|
||||
<AlbumForm Show="true"
|
||||
EditAlbum="new AlbumPreviewDto { Id = album.Id, Name = album.Name, Person = album.Person, PersonName = album.PersonName, CoverAssetId = album.CoverAssetId }"
|
||||
AlbumAssetIds="album.Images"
|
||||
AssetIds="album.Images"
|
||||
OnSaved="HandleFormSaved"
|
||||
OnClosed="CloseForm" />
|
||||
OnClose="CloseForm" />
|
||||
}
|
||||
|
||||
@if (selectedAssetId != null && album != null) {
|
||||
<ImagePreview Show="true"
|
||||
AssetId="selectedAssetId.Value"
|
||||
HasPreview="currentPreview?.HasPreview ?? false"
|
||||
HasPrevious="selectedIndex > 0"
|
||||
HasNext="selectedIndex < album.Images.Count - 1"
|
||||
MimeType="currentPreview?.MimeType"
|
||||
HasPrevious="CanGoPrev"
|
||||
HasNext="CanGoNext"
|
||||
Filename="@(currentPreview?.FileName)"
|
||||
OnNavigate="NavigatePreview"
|
||||
OnClose="ClosePreview">
|
||||
@@ -207,28 +140,37 @@
|
||||
</ImagePreview>
|
||||
}
|
||||
|
||||
@if (showVisibilityModal) {
|
||||
@if (visibility.Show) {
|
||||
<VisibilityModal Show="true"
|
||||
ItemCount="@selectedImageIds.Count"
|
||||
ItemCount="@selectedImages.Count"
|
||||
ItemLabel="asset(s)"
|
||||
Visibility="@selectedVisibility"
|
||||
VisibilityChanged="@((EVisibility v) => selectedVisibility = v)"
|
||||
Visibility="visibility.Selection"
|
||||
VisibilityChanged="@((EVisibility v) => visibility.Selection = v)"
|
||||
Error="@visibility.Error"
|
||||
OnApply="ApplyVisibility"
|
||||
OnClose="CloseVisibilityModal" />
|
||||
OnClose="visibility.Close" />
|
||||
}
|
||||
|
||||
<ConfirmDialog Show="_showDeleteConfirm"
|
||||
<ConfirmDialog Show="deleteConfirm.Show"
|
||||
Title="Confirm Deletion"
|
||||
Message="@_deleteConfirmMessage"
|
||||
Message="@deleteConfirm.Message"
|
||||
ConfirmText="Delete"
|
||||
ConfirmButtonClass="btn-danger"
|
||||
OnConfirm="ExecutePendingDelete"
|
||||
OnCancel="() => _showDeleteConfirm = false" />
|
||||
OnConfirm="ExecuteDeletePending"
|
||||
OnCancel="deleteConfirm.Cancel" />
|
||||
|
||||
<ConfirmDialog Show="restoreConfirm.Show"
|
||||
Title="Confirm Restore"
|
||||
Message="@restoreConfirm.Message"
|
||||
ConfirmText="Restore"
|
||||
ConfirmButtonClass="btn-success"
|
||||
OnConfirm="ExecuteRestorePending"
|
||||
OnCancel="restoreConfirm.Cancel" />
|
||||
|
||||
@if (showAssetPicker && album != null) {
|
||||
<AlbumAssetPicker Show="true"
|
||||
OnSelected="OnAssetsAdded"
|
||||
OnClosed="() => showAssetPicker = false" />
|
||||
OnClose="() => showAssetPicker = false" />
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -239,33 +181,39 @@
|
||||
bool isLoading = true;
|
||||
bool showForm;
|
||||
bool selectMode;
|
||||
bool showVisibilityModal;
|
||||
bool showAssetPicker;
|
||||
bool _showDeleteConfirm;
|
||||
string _deleteConfirmMessage = "";
|
||||
Func<Task>? _pendingDeleteAction;
|
||||
EVisibility selectedVisibility = EVisibility.Public;
|
||||
HashSet<Guid> selectedImageIds = [];
|
||||
DeleteConfirmation deleteConfirm = new();
|
||||
DeleteConfirmation restoreConfirm = new();
|
||||
VisibilityApplyState visibility = new();
|
||||
MultiSelect<Guid> selectedImages = new();
|
||||
SelectionRange assetSelectionRange = new();
|
||||
Guid? selectedAssetId;
|
||||
int selectedIndex = -1;
|
||||
string viewMode = "masonry";
|
||||
bool _masonrySetup;
|
||||
|
||||
void SetMasonry() { viewMode = "masonry"; StateHasChanged(); }
|
||||
void SetGrid() { viewMode = "grid"; StateHasChanged(); }
|
||||
bool masonrySetup;
|
||||
|
||||
AlbumAssetPreviewDto? currentPreview => album?.AssetPreviews != null && selectedIndex >= 0 && selectedIndex < album.AssetPreviews.Count
|
||||
? album.AssetPreviews[selectedIndex]
|
||||
: null;
|
||||
|
||||
bool allSelected => album != null && album.Images.Count > 0 && selectedImageIds.Count == album.Images.Count;
|
||||
bool allSelected => album != null && selectedImages.IsAllSelected(album.Images);
|
||||
|
||||
// Deleted tiles are only selectable in edit mode; this counts how many of the
|
||||
// currently selected assets are soft-deleted and thus eligible for restore.
|
||||
int selectedDeletedCount {
|
||||
get {
|
||||
if (album == null || selectedImages.Count == 0) return 0;
|
||||
var count = 0;
|
||||
for (var i = 0; i < album.Images.Count && i < album.AssetPreviews.Count; i++)
|
||||
if (selectedImages.Contains(album.Images[i]) && album.AssetPreviews[i].DeletedAt != null)
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
void ToggleSelectAll() {
|
||||
if (album == null) return;
|
||||
if (allSelected)
|
||||
selectedImageIds.Clear();
|
||||
else
|
||||
selectedImageIds.UnionWith(album.Images);
|
||||
selectedImages.ToggleAll(album.Images);
|
||||
}
|
||||
|
||||
string AssetStatsText {
|
||||
@@ -292,11 +240,11 @@
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (viewMode == "masonry" && !_masonrySetup && album != null && album.Images.Count > 0) {
|
||||
_masonrySetup = true;
|
||||
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;
|
||||
} else if (viewMode != "masonry" && masonrySetup) {
|
||||
masonrySetup = false;
|
||||
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-assets-grid.masonry");
|
||||
}
|
||||
}
|
||||
@@ -310,7 +258,7 @@
|
||||
selectedAssetId = null;
|
||||
selectedIndex = -1;
|
||||
selectMode = false;
|
||||
selectedImageIds.Clear();
|
||||
selectedImages.Clear();
|
||||
album = await albumService.GetAlbumAsync(Id);
|
||||
if (album != null) {
|
||||
var seen = new Dictionary<Guid, int>();
|
||||
@@ -349,24 +297,18 @@
|
||||
navigationManager.NavigateTo("/albums");
|
||||
}
|
||||
|
||||
async Task ConfirmDelete() {
|
||||
void ConfirmDelete() {
|
||||
if (album == null) return;
|
||||
_deleteConfirmMessage = $"Delete album '{album.Name}'? This cannot be undone.";
|
||||
_pendingDeleteAction = ExecuteDeleteAlbum;
|
||||
_showDeleteConfirm = true;
|
||||
deleteConfirm.Confirm($"Delete album '{album.Name}'? This cannot be undone.", ExecuteDeleteAlbum);
|
||||
}
|
||||
|
||||
async Task ExecuteDeleteAlbum() {
|
||||
if (album == null) return;
|
||||
_showDeleteConfirm = false;
|
||||
await albumService.DeleteAlbumAsync(album.Id);
|
||||
navigationManager.NavigateTo("/albums");
|
||||
}
|
||||
|
||||
async Task ExecutePendingDelete() {
|
||||
if (_pendingDeleteAction != null)
|
||||
await _pendingDeleteAction();
|
||||
}
|
||||
async Task ExecuteDeletePending() => await deleteConfirm.ExecuteAsync();
|
||||
|
||||
void OpenAssetPicker() {
|
||||
showAssetPicker = true;
|
||||
@@ -384,68 +326,73 @@
|
||||
|
||||
void EnterSelectMode() {
|
||||
selectMode = true;
|
||||
selectedImageIds.Clear();
|
||||
selectedImages.Clear();
|
||||
}
|
||||
|
||||
void CancelSelectMode() {
|
||||
selectMode = false;
|
||||
selectedImageIds.Clear();
|
||||
selectedImages.Clear();
|
||||
}
|
||||
|
||||
void HandleTileClick(int index, Guid assetId) {
|
||||
void HandleTileClick(int index, Guid assetId, bool shift) {
|
||||
if (selectMode)
|
||||
ToggleImageSelection(assetId);
|
||||
assetSelectionRange.Toggle(selectedImages, album?.Images ?? [], assetId, shift);
|
||||
else
|
||||
OpenPreview(index);
|
||||
}
|
||||
|
||||
void ToggleImageSelection(Guid id) {
|
||||
if (selectedImageIds.Contains(id))
|
||||
selectedImageIds.Remove(id);
|
||||
else
|
||||
selectedImageIds.Add(id);
|
||||
}
|
||||
|
||||
async Task UnlinkSelectedImages() {
|
||||
if (album == null || selectedImageIds.Count == 0) return;
|
||||
var remaining = album.Images.Where(id => !selectedImageIds.Contains(id)).ToList();
|
||||
if (album == null || selectedImages.Count == 0) return;
|
||||
var remaining = album.Images.Where(id => !selectedImages.Contains(id)).ToList();
|
||||
await albumService.UpdateAlbumAsync(album.Id, new AlbumUpdateDto { Assets = remaining });
|
||||
selectMode = false;
|
||||
selectedImageIds.Clear();
|
||||
selectedImages.Clear();
|
||||
await LoadAlbum();
|
||||
}
|
||||
|
||||
async Task DeleteSelectedImages() {
|
||||
if (album == null || selectedImageIds.Count == 0) return;
|
||||
_deleteConfirmMessage = $"Delete {selectedImageIds.Count} selected asset{(selectedImageIds.Count == 1 ? "" : "s")}? This cannot be undone.";
|
||||
_pendingDeleteAction = ExecuteDeleteSelectedImages;
|
||||
_showDeleteConfirm = true;
|
||||
void DeleteSelectedImages() {
|
||||
if (album == null || selectedImages.Count == 0) return;
|
||||
deleteConfirm.Confirm($"Delete {selectedImages.Count} selected asset{(selectedImages.Count == 1 ? "" : "s")}? This cannot be undone.", ExecuteDeleteSelectedImages);
|
||||
}
|
||||
|
||||
async Task ExecuteDeleteSelectedImages() {
|
||||
if (album == null || selectedImageIds.Count == 0) return;
|
||||
_showDeleteConfirm = false;
|
||||
await assetService.BulkDeleteAssetsAsync([.. selectedImageIds]);
|
||||
if (album == null || selectedImages.Count == 0) return;
|
||||
await assetService.BulkDeleteAssetsAsync(selectedImages.ToList());
|
||||
selectMode = false;
|
||||
selectedImageIds.Clear();
|
||||
selectedImages.Clear();
|
||||
await LoadAlbum();
|
||||
}
|
||||
|
||||
void OpenVisibilityModal() {
|
||||
showVisibilityModal = true;
|
||||
selectedVisibility = EVisibility.Public;
|
||||
void RestoreSelectedImages() {
|
||||
var ids = album?.AssetPreviews
|
||||
.Where(p => p.DeletedAt != null && selectedImages.Contains(p.Id))
|
||||
.Select(p => p.Id)
|
||||
.ToList();
|
||||
if (ids == null || ids.Count == 0) return;
|
||||
restoreConfirm.Confirm(
|
||||
$"Restore {ids.Count} selected asset{(ids.Count == 1 ? "" : "s")}?",
|
||||
() => ExecuteRestoreSelectedImages(ids)
|
||||
);
|
||||
}
|
||||
|
||||
void CloseVisibilityModal() {
|
||||
showVisibilityModal = false;
|
||||
async Task ExecuteRestoreSelectedImages(List<Guid> ids) {
|
||||
if (album == null) return;
|
||||
await assetService.RestoreAssetsAsync(ids);
|
||||
selectMode = false;
|
||||
selectedImages.Clear();
|
||||
await LoadAlbum();
|
||||
}
|
||||
|
||||
async Task ExecuteRestorePending() => await restoreConfirm.ExecuteAsync();
|
||||
|
||||
void OpenVisibilityModal() => visibility.Open();
|
||||
|
||||
async Task ApplyVisibility() {
|
||||
if (album == null || selectedImageIds.Count == 0) return;
|
||||
await assetService.BulkUpdateAssetsAsync([.. selectedImageIds], new AssetUpdateDto { Visibility = selectedVisibility });
|
||||
showVisibilityModal = false;
|
||||
if (album == null || selectedImages.Count == 0) return;
|
||||
await assetService.BulkUpdateAssetsAsync(selectedImages.ToList(), new AssetUpdateDto { Visibility = visibility.Selection });
|
||||
visibility.MarkApplied();
|
||||
selectMode = false;
|
||||
selectedImageIds.Clear();
|
||||
selectedImages.Clear();
|
||||
await LoadAlbum();
|
||||
}
|
||||
|
||||
@@ -481,10 +428,42 @@
|
||||
selectedIndex = -1;
|
||||
}
|
||||
|
||||
// Previewer navigation must mirror the grid: in browse mode, private/deleted assets are hidden
|
||||
// (R6), so prev/next scan past them instead of landing on tiles the user cannot see.
|
||||
bool CanGoPrev => HasVisibleAssetBefore(selectedIndex);
|
||||
bool CanGoNext => HasVisibleAssetAfter(selectedIndex);
|
||||
|
||||
bool HasVisibleAssetBefore(int rawIndex) {
|
||||
if (album == null) return false;
|
||||
for (var i = rawIndex - 1; i >= 0; i--)
|
||||
if (!IsHiddenFromBrowse(i)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool HasVisibleAssetAfter(int rawIndex) {
|
||||
if (album == null) return false;
|
||||
for (var i = rawIndex + 1; i < album.Images.Count; i++)
|
||||
if (!IsHiddenFromBrowse(i)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Matches the grid's browse-mode filter (skip deleted or private previews).
|
||||
bool IsHiddenFromBrowse(int rawIndex) {
|
||||
var preview = album != null && rawIndex >= 0 && rawIndex < album.AssetPreviews.Count
|
||||
? album.AssetPreviews[rawIndex]
|
||||
: null;
|
||||
return preview != null && (preview.DeletedAt != null ||
|
||||
(preview.Visibility.HasValue && preview.Visibility.Value == EVisibility.Private));
|
||||
}
|
||||
|
||||
void NavigatePreview(int direction) {
|
||||
if (album == null) return;
|
||||
var newIndex = selectedIndex + direction;
|
||||
if (newIndex < 0 || newIndex >= album.Images.Count) return;
|
||||
var newIndex = selectedIndex;
|
||||
while (true) {
|
||||
newIndex += direction;
|
||||
if (newIndex < 0 || newIndex >= album.Images.Count) return;
|
||||
if (!IsHiddenFromBrowse(newIndex)) break;
|
||||
}
|
||||
selectedAssetId = album.Images[newIndex];
|
||||
selectedIndex = newIndex;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
@using Butter.Types
|
||||
@implements IDisposable
|
||||
|
||||
@using Butter.Dtos.Asset
|
||||
@inject AlbumService albumService
|
||||
@inject AssetService assetService
|
||||
@inject LoginService loginService
|
||||
@inject IJSRuntime jsRuntime
|
||||
@inject NavigationManager navigationManager
|
||||
|
||||
<PageTitle>Albums</PageTitle>
|
||||
|
||||
@@ -24,131 +21,58 @@
|
||||
SortOptions="@sortOptions">
|
||||
<ActionButtons>
|
||||
@if (loginService.CanEditAll) {
|
||||
@* Desktop: inline buttons *@
|
||||
<div class="d-none d-md-flex gap-1 align-items-center">
|
||||
<ResponsiveActionBar>
|
||||
@if (selectMode) {
|
||||
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
<button class="btn btn-sm btn-warning" @onclick="OpenLinkCosplayerModal" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Link to Cosplayer
|
||||
</button>
|
||||
<button class="btn btn-sm btn-info" @onclick="OpenMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
|
||||
<i class="bi bi-arrow-right-circle"></i> Merge
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAllAlbums">
|
||||
@(albumsAllSelected ? "Deselect All" : "Select All")
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
|
||||
Done
|
||||
</button>
|
||||
<ActionButton Icon="bi-trash" Label="@($"Delete {selectedAlbums.Count}")" Class="btn-danger" MobileClass="text-danger"
|
||||
Disabled="@(selectedAlbums.Count == 0)" OnClick="DeleteSelectedAlbums" />
|
||||
<ActionButton Icon="bi-eye" Label="Visibility" Class="btn-secondary"
|
||||
Disabled="@(selectedAlbums.Count == 0)" OnClick="OpenVisibilityModal" />
|
||||
<ActionButton Icon="bi-link-45deg" Label="Link to Cosplayer" Class="btn-warning" MobileClass="btn-warning"
|
||||
Disabled="@(selectedAlbums.Count == 0)" OnClick="OpenLinkCosplayerModal" />
|
||||
<ActionButton Icon="bi-arrow-right-circle" Label="Merge" Class="btn-info"
|
||||
Disabled="@(selectedAlbums.Count < 2)" OnClick="OpenMergeModal" />
|
||||
<ActionButton Icon="@(albumsAllSelected ? "bi-x-square" : "bi-check2-square")" Label="@(albumsAllSelected ? "Deselect All" : "Select All")"
|
||||
Class="btn-outline-secondary" OnClick="ToggleSelectAllAlbums" />
|
||||
<ActionButton Label="Done" Class="btn-primary" MobileIcon="bi-x-circle" OnClick="CancelSelectMode" />
|
||||
} else {
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => selectMode = true">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
<ActionButton Icon="bi-check2-square" Label="Select" Class="btn-secondary" OnClick="() => selectMode = true" />
|
||||
}
|
||||
</div>
|
||||
@* Mobile: dropdown *@
|
||||
<div class="btn-group btn-group-sm d-md-none">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
@if (selectMode) {
|
||||
<li>
|
||||
<button class="dropdown-item text-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item btn-warning" @onclick="OpenLinkCosplayerModal" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Link to Cosplayer
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
|
||||
<i class="bi bi-arrow-right-circle"></i> Merge
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="ToggleSelectAllAlbums">
|
||||
<i class="bi @(albumsAllSelected ? "bi-x-square" : "bi-check2-square")"></i> @(albumsAllSelected ? "Deselect All" : "Select All")
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="CancelSelectMode">
|
||||
<i class="bi bi-x-circle"></i> Done
|
||||
</button>
|
||||
</li>
|
||||
} else {
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="() => selectMode = true">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenCreateForm">
|
||||
<i class="bi bi-plus-circle"></i> New Album
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetMasonry" title="Masonry">
|
||||
<i class="bi bi-grid-3x2-gap"></i>
|
||||
</button>
|
||||
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetGrid" title="Grid">
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (loginService.CanEditAll) {
|
||||
<button class="btn btn-primary btn-sm d-none d-md-inline-block" @onclick="OpenCreateForm">
|
||||
<i class="bi bi-plus-circle"></i> <span class="d-none d-sm-inline">New Album</span>
|
||||
</button>
|
||||
<li class="dropdown-divider d-md-none"></li>
|
||||
<ActionButton Icon="bi-plus-circle" Label="New Album" Class="btn-primary" OnClick="OpenCreateForm" />
|
||||
</ResponsiveActionBar>
|
||||
}
|
||||
<ViewModeToggle ViewMode="@viewMode" ViewModeChanged="@((string v) => viewMode = v)" />
|
||||
</ActionButtons>
|
||||
</SortFilterBar>
|
||||
|
||||
@if (showVisibilityModal) {
|
||||
<VisibilityModal Show="true"
|
||||
ItemCount="@selectedAlbumIds.Count"
|
||||
ItemCount="@selectedAlbums.Count"
|
||||
ItemLabel="album(s)"
|
||||
Visibility="@selectedAlbumVisibility"
|
||||
VisibilityChanged="@((EVisibility v) => selectedAlbumVisibility = v)"
|
||||
CascadeLabel="Also change visibility of all assets in these albums (skips private/deleted)"
|
||||
CascadeLabel="Also change visibility of all assets in these albums (skips deleted)"
|
||||
CascadeEnabled="@cascadeToAssets"
|
||||
CascadeEnabledChanged="@((bool v) => cascadeToAssets = v)"
|
||||
Error="@visibilityError"
|
||||
OnApply="ApplyAlbumVisibility"
|
||||
OnClose="CloseVisibilityModal" />
|
||||
}
|
||||
|
||||
@if (showLinkCosplayerModal) {
|
||||
<LinkToCosplayerModal Show="true"
|
||||
AlbumCount="@selectedAlbumIds.Count"
|
||||
AlbumCount="@selectedAlbums.Count"
|
||||
OnLink="@LinkSelectedAlbumsToCosplayer"
|
||||
OnClose="() => showLinkCosplayerModal = false" />
|
||||
}
|
||||
|
||||
<ConfirmDialog Show="_showDeleteConfirm"
|
||||
<ConfirmDialog Show="showDeleteConfirm"
|
||||
Title="Confirm Deletion"
|
||||
Message="@($"Delete {selectedAlbumIds.Count} selected album{(selectedAlbumIds.Count == 1 ? "" : "s")}? This cannot be undone.")"
|
||||
Message="@($"Delete {selectedAlbums.Count} selected album{(selectedAlbums.Count == 1 ? "" : "s")}? This cannot be undone.")"
|
||||
ConfirmText="Delete"
|
||||
ConfirmButtonClass="btn-danger"
|
||||
OnConfirm="ExecuteDeleteSelectedAlbums"
|
||||
OnCancel="() => _showDeleteConfirm = false" />
|
||||
OnCancel="() => showDeleteConfirm = false" />
|
||||
|
||||
@if (showMergeModal) {
|
||||
<MergeModal Show="true"
|
||||
@@ -164,7 +88,7 @@
|
||||
SortAsc="@sortAsc"
|
||||
ViewMode="@viewMode"
|
||||
SelectionMode="@selectMode"
|
||||
SelectedIds="@selectedAlbumIds"
|
||||
SelectedIds="@selectedAlbums.Selected"
|
||||
OnToggleSelection="@ToggleAlbumSelection"
|
||||
LoadVersion="@loadVersion" />
|
||||
|
||||
@@ -172,29 +96,31 @@
|
||||
<AlbumForm Show="true"
|
||||
EditAlbum="editingAlbum"
|
||||
OnSaved="HandleFormSaved"
|
||||
OnClosed="CloseForm" />
|
||||
OnClose="CloseForm" />
|
||||
}
|
||||
|
||||
@code {
|
||||
AlbumGrid? albumGridRef;
|
||||
SelectionRange albumSelectionRange = new();
|
||||
bool showForm;
|
||||
AlbumPreviewDto? editingAlbum;
|
||||
string viewMode = "masonry";
|
||||
bool selectMode;
|
||||
bool showVisibilityModal;
|
||||
bool _showDeleteConfirm;
|
||||
bool showDeleteConfirm;
|
||||
bool showLinkCosplayerModal;
|
||||
bool showMergeModal;
|
||||
List<MergeModal.MergeItem> mergeItems = [];
|
||||
bool cascadeToAssets;
|
||||
EVisibility selectedAlbumVisibility = EVisibility.Public;
|
||||
HashSet<Guid> selectedAlbumIds = [];
|
||||
bool albumsAllSelected => albumGridRef != null && selectedAlbumIds.Count == albumGridRef.GetCurrentAlbumIds().Count && selectedAlbumIds.Count > 0;
|
||||
string? searchQuery;
|
||||
string? visibilityError;
|
||||
MultiSelect<Guid> selectedAlbums = new();
|
||||
bool albumsAllSelected => albumGridRef != null && selectedAlbums.IsAllSelected(albumGridRef.GetCurrentAlbumIds());
|
||||
[SupplyParameterFromQuery(Name = "search")] public string? searchQuery { get; set; }
|
||||
string? sortBy;
|
||||
bool sortAsc;
|
||||
int loadVersion;
|
||||
EventHandler<UserInfoDto?>? _loggedUserChangedHandler;
|
||||
EventHandler<UserInfoDto?>? loggedUserChangedHandler;
|
||||
Dictionary<string, string> sortOptions = new()
|
||||
{
|
||||
["Created"] = "created",
|
||||
@@ -205,50 +131,29 @@
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
ParseSearchQuery();
|
||||
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
|
||||
_loggedUserChangedHandler = (_, _) => StateHasChanged();
|
||||
loginService.LoggedUserChanged += _loggedUserChangedHandler;
|
||||
loggedUserChangedHandler = (_, _) => StateHasChanged();
|
||||
loginService.LoggedUserChanged += loggedUserChangedHandler;
|
||||
}
|
||||
|
||||
protected override void OnParametersSet() {
|
||||
ParseSearchQuery();
|
||||
}
|
||||
|
||||
void ParseSearchQuery() {
|
||||
var uri = navigationManager.Uri;
|
||||
var qIdx = uri.IndexOf('?');
|
||||
if (qIdx < 0) return;
|
||||
foreach (var part in uri[(qIdx + 1)..].Split('&'))
|
||||
{
|
||||
var kv = part.Split('=', 2);
|
||||
if (kv.Length == 2 && kv[0] == "search")
|
||||
searchQuery = Uri.UnescapeDataString(kv[1].Replace("+", " "));
|
||||
}
|
||||
}
|
||||
|
||||
void SetMasonry() => viewMode = "masonry";
|
||||
void SetGrid() => viewMode = "grid";
|
||||
|
||||
void ToggleAlbumSelection(Guid id) {
|
||||
if (selectedAlbumIds.Contains(id)) selectedAlbumIds.Remove(id);
|
||||
else selectedAlbumIds.Add(id);
|
||||
void ToggleAlbumSelection((Guid Id, bool Shift) args) {
|
||||
albumSelectionRange.Toggle(selectedAlbums, albumGridRef?.GetCurrentAlbumIds() ?? [], args.Id, args.Shift);
|
||||
}
|
||||
|
||||
void CancelSelectMode() {
|
||||
selectMode = false;
|
||||
selectedAlbumIds.Clear();
|
||||
selectedAlbums.Clear();
|
||||
}
|
||||
|
||||
async Task DeleteSelectedAlbums() {
|
||||
if (selectedAlbumIds.Count == 0) return;
|
||||
_showDeleteConfirm = true;
|
||||
if (selectedAlbums.Count == 0) return;
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async Task ExecuteDeleteSelectedAlbums() {
|
||||
_showDeleteConfirm = false;
|
||||
await albumService.BulkDeleteAlbumsAsync(selectedAlbumIds.ToList());
|
||||
selectedAlbumIds.Clear();
|
||||
showDeleteConfirm = false;
|
||||
await albumService.BulkDeleteAlbumsAsync(selectedAlbums.ToList());
|
||||
selectedAlbums.Clear();
|
||||
selectMode = false;
|
||||
loadVersion++;
|
||||
}
|
||||
@@ -256,15 +161,13 @@
|
||||
void ToggleSelectAllAlbums() {
|
||||
if (albumGridRef == null) return;
|
||||
var allIds = albumGridRef.GetCurrentAlbumIds();
|
||||
if (albumsAllSelected)
|
||||
selectedAlbumIds.Clear();
|
||||
else
|
||||
selectedAlbumIds.UnionWith(allIds);
|
||||
selectedAlbums.ToggleAll(allIds);
|
||||
}
|
||||
|
||||
void OpenVisibilityModal() {
|
||||
showVisibilityModal = true;
|
||||
selectedAlbumVisibility = EVisibility.Public;
|
||||
visibilityError = null;
|
||||
}
|
||||
|
||||
void CloseVisibilityModal() {
|
||||
@@ -279,7 +182,7 @@
|
||||
void OpenMergeModal() {
|
||||
if (albumGridRef is null) return;
|
||||
mergeItems = albumGridRef.GetCurrentAlbumPreviews()
|
||||
.Where(a => selectedAlbumIds.Contains(a.Id))
|
||||
.Where(a => selectedAlbums.Contains(a.Id))
|
||||
.Select(a => new MergeModal.MergeItem { Id = a.Id, Name = a.Name, Subtitle = $"{a.AssetCount} asset{(a.AssetCount == 1 ? "" : "s")}" })
|
||||
.ToList();
|
||||
showMergeModal = true;
|
||||
@@ -291,29 +194,36 @@
|
||||
await albumService.MergeAlbumsAsync(dto);
|
||||
showMergeModal = false;
|
||||
selectMode = false;
|
||||
selectedAlbumIds.Clear();
|
||||
selectedAlbums.Clear();
|
||||
loadVersion++;
|
||||
}
|
||||
|
||||
async Task LinkSelectedAlbumsToCosplayer(Guid personId) {
|
||||
if (selectedAlbumIds.Count == 0) return;
|
||||
await albumService.BulkUpdateAlbumsAsync([.. selectedAlbumIds], new AlbumUpdateDto { Person = personId });
|
||||
if (selectedAlbums.Count == 0) return;
|
||||
await albumService.BulkUpdateAlbumsAsync(selectedAlbums.ToList(), new AlbumUpdateDto { Person = personId });
|
||||
showLinkCosplayerModal = false;
|
||||
selectMode = false;
|
||||
selectedAlbumIds.Clear();
|
||||
selectedAlbums.Clear();
|
||||
loadVersion++;
|
||||
}
|
||||
|
||||
async Task ApplyAlbumVisibility() {
|
||||
if (selectedAlbumIds.Count == 0) return;
|
||||
await albumService.BulkUpdateAlbumsAsync([.. selectedAlbumIds], new AlbumUpdateDto { Visibility = selectedAlbumVisibility });
|
||||
if (cascadeToAssets)
|
||||
await albumService.CascadeVisibilityToAssetsAsync([.. selectedAlbumIds], selectedAlbumVisibility);
|
||||
showVisibilityModal = false;
|
||||
cascadeToAssets = false;
|
||||
selectMode = false;
|
||||
selectedAlbumIds.Clear();
|
||||
loadVersion++;
|
||||
if (selectedAlbums.Count == 0) return;
|
||||
visibilityError = null;
|
||||
try {
|
||||
await albumService.BulkUpdateAlbumsAsync(
|
||||
selectedAlbums.ToList(),
|
||||
new AlbumUpdateDto { Visibility = selectedAlbumVisibility },
|
||||
new CascadeOptions { ToAssets = cascadeToAssets }
|
||||
);
|
||||
showVisibilityModal = false;
|
||||
cascadeToAssets = false;
|
||||
selectMode = false;
|
||||
selectedAlbums.Clear();
|
||||
loadVersion++;
|
||||
} catch (Exception) {
|
||||
visibilityError = "The visibility update failed. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
void OpenCreateForm() {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
@page "/cosplayer/{Id:guid}"
|
||||
@using Butter.Dtos.Album
|
||||
@using Butter.Dtos.Asset
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Types
|
||||
@using System.Globalization
|
||||
@inject PersonService personService
|
||||
@inject AlbumService albumService
|
||||
@inject AssetService assetService
|
||||
@inject LoginService loginService
|
||||
@inject MediaService mediaService
|
||||
@inject NavigationManager navigationManager
|
||||
@@ -102,112 +100,32 @@
|
||||
SortOptions="@albumSortOptions">
|
||||
<ActionButtons>
|
||||
@if (person.TotalAlbums > 0) {
|
||||
<div class="btn-group btn-group-sm d-none d-md-inline-flex">
|
||||
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetMasonry" title="Masonry">
|
||||
<i class="bi bi-grid-3x2-gap"></i>
|
||||
</button>
|
||||
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetGrid" title="Grid">
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
</button>
|
||||
</div>
|
||||
<ViewModeToggle ViewMode="@viewMode" ViewModeChanged="@((string v) => viewMode = v)" Class="d-none d-md-inline-flex" />
|
||||
}
|
||||
@if (loginService.CanEdit(Id)) {
|
||||
@* Desktop: inline buttons *@
|
||||
<div class="d-none d-md-flex gap-1 align-items-center">
|
||||
<button class="btn btn-success btn-sm" @onclick="OpenAlbumAssigner">
|
||||
<i class="bi bi-plus-circle"></i> Add Albums
|
||||
</button>
|
||||
<ResponsiveActionBar>
|
||||
<ActionButton Icon="bi-plus-circle" Label="Add Albums" Class="btn-success" OnClick="OpenAlbumAssigner" />
|
||||
@if (person.TotalAlbums > 0) {
|
||||
if (selectMode) {
|
||||
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
|
||||
</button>
|
||||
<button class="btn btn-sm btn-warning" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
<button class="btn btn-sm btn-info" @onclick="OpenAlbumMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
|
||||
<i class="bi bi-arrow-right-circle"></i> Merge
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAllAlbums">
|
||||
@(albumsAllSelected ? "Deselect All" : "Select All")
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
|
||||
Done
|
||||
</button>
|
||||
@if (selectMode) {
|
||||
<ActionButton Icon="bi-trash" Label="@($"Delete {selectedAlbums.Count}")" Class="btn-danger" MobileClass="text-danger"
|
||||
Disabled="@(selectedAlbums.Count == 0)" OnClick="DeleteSelectedAlbums" />
|
||||
<ActionButton Icon="bi-link-45deg" Label="@($"Unlink {selectedAlbums.Count}")" Class="btn-warning" MobileClass="text-warning"
|
||||
Disabled="@(selectedAlbums.Count == 0)" OnClick="RemoveSelectedAlbums" />
|
||||
<ActionButton Icon="bi-eye" Label="Visibility" Class="btn-secondary"
|
||||
Disabled="@(selectedAlbums.Count == 0)" OnClick="OpenVisibilityModal" />
|
||||
<ActionButton Icon="bi-arrow-right-circle" Label="Merge" Class="btn-info"
|
||||
Disabled="@(selectedAlbums.Count < 2)" OnClick="OpenAlbumMergeModal" />
|
||||
<ActionButton Icon="@(albumsAllSelected ? "bi-x-square" : "bi-check2-square")" Label="@(albumsAllSelected ? "Deselect All" : "Select All")"
|
||||
Class="btn-outline-secondary" OnClick="ToggleSelectAllAlbums" />
|
||||
<ActionButton Label="Done" Class="btn-primary" MobileIcon="bi-x-circle" OnClick="CancelSelectMode" />
|
||||
} else {
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => selectMode = true">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
<ActionButton Icon="bi-check2-square" Label="Select" Class="btn-secondary" OnClick="() => selectMode = true" />
|
||||
}
|
||||
<li class="dropdown-divider d-md-none"></li>
|
||||
}
|
||||
<button class="btn btn-danger btn-sm" @onclick="ConfirmDelete" title="Delete cosplayer">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
@* Mobile: dropdown *@
|
||||
<div class="btn-group btn-group-sm d-md-none">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenAlbumAssigner">
|
||||
<i class="bi bi-plus-circle"></i> Add Albums
|
||||
</button>
|
||||
</li>
|
||||
@if (person.TotalAlbums > 0) {
|
||||
@if (selectMode) {
|
||||
<li>
|
||||
<button class="dropdown-item text-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item text-warning" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-eye"></i> Visibility
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="OpenAlbumMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
|
||||
<i class="bi bi-arrow-right-circle"></i> Merge
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="ToggleSelectAllAlbums">
|
||||
<i class="bi @(albumsAllSelected ? "bi-x-square" : "bi-check2-square")"></i> @(albumsAllSelected ? "Deselect All" : "Select All")
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="CancelSelectMode">
|
||||
<i class="bi bi-x-circle"></i> Done
|
||||
</button>
|
||||
</li>
|
||||
} else {
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="() => selectMode = true">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
}
|
||||
<li>
|
||||
<button class="dropdown-item text-danger" @onclick="ConfirmDelete">
|
||||
<i class="bi bi-trash"></i> Delete Cosplayer
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ActionButton Icon="bi-trash" Label="" MobileLabel="Delete Cosplayer" Class="btn-danger" MobileClass="text-danger"
|
||||
Title="Delete cosplayer" OnClick="ConfirmDelete" />
|
||||
</ResponsiveActionBar>
|
||||
}
|
||||
</ActionButtons>
|
||||
</SortFilterBar>
|
||||
@@ -218,7 +136,7 @@
|
||||
SortAsc="@albumSortAsc"
|
||||
ViewMode="@viewMode"
|
||||
SelectionMode="@selectMode"
|
||||
SelectedIds="@selectedAlbumIds"
|
||||
SelectedIds="@selectedAlbums.Selected"
|
||||
OnToggleSelection="@ToggleSelectAlbum"
|
||||
LoadVersion="@albumLoadVersion"
|
||||
FetchAlbums="@FetchPersonAlbums"
|
||||
@@ -232,7 +150,7 @@
|
||||
AssetIds="allAssetIds"
|
||||
MaintainerUserIds="person.MaintainerUserIds"
|
||||
OnSaved="HandleFormSaved"
|
||||
OnClosed="CloseForm" />
|
||||
OnClose="CloseForm" />
|
||||
}
|
||||
|
||||
<ModalFrame Title="" Show="showAlbumAssigner && person != null" OnClose="CloseAlbumAssigner">
|
||||
@@ -272,13 +190,14 @@
|
||||
|
||||
@if (showAlbumVisibilityModal) {
|
||||
<VisibilityModal Show="true"
|
||||
ItemCount="@selectedAlbumIds.Count"
|
||||
ItemCount="@selectedAlbums.Count"
|
||||
ItemLabel="album(s)"
|
||||
Visibility="@selectedAlbumVisibility"
|
||||
VisibilityChanged="@((EVisibility v) => selectedAlbumVisibility = v)"
|
||||
CascadeLabel="Also change visibility of all assets in these albums (skips private/deleted)"
|
||||
CascadeLabel="Also change visibility of all assets in these albums (skips deleted)"
|
||||
CascadeEnabled="@cascadeToAssets"
|
||||
CascadeEnabledChanged="@((bool v) => cascadeToAssets = v)"
|
||||
Error="@visibilityError"
|
||||
OnApply="ApplyAlbumVisibility"
|
||||
OnClose="CloseVisibilityModal" />
|
||||
}
|
||||
@@ -291,13 +210,13 @@
|
||||
OnClose="() => showAlbumMergeModal = false" />
|
||||
}
|
||||
|
||||
<ConfirmDialog Show="_showDeleteConfirm"
|
||||
<ConfirmDialog Show="deleteConfirm.Show"
|
||||
Title="Confirm Deletion"
|
||||
Message="@_deleteConfirmMessage"
|
||||
Message="@deleteConfirm.Message"
|
||||
ConfirmText="Delete"
|
||||
ConfirmButtonClass="btn-danger"
|
||||
OnConfirm="ExecutePendingDelete"
|
||||
OnCancel="() => _showDeleteConfirm = false" />
|
||||
OnConfirm="ExecuteDeletePending"
|
||||
OnCancel="deleteConfirm.Cancel" />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
@@ -311,16 +230,16 @@
|
||||
bool showAlbumVisibilityModal;
|
||||
bool showAlbumMergeModal;
|
||||
List<MergeModal.MergeItem> albumMergeItems = [];
|
||||
bool _showDeleteConfirm;
|
||||
string _deleteConfirmMessage = "";
|
||||
Func<Task>? _pendingDeleteAction;
|
||||
DeleteConfirmation deleteConfirm = new();
|
||||
bool cascadeToAssets;
|
||||
EVisibility selectedAlbumVisibility = EVisibility.Public;
|
||||
string? visibilityError;
|
||||
string viewMode = "masonry";
|
||||
bool showAlbumAssigner;
|
||||
List<AlbumPreviewDto>? unassignedAlbums;
|
||||
HashSet<Guid> assignAlbumIds = [];
|
||||
HashSet<Guid> selectedAlbumIds = [];
|
||||
MultiSelect<Guid> selectedAlbums = new();
|
||||
SelectionRange albumSelectionRange = new();
|
||||
List<string> bannerCoverUrls = [];
|
||||
|
||||
// Search & sort
|
||||
@@ -378,9 +297,6 @@
|
||||
await LoadPerson();
|
||||
}
|
||||
|
||||
void SetMasonry() => viewMode = "masonry";
|
||||
void SetGrid() => viewMode = "grid";
|
||||
|
||||
void CloseForm() => showForm = false;
|
||||
|
||||
async Task OpenAlbumAssigner() {
|
||||
@@ -399,17 +315,17 @@
|
||||
else assignAlbumIds.Remove(id);
|
||||
}
|
||||
|
||||
bool albumsAllSelected => person?.Albums != null && person.Albums.Count > 0 && selectedAlbumIds.Count == person.Albums.Count;
|
||||
bool albumsAllSelected => personAlbumGridRef != null && selectedAlbums.IsAllSelected(personAlbumGridRef.GetCurrentAlbumIds());
|
||||
|
||||
void CancelSelectMode() {
|
||||
selectMode = false;
|
||||
selectedAlbumIds.Clear();
|
||||
selectedAlbums.Clear();
|
||||
}
|
||||
|
||||
void OpenAlbumMergeModal() {
|
||||
if (personAlbumGridRef is null) return;
|
||||
albumMergeItems = personAlbumGridRef.GetCurrentAlbumPreviews()
|
||||
.Where(a => selectedAlbumIds.Contains(a.Id))
|
||||
.Where(a => selectedAlbums.Contains(a.Id))
|
||||
.Select(a => new MergeModal.MergeItem { Id = a.Id, Name = a.Name, Subtitle = $"{a.AssetCount} asset{(a.AssetCount == 1 ? "" : "s")}" })
|
||||
.ToList();
|
||||
showAlbumMergeModal = true;
|
||||
@@ -426,16 +342,14 @@
|
||||
}
|
||||
|
||||
void ToggleSelectAllAlbums() {
|
||||
if (person?.Albums == null) return;
|
||||
if (albumsAllSelected)
|
||||
selectedAlbumIds.Clear();
|
||||
else
|
||||
selectedAlbumIds.UnionWith(person.Albums.Select(a => a.Id));
|
||||
if (personAlbumGridRef == null) return;
|
||||
var allIds = personAlbumGridRef.GetCurrentAlbumIds();
|
||||
selectedAlbums.ToggleAll(allIds);
|
||||
}
|
||||
|
||||
void ToggleSelectAlbum(Guid id) {
|
||||
if (selectedAlbumIds.Contains(id)) selectedAlbumIds.Remove(id);
|
||||
else selectedAlbumIds.Add(id);
|
||||
void ToggleSelectAlbum((Guid Id, bool Shift) args) {
|
||||
var ordered = personAlbumGridRef?.GetCurrentAlbumIds() ?? [];
|
||||
albumSelectionRange.Toggle(selectedAlbums, ordered, args.Id, args.Shift);
|
||||
}
|
||||
|
||||
async Task AssignSelectedAlbums() {
|
||||
@@ -450,24 +364,21 @@
|
||||
|
||||
async Task RemoveSelectedAlbums() {
|
||||
if (person == null) return;
|
||||
foreach (var albumId in selectedAlbumIds)
|
||||
foreach (var albumId in selectedAlbums.Selected)
|
||||
await albumService.UnlinkPersonAsync(albumId);
|
||||
CancelSelectMode();
|
||||
await LoadPerson();
|
||||
albumLoadVersion++;
|
||||
}
|
||||
|
||||
async Task DeleteSelectedAlbums() {
|
||||
if (person == null || selectedAlbumIds.Count == 0) return;
|
||||
_deleteConfirmMessage = $"Delete {selectedAlbumIds.Count} selected album{(selectedAlbumIds.Count == 1 ? "" : "s")}? This cannot be undone.";
|
||||
_pendingDeleteAction = ExecuteDeleteSelectedAlbums;
|
||||
_showDeleteConfirm = true;
|
||||
void DeleteSelectedAlbums() {
|
||||
if (person == null || selectedAlbums.Count == 0) return;
|
||||
deleteConfirm.Confirm($"Delete {selectedAlbums.Count} selected album{(selectedAlbums.Count == 1 ? "" : "s")}? This cannot be undone.", ExecuteDeleteSelectedAlbums);
|
||||
}
|
||||
|
||||
async Task ExecuteDeleteSelectedAlbums() {
|
||||
if (person == null || selectedAlbumIds.Count == 0) return;
|
||||
_showDeleteConfirm = false;
|
||||
await albumService.BulkDeleteAlbumsAsync([.. selectedAlbumIds]);
|
||||
if (person == null || selectedAlbums.Count == 0) return;
|
||||
await albumService.BulkDeleteAlbumsAsync(selectedAlbums.ToList());
|
||||
CancelSelectMode();
|
||||
await LoadPerson();
|
||||
albumLoadVersion++;
|
||||
@@ -476,6 +387,7 @@
|
||||
void OpenVisibilityModal() {
|
||||
showAlbumVisibilityModal = true;
|
||||
selectedAlbumVisibility = EVisibility.Public;
|
||||
visibilityError = null;
|
||||
}
|
||||
|
||||
void CloseVisibilityModal() {
|
||||
@@ -484,33 +396,34 @@
|
||||
}
|
||||
|
||||
async Task ApplyAlbumVisibility() {
|
||||
if (person == null || selectedAlbumIds.Count == 0) return;
|
||||
await albumService.BulkUpdateAlbumsAsync([.. selectedAlbumIds], new AlbumUpdateDto { Visibility = selectedAlbumVisibility });
|
||||
if (cascadeToAssets)
|
||||
await albumService.CascadeVisibilityToAssetsAsync([.. selectedAlbumIds], selectedAlbumVisibility);
|
||||
showAlbumVisibilityModal = false;
|
||||
cascadeToAssets = false;
|
||||
CancelSelectMode();
|
||||
await LoadPerson();
|
||||
albumLoadVersion++;
|
||||
if (person == null || selectedAlbums.Count == 0) return;
|
||||
visibilityError = null;
|
||||
try {
|
||||
await albumService.BulkUpdateAlbumsAsync(
|
||||
selectedAlbums.ToList(),
|
||||
new AlbumUpdateDto { Visibility = selectedAlbumVisibility },
|
||||
new CascadeOptions { ToAssets = cascadeToAssets }
|
||||
);
|
||||
showAlbumVisibilityModal = false;
|
||||
cascadeToAssets = false;
|
||||
CancelSelectMode();
|
||||
await LoadPerson();
|
||||
albumLoadVersion++;
|
||||
} catch (Exception) {
|
||||
visibilityError = "The visibility update failed. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
async Task ConfirmDelete() {
|
||||
void ConfirmDelete() {
|
||||
if (person == null) return;
|
||||
_deleteConfirmMessage = $"Delete cosplayer '{person.Name}'? This cannot be undone.";
|
||||
_pendingDeleteAction = ExecuteDeletePerson;
|
||||
_showDeleteConfirm = true;
|
||||
deleteConfirm.Confirm($"Delete cosplayer '{person.Name}'? This cannot be undone.", ExecuteDeletePerson);
|
||||
}
|
||||
|
||||
async Task ExecuteDeletePerson() {
|
||||
if (person == null) return;
|
||||
_showDeleteConfirm = false;
|
||||
await personService.DeletePersonAsync(person.Id);
|
||||
navigationManager.NavigateTo("/cosplayers");
|
||||
}
|
||||
|
||||
async Task ExecutePendingDelete() {
|
||||
if (_pendingDeleteAction != null)
|
||||
await _pendingDeleteAction();
|
||||
}
|
||||
async Task ExecuteDeletePending() => await deleteConfirm.ExecuteAsync();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user