Compare commits
479
Commits
41c262d157
...
develop
| 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 | ||
|
|
d3124c55bd | ||
|
|
c97b16ada9 | ||
|
|
5e7c053218 | ||
|
|
e1bb7d3330 | ||
|
|
0c8e653a5f | ||
|
|
725a020078 | ||
|
|
2782a38819 | ||
|
|
84e4492aff | ||
|
|
f75940cd20 | ||
|
|
84d3f63a97 | ||
|
|
9e9d091f60 | ||
|
|
556c4a15e0 | ||
|
|
3374fe6ad4 | ||
|
|
fe4e19a7de | ||
|
|
a5a783e8d6 | ||
|
|
3afa7f2834 | ||
|
|
66782cacfc | ||
|
|
aacd4c8049 | ||
|
|
fa62e75a72 | ||
|
|
1599715d06 | ||
|
|
4189e975a1 | ||
|
|
5fbb0e75e8 | ||
|
|
2371011720 | ||
|
|
2d4e78f014 | ||
|
|
e1839d4e8f | ||
|
|
ee73c8ad90 | ||
|
|
a9d58606a7 | ||
|
|
588095f370 | ||
|
|
9f5d9c0c11 | ||
|
|
25144ccd3c | ||
|
|
a9b52a890a | ||
|
|
b344c1d625 | ||
|
|
cff5a7969d | ||
|
|
30dca5e216 | ||
|
|
e6a8799965 | ||
|
|
ff2e7ff6dc | ||
|
|
cd1e5841cf | ||
|
|
d318400504 | ||
|
|
9d70516da2 | ||
|
|
52e026d7e1 | ||
|
|
a811221228 | ||
|
|
3ffee8df78 | ||
|
|
63e3413141 | ||
|
|
b83b97934c | ||
|
|
6bf627e429 | ||
|
|
7ef8d8155f | ||
|
|
1aeb644e30 | ||
|
|
8913fa7c43 | ||
|
|
f42c2e4d04 | ||
|
|
0480b00766 | ||
|
|
26674e05e5 | ||
|
|
29eb39e545 | ||
|
|
fbcc7a317d | ||
|
|
ee07306a4d | ||
|
|
294f7a6cae | ||
|
|
595399385b | ||
|
|
77ee80cd63 | ||
|
|
9e67c597ef | ||
|
|
05ff6e8f2d | ||
|
|
07e43b5023 | ||
|
|
00e0dd42d1 | ||
|
|
6f60ade6df | ||
|
|
eaa067134e | ||
|
|
701c4360b4 | ||
|
|
e132d269eb | ||
|
|
79252d363d | ||
|
|
8ff21fa56b | ||
|
|
2305e44e68 | ||
|
|
439df56f2b | ||
|
|
f70f256c07 | ||
|
|
3b1f26cdf3 | ||
|
|
9965a13f40 | ||
|
|
fbafa54322 | ||
|
|
9607746912 | ||
|
|
785d33e289 | ||
|
|
a3eeb8b6d1 | ||
|
|
cae61116c8 | ||
|
|
ced0f61b87 | ||
|
|
9751c81574 | ||
|
|
550f195cf2 | ||
|
|
06c6cf7648 | ||
|
|
a5b6642b94 | ||
|
|
f0b67b0404 | ||
|
|
fb2b02e71e | ||
|
|
4658ed1a27 | ||
|
|
52c08089b2 | ||
|
|
111875670b | ||
|
|
9c50f84783 | ||
|
|
593d88d3e3 | ||
|
|
abef50b010 | ||
|
|
ce221428f5 | ||
|
|
213c643263 | ||
|
|
ecc6e02c78 | ||
|
|
b78c5772b4 | ||
|
|
6463df51d1 | ||
|
|
dd2d703fc0 | ||
|
|
a388887f56 | ||
|
|
7d30ea2394 | ||
|
|
ab420fe167 | ||
|
|
19e177504f | ||
|
|
f57e2864c5 | ||
|
|
d89f6f0a21 | ||
|
|
ca217ddb59 | ||
|
|
e025866357 | ||
|
|
1449664678 | ||
|
|
cf8bee555a | ||
|
|
664ac54a7c | ||
|
|
df48dd17f4 | ||
|
|
f15fe8f31a | ||
|
|
8589a85625 | ||
|
|
983cd8baef | ||
|
|
f52b376fca | ||
|
|
75e57353e7 | ||
|
|
eae6152738 | ||
|
|
c4788e1572 | ||
|
|
1dfa9eaf41 | ||
|
|
50b5a28841 | ||
|
|
580cdee67d | ||
|
|
8f0267b0f7 | ||
|
|
5d9aa21987 | ||
|
|
ad1f9e2222 | ||
|
|
90c81c2d20 | ||
|
|
0cc80a084a | ||
|
|
ad7c38b20e | ||
|
|
e0a5bd0ce4 | ||
|
|
e41e7d774f | ||
|
|
34220e93d4 | ||
|
|
22706a09cd | ||
|
|
664c1de5da | ||
|
|
081295af86 | ||
|
|
7dd00c0a5e | ||
|
|
3a195dbc15 | ||
|
|
cce310ee14 | ||
|
|
ada113914d | ||
|
|
a2a25673de | ||
|
|
963e897fe5 | ||
|
|
f9fe91e975 | ||
|
|
01fc121bc3 | ||
|
|
da8a5ad539 | ||
|
|
ff2ed4f7cd | ||
|
|
2c292d2d81 | ||
|
|
f064277c31 | ||
|
|
c5ea749517 | ||
|
|
ebe83fe691 | ||
|
|
ab58ac7dd8 | ||
|
|
17d3f0f2a7 | ||
|
|
1e2bc83b20 | ||
|
|
58014a37bd | ||
|
|
c79ed47e41 | ||
|
|
e48da562fa | ||
|
|
b943d2e595 | ||
|
|
0950106609 | ||
|
|
7ba1ab1551 | ||
|
|
656e6fa9bf | ||
|
|
5e1306e466 | ||
|
|
22565c4f92 | ||
|
|
e62a567e4e | ||
|
|
e5ffe86077 | ||
|
|
d4a53cb2dc | ||
|
|
548b0d5bfd | ||
|
|
92fb5a0149 | ||
|
|
6542dcafd6 | ||
|
|
b7690b5828 | ||
|
|
f3f2bdba02 | ||
|
|
8bf6405c64 | ||
|
|
c1b1feef4d | ||
|
|
0e9820f7c3 | ||
|
|
133c697176 | ||
|
|
8f07d7c445 | ||
|
|
c7e83ed97b | ||
|
|
7db1738678 | ||
|
|
3e099987a3 | ||
|
|
ca50554108 | ||
|
|
2bfa370989 | ||
|
|
dbd5d15fa1 | ||
|
|
b1f32844a9 | ||
|
|
3bf98cbfed | ||
|
|
80c9c8bd6f | ||
|
|
7086fb127f | ||
|
|
19ab8c0975 | ||
|
|
d7f8350ae7 | ||
|
|
f0e3cc9c37 | ||
|
|
4f56af252b | ||
|
|
8aeb4df064 | ||
|
|
dc2983ae55 | ||
|
|
a50dc3aa30 | ||
|
|
3cdad1cebd | ||
|
|
0903da4490 | ||
|
|
2fcd6467e1 | ||
|
|
10337c735c | ||
|
|
e3d4cf9673 | ||
|
|
a203885377 | ||
|
|
fff5ea65df | ||
|
|
86f95d7461 | ||
|
|
ec287f087c | ||
|
|
758696f7c9 | ||
|
|
de9cd618b6 | ||
|
|
e7fb01be9f | ||
|
|
e4a34a7dad | ||
|
|
d799c3eb1f | ||
|
|
c5fabf0e07 | ||
|
|
3cdf9eb185 | ||
|
|
6efccae030 | ||
|
|
93cbbea3c7 | ||
|
|
cace8640e9 | ||
|
|
a3ce98a984 | ||
|
|
4f3e3a28cc | ||
|
|
657ffaae11 | ||
|
|
700d5a63a7 | ||
|
|
9531ac2e23 | ||
|
|
288da412d9 | ||
|
|
1b34520a66 | ||
|
|
23b285cf49 | ||
|
|
27707b120c | ||
|
|
14978087e3 | ||
|
|
3021788271 | ||
|
|
358bf0918e | ||
|
|
656784af00 | ||
|
|
c8b71f290b | ||
|
|
8f109bf92f | ||
|
|
268b380867 | ||
|
|
71889ddfac | ||
|
|
0a74ff8770 | ||
|
|
f158181ccf | ||
|
|
e585abc378 | ||
|
|
ccf16b6abc | ||
|
|
7dcb78db97 | ||
|
|
26984f4452 | ||
|
|
f32d0678df | ||
|
|
05b99ab6c5 | ||
|
|
88a0098314 | ||
|
|
8b53899648 | ||
|
|
e2b0eaba37 | ||
|
|
e1504dbf72 | ||
|
|
b67b2260fe | ||
|
|
06fd26e641 | ||
|
|
74c74cbbdf | ||
|
|
774ee0d7c7 | ||
|
|
45b3c56ce2 | ||
|
|
b0fa104642 | ||
|
|
d4cd305c28 | ||
|
|
671783fd16 | ||
|
|
674710986f | ||
|
|
77a6ee9747 | ||
|
|
1f2bf02d02 | ||
|
|
c5b8c93a39 | ||
|
|
b9251c3e19 | ||
|
|
0d1ce9feb3 | ||
|
|
0673017765 | ||
|
|
08c389026a | ||
|
|
bbb2ed8f73 | ||
|
|
fe33fe2044 | ||
|
|
a449c792c1 | ||
|
|
9323111d0d | ||
|
|
c2326414b4 | ||
|
|
747ad77f4c | ||
|
|
f77a4574ac | ||
|
|
c64c1947e6 | ||
|
|
1b444dab93 | ||
|
|
50bf0a2a78 | ||
|
|
2335e8e23a | ||
|
|
d02a010083 | ||
|
|
6676347353 | ||
|
|
c9391d840b | ||
|
|
51323b4a64 | ||
|
|
842ed76345 | ||
|
|
f3ed658dd0 | ||
|
|
6c83b8395b | ||
|
|
e58524ce19 | ||
|
|
9dced21554 | ||
|
|
3fdf55b53c | ||
|
|
f5b28e5a21 | ||
|
|
7a380d7df5 | ||
|
|
f7d6348ea9 | ||
|
|
46871299ca | ||
|
|
c281746fdc | ||
|
|
be7532dd74 | ||
|
|
108b53a7e2 | ||
|
|
38793efe6f | ||
|
|
4205188855 | ||
|
|
d2daed27a8 | ||
|
|
87b69d5ee8 | ||
|
|
e0d46aa87b | ||
|
|
dbe55be63f | ||
|
|
28f9e7faae | ||
|
|
820d3b1c8b | ||
|
|
278648aae6 | ||
|
|
9d9491253b | ||
|
|
499c6375f6 | ||
|
|
2b150060f4 | ||
|
|
b93cb73e0a | ||
|
|
d2d584d509 | ||
|
|
8bcb408a5b | ||
|
|
445b030160 | ||
|
|
8195808500 | ||
|
|
393ad136f6 | ||
|
|
00acfef28c | ||
|
|
efeed2b668 | ||
|
|
efc6379bb3 | ||
|
|
20a456591d | ||
|
|
1b301ff2c0 | ||
|
|
50c58e22ff | ||
|
|
ad2f08698d | ||
|
|
e05ab3beaf | ||
|
|
48b6d4f149 | ||
|
|
89edf290ff | ||
|
|
c77096b0f9 | ||
|
|
e333d0e9db | ||
|
|
324c1b1a17 | ||
|
|
ee3511c09a |
@@ -0,0 +1,63 @@
|
||||
name: Build and Push Containers
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., v1.2.3)'
|
||||
required: true
|
||||
default: 'manual'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
strategy:
|
||||
matrix:
|
||||
service: [Lactose, MilkStream]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.r3d.codes
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Determine version tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "VERSION=$(echo '${{ github.event.inputs.version }}' | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||
else
|
||||
echo "VERSION=$(echo '${{ gitea.ref_name }}' | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Lowercase repository name
|
||||
run: echo "REPO_LC=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
|
||||
- name: Lowercase service name
|
||||
run: echo "SERVICE_LC=$(echo '${{ matrix.service }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare build args for ${{ matrix.service }}
|
||||
run: |
|
||||
if [ "${{ matrix.service }}" = "Lactose" ]; then
|
||||
echo "BUILD_ARGS=SixLaborsLicenseKey=${{ secrets.SIXLABORS_KEY }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "BUILD_ARGS=APP_VERSION=$VERSION" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Build and push ${{ matrix.service }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./${{ matrix.service }}/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
git.r3d.codes/${{ env.REPO_LC }}/${{ env.SERVICE_LC }}:${{ env.VERSION }}
|
||||
git.r3d.codes/${{ env.REPO_LC }}/${{ env.SERVICE_LC }}:latest
|
||||
build-args: ${{ env.BUILD_ARGS }}
|
||||
@@ -1,320 +0,0 @@
|
||||
# MilkyShots – Agent Guidelines
|
||||
|
||||
This file captures the coding conventions, architecture rules, and standards extracted from the codebase. Follow them in every contribution to keep the project consistent.
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
MilkyShots is a .NET 8 media-library application composed of four projects:
|
||||
|
||||
| Project | Role |
|
||||
|---|---|
|
||||
| **Butter** | Shared class library – DTOs, enums, MIME-type tables, settings definitions |
|
||||
| **Lactose** | ASP.NET Core Web API backend – controllers, EF Core models, repositories, services, background jobs |
|
||||
| **MilkStream** | Blazor WebAssembly host – serves static WASM files and provides dynamic configuration |
|
||||
| **MilkStream.Client** | Blazor WASM client – Razor components, frontend services, SCSS styles (runs in browser) |
|
||||
|
||||
The backend stores data in **PostgreSQL** (with the `pgvecto-rs` extension) accessed via **Entity Framework Core**.
|
||||
The WASM client runs entirely in the browser and communicates with the Lactose backend over HTTP/JSON using named `HttpClient` instances.
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **.NET 8 / C# 12** — target framework and language version for all projects.
|
||||
- **ASP.NET Core** (Lactose) and **Blazor WebAssembly** (MilkStream.Client hosted by MilkStream).
|
||||
- **Entity Framework Core** with `Npgsql.EntityFrameworkCore.PostgreSQL`.
|
||||
- **JWT Bearer** authentication + refresh-token rotation.
|
||||
- **Bootstrap 5** and **SCSS** (compiled by `AspNetCore.SassCompiler` in debug) for styling.
|
||||
- **Docker / docker-compose** for containerised deployment.
|
||||
|
||||
---
|
||||
|
||||
## Solution Structure
|
||||
|
||||
```
|
||||
MilkyShots/
|
||||
├── Butter/ # Shared library
|
||||
│ ├── Dtos/ # DTOs grouped by entity (Album/, Asset/, …)
|
||||
│ ├── Settings/ # Settings enum + extensions
|
||||
│ └── Types/ # Shared enums (EAccessLevel, EAssetType)
|
||||
├── Lactose/ # Web API
|
||||
│ ├── Authorization/ # Claims transformation + authorization handlers
|
||||
│ ├── Configuration/ # Options classes (SignKeyConfiguration, SignKeyProvider)
|
||||
│ ├── Context/ # EF Core DbContext (LactoseDbContext)
|
||||
│ ├── Controllers/ # API controllers
|
||||
│ ├── Jobs/ # Background job infrastructure + concrete jobs
|
||||
│ ├── Mapper/ # Model → DTO mapping (static extension methods)
|
||||
│ ├── Migrations/ # EF Core migrations (generated – do NOT hand-edit)
|
||||
│ ├── Models/ # EF Core entity classes
|
||||
│ ├── Repositories/ # Interfaces + implementations for data access
|
||||
│ ├── Services/ # Application services (auth, DB initialiser, scheduler)
|
||||
│ └── Utils/ # Small helpers / extension methods
|
||||
├── MilkStream/ # Blazor WASM static host
|
||||
│ ├── Program.cs # Minimal API – serves WASM files + dynamic /appsettings.json endpoint
|
||||
│ └── appsettings.json # Server-side config (LactoseBaseUrl for dynamic injection)
|
||||
└── MilkStream.Client/ # Blazor WASM client (runs in browser)
|
||||
├── Components/ # Razor components (Pages/, Layout/, SettingBoxes/, …)
|
||||
├── Services/ # Frontend services (LoginService, MediaService, …)
|
||||
├── Styles/ # SCSS source files
|
||||
└── wwwroot/ # Static assets bundled into WASM
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### C# identifiers
|
||||
|
||||
| Category | Pattern | Example |
|
||||
|---|---|---|
|
||||
| Enums | `E` prefix | `EAccessLevel`, `EAssetType`, `EJobStatus` |
|
||||
| Interfaces | `I` prefix | `IAssetRepository`, `IAlbumRepository` |
|
||||
| DTOs | `…Dto` suffix (PascalCase) | `AssetDto`, `AlbumCreateDto` |
|
||||
| DTO sets: create / update / preview / full | `…CreateDto`, `…UpdateDto`, `…PreviewDto`, `…FullDto` | `AlbumCreateDto` |
|
||||
| Repository interfaces | `I…Repository` | `IAssetRepository` |
|
||||
| Repository implementations | `…Repository` | `AssetRepository` |
|
||||
| Mapper classes | `…Mapper` | `AssetsMapper`, `UsersMapper` |
|
||||
| Controller classes | `…Controller` | `AssetController` |
|
||||
| Background jobs | extend `Job` | `FileSystemCrawlJob` |
|
||||
| Services | `…Service` | `LactoseAuthService`, `LoginService` |
|
||||
|
||||
> **Note:** The suffix `DTO` (all-caps) appears in one legacy file (`PagedParametersDTO.cs`).
|
||||
> New files must use `Dto` (mixed-case) to match the rest of the codebase.
|
||||
|
||||
### Files and directories
|
||||
|
||||
- One public type per file; file name equals the type name.
|
||||
- Folders mirror the namespace suffix (e.g. `Lactose.Repositories` → `Lactose/Repositories/`).
|
||||
|
||||
---
|
||||
|
||||
## C# Coding Style
|
||||
|
||||
- Enable **nullable reference types** (`<Nullable>enable</Nullable>`) and **implicit usings** in all projects.
|
||||
- Prefer **primary constructors** (C# 12) for constructor injection:
|
||||
```csharp
|
||||
public class AssetController(
|
||||
ILogger<AssetController> logger,
|
||||
LactoseAuthService authService,
|
||||
IAssetRepository assetRepository
|
||||
) : ControllerBase { … }
|
||||
```
|
||||
- Use **`var`** for local variables when the type is obvious from the right-hand side.
|
||||
- Use **object initialisers** when creating entity or DTO instances.
|
||||
- Use the **`required`** keyword on properties that must be set during object construction.
|
||||
- Default string properties to `string.Empty`, not `null`.
|
||||
- Default collection navigation properties to `null` (lazy) — the EF Core model builder wires up relationships.
|
||||
- Mark navigation property regions with `#region Navigation Properties … #endregion`.
|
||||
|
||||
### Documentation comments
|
||||
|
||||
- Every **model class** and every property on it must have a `/// <summary>` XML comment.
|
||||
- Every **interface method** must have `<summary>`, `<param>`, and `<returns>` XML comments.
|
||||
- **Mapper classes** must have a `<summary>` on the class and on each method.
|
||||
- Controllers may omit XML comments on action methods (use inline comments instead).
|
||||
|
||||
### Logging
|
||||
|
||||
- Inject `ILogger<T>` into every controller and service.
|
||||
- Use structured logging with message templates (avoid string concatenation in log calls where possible).
|
||||
- Use verbatim interpolated string literals (`$"""…"""`) for multi-line log entries.
|
||||
- Log-level conventions:
|
||||
|
||||
| Level | When to use |
|
||||
|---|---|
|
||||
| `LogTrace` | Detailed per-request data (IDs, field values) |
|
||||
| `LogDebug` | Medium-detail operational info |
|
||||
| `LogInformation` | Key lifecycle events (job started, user registered, …) |
|
||||
| `LogWarning` | Non-fatal problems (missing resource, permission violation) |
|
||||
| `LogError` | Exceptions and failures |
|
||||
|
||||
---
|
||||
|
||||
## Entity / Model Rules
|
||||
|
||||
- Primary keys are **`Guid`** annotated with `[Key]`.
|
||||
- String columns must specify `[Column(TypeName = "VARCHAR(n)")]` with an appropriate max length.
|
||||
- Foreign-key scalar properties use `[ForeignKey(nameof(NavigationProperty))]`.
|
||||
- Timestamps follow the pattern: `CreatedAt`, `UpdatedAt`, `DeletedAt` (nullable `DateTime?` for soft delete).
|
||||
- **Soft delete** — entities are never hard-deleted from the database via normal application flows; set `DeletedAt = DateTime.Now` instead.
|
||||
- `[Index(nameof(Field), IsUnique = true)]` is used on fields that must be unique at the DB level.
|
||||
- Model classes live in `Lactose.Models`; shared enums/types live in `Butter.Types`.
|
||||
|
||||
---
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
- Every repository exposes its contract through an `I…Repository` interface in the same folder.
|
||||
- All repository interfaces implement `IDisposable` (or `IAsyncDisposable` where async teardown is needed).
|
||||
- Standard method set:
|
||||
|
||||
| Method | Signature |
|
||||
|---|---|
|
||||
| Find by PK | `T? Find(Guid id)` |
|
||||
| Find multiple | `IEnumerable<T> FindBulk(IEnumerable<Guid> ids)` |
|
||||
| Insert | `void Insert(T entity)` |
|
||||
| Update | `void Update(T entity)` (sets `UpdatedAt = DateTime.Now`) |
|
||||
| Bulk update | `void UpdateBulk(IEnumerable<T> entities)` |
|
||||
| Save | `void Save()` → `context.SaveChanges()` |
|
||||
| Delete | `void Delete(T entity)` or `void Delete(Guid id)` |
|
||||
|
||||
- **`Save()` must be called explicitly** after insert/update/delete operations; repositories do not auto-save except `FolderRepository.Create()` (which needs to fire an event after commit).
|
||||
- Repositories may expose **static events** for cross-service notification (e.g. `FolderRepository.FolderAdded`, `SettingsRepository.SettingChanged`). Keep these static and `EventHandler<T>` typed.
|
||||
- Register all repositories as **`Transient`** in `Program.cs`.
|
||||
|
||||
---
|
||||
|
||||
## Mapper / DTO Rules
|
||||
|
||||
- Mapper classes are **`public static`** and live in `Lactose.Mapper`.
|
||||
- Mapping methods are **extension methods** on the model type:
|
||||
```csharp
|
||||
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) { … }
|
||||
```
|
||||
- Naming convention: `To<TargetType>()` or `To<TargetType>(extraParam)`.
|
||||
- Never put business logic in mappers — only field assignments.
|
||||
|
||||
---
|
||||
|
||||
## Controller Rules
|
||||
|
||||
- Decorate every controller with `[ApiController]` and `[Route("api/[controller]")]`.
|
||||
- Route URLs are lowercase (`builder.Services.Configure<RouteOptions>(o => o.LowercaseUrls = true)`).
|
||||
- HTTP verb → CRUD mapping:
|
||||
|
||||
| Verb | Operation |
|
||||
|---|---|
|
||||
| `[HttpGet]` / `[HttpGet("{id}")]` | Read single / search/list |
|
||||
| `[HttpPut]` | Create a new resource |
|
||||
| `[HttpPost("{id}")]` | Update a specific resource |
|
||||
| `[HttpPost]` | Bulk update |
|
||||
| `[HttpDelete("{id}")]` | Delete a specific resource |
|
||||
| `[HttpDelete]` | Bulk delete |
|
||||
|
||||
- Return `ActionResult<T>` for endpoints that return a body, and `IStatusCodeActionResult` or `ActionResult` for status-only responses.
|
||||
- Standard error returns: `NotFound()`, `BadRequest()`, `Unauthorized()`, `Forbid()`, `Conflict(…)`.
|
||||
- Always validate `null` after a repository lookup and return `NotFound()` before proceeding.
|
||||
- Access-level checks must be performed inside the action method as well as via `[Authorize]` attributes (defence-in-depth).
|
||||
|
||||
---
|
||||
|
||||
## Authentication & Authorisation
|
||||
|
||||
- Auth uses **JWT Bearer** tokens (`JwtBearerDefaults.AuthenticationScheme`).
|
||||
- Access is controlled by three roles (in ascending privilege order):
|
||||
|
||||
| Role | Description |
|
||||
|---|---|
|
||||
| `User` | Read-only access to publicly shared / explicitly shared content |
|
||||
| `Curator` | Can create/update/delete their own content |
|
||||
| `Admin` | Unrestricted access |
|
||||
|
||||
- `[Authorize]` with no roles = any authenticated user.
|
||||
- `[Authorize(Roles = "Admin")]` or `[Authorize(Roles = "Admin, Curator")]` for restricted endpoints.
|
||||
Note the space after the comma in the role list — this is the project's established style.
|
||||
- Use `LactoseAuthService.GetUserData(User)` inside action methods to retrieve the typed `LactoseAuthenticatedUser` from claims.
|
||||
- Refresh tokens are stored (hashed by ASP.NET Identity) in the `User` table.
|
||||
`RefreshTokenTransformation` (a `IClaimsTransformation`) validates the refresh token on every request and adds a `"LoginValid"` claim.
|
||||
- Access tokens expire after **10 minutes**; refresh tokens expire after **60 minutes**.
|
||||
|
||||
---
|
||||
|
||||
## Background Jobs System
|
||||
|
||||
- All background jobs **extend `Job`** (abstract base in `Lactose.Jobs`).
|
||||
- Override `TaskJob(CancellationToken token)` to implement job logic.
|
||||
- Check `token.IsCancellationRequested` inside loops and call `Status.Cancel()` + `return` when set.
|
||||
- Use `Status.UpdateProgress(float, string)` to report progress (value between 0 and 1).
|
||||
- Use `Status.Complete(string)` / `Status.Fail(string)` for terminal states.
|
||||
- New job types must be instantiatable via `ActivatorUtilities.CreateInstance<T>(serviceProvider, extraArgs)`.
|
||||
- Enqueue jobs with `JobManager.CreateJob<T>(args)` then `JobManager.EnqueueJob(job)`.
|
||||
- Maximum concurrent jobs defaults to **8** (`JobManager.MaxConcurrentJobs`).
|
||||
- Timer-based scheduling is handled by `JobScheduler` which reacts to `SettingsRepository.SettingChanged`.
|
||||
|
||||
---
|
||||
|
||||
## Settings System
|
||||
|
||||
- Runtime settings are persisted in the `Settings` database table.
|
||||
- Canonical names are defined in `Butter.Settings.Settings` enum with `AsString()` extension.
|
||||
- Default values and metadata are loaded from **`Lactose/DefaultSettings.json`** on startup by `DbInitializer`.
|
||||
- When adding a new setting:
|
||||
1. Add an entry to `Settings` enum and `AsString()`.
|
||||
2. Add the default entry to `DefaultSettings.json`.
|
||||
3. React to changes via `SettingsRepository.SettingChanged` event.
|
||||
|
||||
---
|
||||
|
||||
## Frontend (MilkStream.Client / Blazor WASM)
|
||||
|
||||
- Use **Blazor WebAssembly** – the client runs entirely in the browser.
|
||||
- Pages live in `MilkStream.Client/Components/Pages/`, reusable components in `MilkStream.Client/Components/` (or a subfolder by concern).
|
||||
- Services that call the backend extend either `ServiceBase` (no auth) or `AuthServiceBase` (requires auth).
|
||||
- `AuthServiceBase` subscribes to `LoginService.AuthInfoChanged` to keep the `Authorization` header up to date.
|
||||
- Use the **`"MilkStreamClient"`** named `HttpClient` (configured with `JwtTokenRefresher`) for any authenticated API call.
|
||||
- `JwtTokenRefresher` automatically refreshes the access token when it is expired or expires within 1 minute.
|
||||
- Use `ProtectedLocalStorage` / `ProtectedSessionStorage` (Blazor's encrypted storage) for auth-related persistence.
|
||||
|
||||
### Dynamic Configuration
|
||||
- MilkStream's `Program.cs` registers a `GET /appsettings.json` endpoint **before** `UseStaticFiles()`, shadowing the static file.
|
||||
- This endpoint returns `{ "BaseUrl": "<LactoseBaseUrl>" }` from server-side configuration, ensuring the WASM client always receives the externally-reachable Lactose URL.
|
||||
- The `LactoseBaseUrl` setting is injected via environment variable in Docker deployments and configured in `appsettings.json` / `appsettings.Development.json` for local development.
|
||||
|
||||
- SCSS source files go in `MilkStream.Client/Styles/`; compiled output is written to `MilkStream.Client/wwwroot/css/` (do not edit the compiled files by hand).
|
||||
|
||||
---
|
||||
|
||||
## Docker / Deployment
|
||||
|
||||
- Each deployable project has its own **multi-stage `Dockerfile`** (build → publish → base).
|
||||
- `docker-compose.yml` at repo root wires up `lactose`, `milkstream`, and `database` services.
|
||||
- Media is mounted as a **read-only volume** at `/diary` inside the `lactose` container.
|
||||
- Data-protection keys for MilkStream are persisted via a named Docker volume.
|
||||
- Environment-specific config is loaded from `appsettings.{EnvironmentName}.json`; secrets are injected via environment variables or Docker secrets — **never commit real credentials**.
|
||||
- When `CorsAllowedOrigins` is set to `["*"]` in Lactose configuration, the API accepts requests from any origin (useful for browser-based WASM clients in unpredictable deployment environments). Lock this down to specific origins in production if needed.
|
||||
|
||||
---
|
||||
|
||||
## Configuration & Secrets
|
||||
|
||||
- `appsettings.json` contains placeholder/default values only.
|
||||
- Sensitive values (DB password, JWT signing key) must be provided via environment variables or .NET User Secrets (development only, keyed by `UserSecretsId`).
|
||||
- The JWT signing key (`SignKey:Key`) must be **at least 32 characters**.
|
||||
- The DB port mapping in `docker-compose.yml` exposes PostgreSQL on host port `3306` (maps to container port `5432`).
|
||||
|
||||
---
|
||||
|
||||
## Git & Commit Conventions
|
||||
|
||||
This project uses **Conventional Commits** and generates a changelog with **git-cliff**.
|
||||
|
||||
| Prefix | Purpose |
|
||||
|---|---|
|
||||
| `feat:` | New feature |
|
||||
| `fix:` | Bug fix |
|
||||
| `refactor:` | Code restructuring without behaviour change |
|
||||
| `perf:` | Performance improvement |
|
||||
| `doc:` | Documentation changes |
|
||||
| `style:` | Formatting / style only |
|
||||
| `test:` | Tests |
|
||||
| `chore:` / `ci:` | Build system, tooling, CI |
|
||||
| `revert:` | Revert a previous commit |
|
||||
|
||||
- Commits with `chore(release):`, `chore(deps)`, `chore(pr)`, `chore(pull)` are excluded from the changelog automatically.
|
||||
- Breaking changes must be marked with `[**breaking**]` in the commit body or footer.
|
||||
|
||||
---
|
||||
|
||||
## TODOs & Known Gaps (as of initial analysis)
|
||||
|
||||
The following items appear as `TODO` comments in the codebase and should be tracked:
|
||||
|
||||
- `AssetController.Create` — endpoint marked `[NonAction]`; purpose unclear, may need removal or redesign.
|
||||
- `AlbumController`, `FolderController`, `SettingsController`, `TagController` — logging not yet wired in.
|
||||
- `AuthController.Register` — should return a full `AuthResultDto` instead of a bare Guid.
|
||||
- `UserController.Get` / `GetAll` — lower access levels should receive reduced user information.
|
||||
- `FileSystemCrawlJob.AssetFromPath` — folder ID is not populated when crawling.
|
||||
- `TagController.Get` (ancestors traversal) — not protected against circular references.
|
||||
- Self-registration flow in `AuthController` is gated by the `UserRegistrationEnabled` setting but that check is not yet implemented in the controller.
|
||||
@@ -8,3 +8,7 @@ storageImages/
|
||||
dotnet-tools.json
|
||||
.opencode/
|
||||
*.css.map
|
||||
# SixLabors license file
|
||||
/Lactose/sixlabors.lic
|
||||
.desloppify/
|
||||
.claude/
|
||||
+1
-15
@@ -1,20 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="MariaDb" uuid="eab007eb-4f50-4961-9bce-5c4940a7cdff">
|
||||
<driver-ref>mariadb</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mariadb://localhost:3306/</jdbc-url>
|
||||
<jdbc-additional-properties>
|
||||
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.resource.type" value="Deployment" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||
</jdbc-additional-properties>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
<time-zone>Europe/Rome</time-zone>
|
||||
</data-source>
|
||||
<data-source source="LOCAL" name="TestDb@localhost" uuid="80498c22-67da-4c4a-8106-c4789470ef83">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
@@ -31,7 +17,7 @@
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://192.168.51.101:3306/TestDb</jdbc-url>
|
||||
<jdbc-url>jdbc:postgresql://192.168.51.101:5432/TestDb</jdbc-url>
|
||||
<jdbc-additional-properties>
|
||||
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||
|
||||
+2
-3
@@ -3,9 +3,8 @@
|
||||
<component name="db-forest-configuration">
|
||||
<data version="2">.
|
||||
----------------------------------------
|
||||
1:0:eab007eb-4f50-4961-9bce-5c4940a7cdff
|
||||
2:0:80498c22-67da-4c4a-8106-c4789470ef83
|
||||
3:0:a0f3e480-c856-41e0-8fb4-5cac87b9faf6
|
||||
1:0:80498c22-67da-4c4a-8106-c4789470ef83
|
||||
2:0:a0f3e480-c856-41e0-8fb4-5cac87b9faf6
|
||||
.</data>
|
||||
</component>
|
||||
</project>
|
||||
@@ -20,6 +20,8 @@
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
<method v="2">
|
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,34 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Compose Deployment (PM) Win" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker-PM">
|
||||
<deployment type="docker-compose.yml">
|
||||
<settings>
|
||||
<option name="containerName" value="" />
|
||||
<option name="envFilePath" value="" />
|
||||
<option name="envFilePaths">
|
||||
<list>
|
||||
<option value="" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="envVars">
|
||||
<list>
|
||||
<DockerEnvVarImpl>
|
||||
<option name="name" value="ASPNETCORE_ENVIRONMENT" />
|
||||
<option name="value" value="Docker" />
|
||||
</DockerEnvVarImpl>
|
||||
</list>
|
||||
</option>
|
||||
<option name="removeOrphansOnComposeDown" value="false" />
|
||||
<option name="commandLineOptions" value="--build" />
|
||||
<option name="secondarySourceFiles">
|
||||
<list>
|
||||
<option value="docker-compose.pm.yml" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="sourceFilePath" value="docker-compose.yml" />
|
||||
<option name="upRemoveOrphans" value="true" />
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -29,6 +29,8 @@
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
<method v="2">
|
||||
<option name="RunConfigurationTask" enabled="false" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,17 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="VersionEnvGen" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/generate-version.sh" />
|
||||
<option name="SCRIPT_OPTIONS" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||
<option name="INTERPRETER_PATH" value="/bin/zsh" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="true" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,17 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="VersionEnvGenWin" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="false" />
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/generate-version.ps1" />
|
||||
<option name="SCRIPT_OPTIONS" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="false" />
|
||||
<option name="INTERPRETER_PATH" value=""C:/Program Files/PowerShell/7/pwsh.exe"" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="EXECUTE_IN_TERMINAL" value="false" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="true" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -7,7 +7,7 @@
|
||||
- **.NET 10 SDK** (pinned in `global.json`; `allowPrerelease: true`)
|
||||
- **PostgreSQL with pgvector extension**
|
||||
- **SCSS** compiled by `AspNetCore.SassCompiler` MSBuild task (no Node.js needed; compiles during `dotnet build`)
|
||||
- Run `dotnet tool restore` before first migration — installs `dotnet-ef`
|
||||
- Run `dotnet tool restore` before first migration — installs `dotnet-ef` (tool manifest at `dotnet-tools.json` in repo root)
|
||||
|
||||
## SCSS
|
||||
|
||||
@@ -24,11 +24,12 @@ dotnet build # compiles SCSS automatically
|
||||
- Aggressive gradient mixins in `Styles/_gradients.scss`
|
||||
- Works in Docker — MSBuild task runs during `dotnet build`/`dotnet publish`, no hosted service
|
||||
|
||||
## Projects (4 in solution)
|
||||
## Projects (5 in solution)
|
||||
|
||||
| Project | Role | Entrypoint |
|
||||
|---|---|---|
|
||||
| **Butter** | Shared class library (DTOs, enums, MIME types) | — |
|
||||
| **Lactose.Analyzers** | Roslyn analyzer — enforces `DateTime.UtcNow` (not `DateTime.Now`) as error MS001 | — |
|
||||
| **Lactose** | ASP.NET Core Web API — controllers, EF Core, repos, background jobs | `Lactose/Program.cs` |
|
||||
| **MilkStream** | Blazor WASM host — serves WASM files + dynamic `/appsettings.json` | `MilkStream/Program.cs` |
|
||||
| **MilkStream.Client** | Blazor WASM client — Razor components, SCSS, frontend services, shared UI components (ModalFrame, EmptyState) | `MilkStream.Client/Program.cs` |
|
||||
@@ -43,16 +44,14 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
|
||||
|
||||
**Database:** Default credentials in `appsettings.json`. Docker compose spins up all services.
|
||||
|
||||
**Port gotcha:** `appsettings.json` defaults `DatabaseAddress:Port` to **3306** (legacy MySQL port). PostgreSQL runs on 5432. The raw connection string must be overridden, or use `docker-compose.yml` which maps host 3306 → container 5432 automatically.
|
||||
|
||||
## Infrastructure gotchas
|
||||
|
||||
- **Npgsql legacy timestamps:** `AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true)` is set in `Lactose/Program.cs`. Do not remove without understanding the timestamp implications.
|
||||
- **MilkStream `/appsettings.json`:** Dynamically generated from server config — maps **before** `UseStaticFiles`, so it shadows the static file in `MilkStream.Client/wwwroot/`. This endpoint provides `LactoseBaseUrl` to the WASM client.
|
||||
- **Auth requires claims transformation:** `RefreshTokenTransformation` (registered as `IClaimsTransformation`) must succeed on every authenticated request, or all endpoints return 403.
|
||||
- **EF Core Tools v8.0.15 vs EF Core Design v10.0.9:** Version mismatch may cause `dotnet ef` CLI quirks.
|
||||
- **`dotnet-ef` CLI vs Microsoft.EntityFrameworkCore.Tools:** The global `dotnet-ef` (from `dotnet-tools.json`, v10.0.9) is the correct CLI to use. The Tools package reference in `Lactose.csproj` (v10.0.9) provides the `dotnet ef` target for `dotnet build`-time validation.
|
||||
- **WASM client DI:** `LoginService` is **Singleton**; all other services are `Scoped`. Two `HttpClient` registrations: one unauthenticated (default) for login, and one named `"MilkstreamClient"` which has `JwtTokenRefresher` as a `DelegatingHandler`. The handler transparently refreshes expired tokens pre-flight and retries once on 401 — authenticated services must **never** handle token refreshes manually. Do not add `SendWithRefreshAsync` or similar wrappers; rely solely on the named client.
|
||||
- **HTTPS redirection is commented out** in Lactose — API does not enforce HTTPS.
|
||||
- **DateTime analyzer:** `Lactose.Analyzers` treats `DateTime.Now` as error MS001, `DateTime.Today` as error MS002, `DateTime.Date` as warning MS003, and non-UTC `SpecifyKind` as error MS004. Always use `DateTime.UtcNow`. All timestamp columns use `timestamp with time zone`.
|
||||
|
||||
## XML docs enforced
|
||||
|
||||
@@ -64,12 +63,56 @@ 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`
|
||||
- **Granular commits** — commit each logical change separately (e.g., DTO change, controller logic, UI component) with a descriptive commit message
|
||||
- **Reuse API endpoints** — prefer adding optional query parameters to existing endpoints over creating new routes. New endpoints are a last resort when the existing ones fundamentally cannot serve the need.
|
||||
|
||||
## Modularity
|
||||
## Visibility Rules (R1–R7)
|
||||
|
||||
Defined in issue [#129](https://git.r3d.codes/MilkyShots/MilkyShots/issues/129). Each entity (Asset, Album, Person) carries **independent** `EVisibility` — no inheritance from container to child.
|
||||
|
||||
### R1 — Independent Visibility
|
||||
No entity's visibility is overridden by its container. An asset in a Public album does NOT become Public; the asset is only visible if the viewer has permission for the asset's own visibility.
|
||||
|
||||
### R2 — Asset Visibility (Per-Asset Gate)
|
||||
|
||||
| Actor | Public | Protected | Private |
|
||||
|---|---|---|---|
|
||||
| Anonymous | ✅ | ❌ | ❌ |
|
||||
| User | ✅ | ✅ | ❌ |
|
||||
| Maintainer (not owning / not linked) | ✅ | ✅ | ❌ |
|
||||
| Maintainer (uploaded by self) | ✅ | ✅ | ✅ |
|
||||
| Maintainer (album → person they maintain) | ✅ | ✅ | ✅ |
|
||||
| Curator (non-deleted) | ✅ | ✅ | ✅ |
|
||||
| Curator (deleted, own upload) | ✅ | ✅ | ✅ |
|
||||
| Admin (all, including deleted) | ✅ | ✅ | ✅ |
|
||||
|
||||
### R3 — Album Access Gating
|
||||
Album's own visibility determines who can access it. Not visible → 404.
|
||||
|
||||
| Actor | Public | Protected | Private |
|
||||
|---|---|---|---|
|
||||
| Anonymous / User | ✅ | ✅ | ❌ |
|
||||
| Maintainer (unassigned) | ✅ | ✅ | ❌ |
|
||||
| Maintainer (maintains PersonOwner) | ✅ | ✅ | ✅ |
|
||||
| Curator / Admin | ✅ | ✅ | ✅ |
|
||||
|
||||
### R4 — Person Access Gating
|
||||
Same as R3. Not visible → 404.
|
||||
|
||||
### R5 — Media Serving
|
||||
Uses per-asset rules (R2). Album provenance not checked — too complex, marginal benefit.
|
||||
|
||||
### R6 — Edit Mode (Frontend)
|
||||
API returns Private assets to privileged users (Admin/Curator/Maintainer-on-owned-content). Frontend hides them in browse mode, shows them in edit/curator mode. No backend flag.
|
||||
|
||||
### R7 — Deleted Assets
|
||||
- **Admin**: all deleted assets visible
|
||||
- **Curator**: own deleted uploads visible
|
||||
- **Everyone else**: deleted assets hidden (`DeletedAt IS NULL`)
|
||||
|
||||
Reusable UI components live in `MilkStream.Client/Components/Shared/`.
|
||||
When a markup pattern appears in 2+ places, extract it into a shared component.
|
||||
@@ -85,13 +128,63 @@ 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
|
||||
```
|
||||
|
||||
**Database indexes for performance** should be defined using the **fluent API** in `LactoseDbContext.OnModelCreating` via `HasIndex().HasFilter()`. Do not use the `[Index]` data annotation — the `Filter` parameter is not available in the current package configuration (EF Core Tools v10.0.9 vs Design v10.0.9 mismatch). Example:
|
||||
|
||||
```csharp
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
```
|
||||
|
||||
- **Trigram GIN indexes** on `People.Name` and `Albums.Title` use `HasMethod("gin")` with `HasOperators("gin_trgm_ops")` for case-insensitive `ILike` search.
|
||||
- **pgvector and pg_trgm** extensions are enabled in `OnModelCreating` via `HasPostgresExtension`.
|
||||
|
||||
## Tests
|
||||
|
||||
No test project found in solution.
|
||||
No unit/integration test project exists. API endpoint testing is done via **REST Client `.http` files**:
|
||||
|
||||
- **`Lactose/WepApiTest.http`** — 100+ numbered tests covering all API endpoints across 12 sections (Auth → User CRUD → Person → Album → Tag → Asset → Stats → Settings → Data Visibility → Visibility Gates → Cleanup). Tests verify authorization at every level (anonymous, user, curator, admin) and include comprehensive visibility gate tests (R2–R4). Self-contained — all test data is created at runtime and cleaned up.
|
||||
- **`Lactose/http-client.env.json`** — provides pre-filled variable values (`userName`, `curatorName`, etc.) for the REST Client extension.
|
||||
- Open in VS Code/Rider with the REST Client extension and run individual tests or the full suite.
|
||||
- Server must be running locally (default `http://localhost:5162`).
|
||||
- Paginated list tests (people, albums, users) use `?page=0&pageSize=5` — change these carefully.
|
||||
|
||||
## PR workflow
|
||||
|
||||
- Before starting work, ask the user whether they want the fix in a PR or directly on `develop` (for quick/trivial fixes).
|
||||
- Larger work always goes in a PR.
|
||||
- Always mark the PR as **draft** until the user confirms they are satisfied and have tested it works.
|
||||
- When making the PR ready (removing draft status), mark it ready for review and request:
|
||||
- **REDCODE** when the PR contains frontend changes
|
||||
- **Fastwind** when the PR contains backend changes
|
||||
- **Both** when both areas are touched
|
||||
|
||||
## Data sizes
|
||||
|
||||
Approximate record counts in the database:
|
||||
|
||||
| Entity | Count |
|
||||
|---|---|
|
||||
| Assets | 1,000,000+ |
|
||||
| Albums | 10,000+ |
|
||||
| People | 1,000+ |
|
||||
| Users | 100+ |
|
||||
| Maintainers | 10+ |
|
||||
| Curators | single digit |
|
||||
| Admins | single digit |
|
||||
|
||||
## Gitea labels
|
||||
|
||||
Labels live at two levels:
|
||||
- **Org level** (`MilkyShots` org): generic issue types (`bug`, `enhancement`, `help wanted`, `question`, `duplicate`, `invalid`, `wontfix`)
|
||||
- **Repo level** (`MilkyShots/MilkyShots`): project-specific labels (`area:frontend`, `area:backend`, `page:*`, `type:refactor`, `priority:*`, etc.)
|
||||
- When labelling issues, check both levels with the `label_read` tool (`list_org_labels` / `list_repo_labels`).
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,25 +9,38 @@ public class AlbumAssetPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution width.
|
||||
/// </summary>
|
||||
public int ResolutionWidth { get; set; }
|
||||
public int ResolutionWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
public int ResolutionHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a thumbnail.
|
||||
/// </summary>
|
||||
public bool HasThumbnail { get; set; }
|
||||
public bool HasThumbnail { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a preview.
|
||||
/// </summary>
|
||||
public bool HasPreview { get; set; }
|
||||
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; }
|
||||
public string? FileName { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the soft-delete timestamp. Only populated for admins and the asset uploader.
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,21 +9,21 @@ public class AlbumCreateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the album.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the album.
|
||||
/// </summary>
|
||||
public Guid? Owner { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID associated with the album.
|
||||
/// </summary>
|
||||
public Guid? Person { get; set; }
|
||||
public Guid? Person { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover asset ID for the album.
|
||||
/// </summary>
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of asset IDs in the album.
|
||||
/// </summary>
|
||||
public List<Guid>? Assets { get; set; }
|
||||
public List<Guid>? Assets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
|
||||
/// </summary>
|
||||
public EVisibility Visibility { get; set; } = EVisibility.Protected;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to merge multiple albums into a single destination album.
|
||||
/// </summary>
|
||||
public class AlbumMergeDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the destination album that will receive all assets.
|
||||
/// Must not be present in <see cref="SourceIds"/>.
|
||||
/// </summary>
|
||||
public required Guid DestinationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of source album IDs whose assets will be moved to the destination.
|
||||
/// Source albums will be hard-deleted after the merge.
|
||||
/// </summary>
|
||||
public required List<Guid> SourceIds { get; set; } = [];
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,29 +9,41 @@ public class AlbumPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the album.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the album.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the album.
|
||||
/// </summary>
|
||||
public Guid? Owner { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID associated with the album.
|
||||
/// </summary>
|
||||
public Guid? Person { get; set; }
|
||||
public Guid? Person { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the person name associated with the album.
|
||||
/// </summary>
|
||||
public string? PersonName { get; set; }
|
||||
public string? PersonName { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the cover asset for this album.
|
||||
/// </summary>
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover image width, if a cover exists.
|
||||
/// </summary>
|
||||
public int? CoverWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover image height, if a cover exists.
|
||||
/// </summary>
|
||||
public int? CoverHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets in the album.
|
||||
/// </summary>
|
||||
public int AssetCount { get; set; }
|
||||
public int AssetCount { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the soft-delete timestamp. Always null for albums (not supported).
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
}
|
||||
@@ -8,4 +8,15 @@ public class AlbumSearchParametersDto : PagedSearchParametersDto {
|
||||
/// Gets or sets whether to return only albums with no person assigned.
|
||||
/// </summary>
|
||||
public bool Unassigned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user ID to filter albums by.
|
||||
/// Only returns albums that contain at least one asset uploaded by the specified user.
|
||||
/// </summary>
|
||||
public Guid? AssetUploadedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID to filter albums by their owner.
|
||||
/// </summary>
|
||||
public Guid? PersonOwnerId { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,25 +9,25 @@ public class AlbumUpdateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the album.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the album.
|
||||
/// </summary>
|
||||
public Guid? Owner { get; set; }
|
||||
public string? Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID associated with the album.
|
||||
/// </summary>
|
||||
public Guid? Person { get; set; }
|
||||
public Guid? Person { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether to remove the person assignment from the album.
|
||||
/// </summary>
|
||||
public bool RemovePerson { get; set; }
|
||||
public bool RemovePerson { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover asset ID for the album.
|
||||
/// </summary>
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of asset IDs in the album.
|
||||
/// </summary>
|
||||
public List<Guid>? Assets { get; set; }
|
||||
public List<Guid>? Assets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the parameters for browsing assets within a folder at a specific directory level.
|
||||
/// </summary>
|
||||
public class AssetBrowseOptionsDto : PagedSearchParametersDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the root folder ID to browse within.
|
||||
/// </summary>
|
||||
public Guid FolderId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the current path relative to the folder root (empty or null for root).
|
||||
/// </summary>
|
||||
public string? Path { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether to only include assets not assigned to any album. Defaults to true.
|
||||
/// </summary>
|
||||
public bool UnlinkedOnly { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of browsing unlinked assets at a specific directory level within a folder.
|
||||
/// </summary>
|
||||
public class AssetBrowseResultDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the list of immediate subdirectories at the current path level.
|
||||
/// </summary>
|
||||
public List<DirectoryEntryDto> Directories { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Gets or sets the list of assets located directly at the current path level.
|
||||
/// </summary>
|
||||
public List<AssetPreviewDto> Assets { 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; }
|
||||
}
|
||||
@@ -9,41 +9,42 @@ public class AssetCreateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the creation date of the asset.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the asset.
|
||||
/// Gets or sets the ID of the user who uploaded the asset.
|
||||
/// Null for scanner-created assets (system uploader will be used).
|
||||
/// </summary>
|
||||
public Guid Owner { get; set; }
|
||||
public Guid? UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the type of the asset.
|
||||
/// </summary>
|
||||
public EAssetType AssetType { get; set; }
|
||||
public EAssetType AssetType { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset is publicly shared.
|
||||
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
|
||||
/// </summary>
|
||||
public bool IsPublic { get; set; }
|
||||
public EVisibility Visibility { get; set; } = EVisibility.Private;
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset.
|
||||
/// </summary>
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution width in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionWidth { get; set; }
|
||||
public int ResolutionWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
public int ResolutionHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the file size in bytes.
|
||||
/// </summary>
|
||||
public long FileSize { get; set; }
|
||||
public long FileSize { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the duration in seconds (for video/audio).
|
||||
/// </summary>
|
||||
public int? Duration { get; set; }
|
||||
public int? Duration { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the frame rate (for video).
|
||||
/// </summary>
|
||||
public int? FrameRate { get; set; }
|
||||
public int? FrameRate { get; set; }
|
||||
}
|
||||
|
||||
@@ -10,41 +10,33 @@ public class AssetDto : AssetPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the type of the asset (e.g., Image, Video).
|
||||
/// </summary>
|
||||
public EAssetType AssetType { get; set; }
|
||||
public EAssetType AssetType { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset is publicly shared.
|
||||
/// Gets or sets the user who uploaded the asset.
|
||||
/// </summary>
|
||||
public bool IsPublic { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the owner of the asset.
|
||||
/// </summary>
|
||||
public required UserInfoDto Owner { get; set; }
|
||||
public required UserInfoDto UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the creation date of the asset.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the last updated date of the asset.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the deletion date of the asset.
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset.
|
||||
/// </summary>
|
||||
public new string MimeType { get; set; } = string.Empty;
|
||||
public new string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the file size of the asset in bytes.
|
||||
/// </summary>
|
||||
public long FileSize { get; set; }
|
||||
public long FileSize { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the duration of the asset in seconds (for video/audio).
|
||||
/// </summary>
|
||||
public float Duration { get; set; }
|
||||
public float Duration { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the frame rate of the asset (for video).
|
||||
/// </summary>
|
||||
public float FrameRate { get; set; }
|
||||
public float FrameRate { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a group of unlinked assets (not assigned to any album),
|
||||
/// grouped by folder for drill-down browsing in the asset picker.
|
||||
/// </summary>
|
||||
public class AssetGroupDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the folder ID of the group. Null for assets with no folder or for the maintainer's "My Uploads" group.
|
||||
/// </summary>
|
||||
public Guid? FolderId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the display name of the group (folder base path, or "My Uploads" for maintainers).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the count of unlinked assets in this group.
|
||||
/// </summary>
|
||||
public int AssetCount { get; set; }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,45 +9,65 @@ public class AssetPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the asset.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset.
|
||||
/// </summary>
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution width of the asset in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionWidth { get; set; }
|
||||
public int ResolutionWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height of the asset in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
public int ResolutionHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a generated thumbnail.
|
||||
/// </summary>
|
||||
public bool HasThumbnail { get; set; }
|
||||
public bool HasThumbnail { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a generated preview.
|
||||
/// </summary>
|
||||
public bool HasPreview { get; set; }
|
||||
public bool HasPreview { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of album titles associated with the asset.
|
||||
/// </summary>
|
||||
public List<string>? AlbumNames { get; set; }
|
||||
public List<string>? AlbumNames { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of album IDs associated with the asset, parallel to <see cref="AlbumNames"/>.
|
||||
/// </summary>
|
||||
public List<Guid>? AlbumIds { get; set; }
|
||||
public List<Guid>? AlbumIds { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of cosplayer names associated with the asset, derived from album person owners.
|
||||
/// </summary>
|
||||
public List<string>? CosplayerNames { get; set; }
|
||||
public List<string>? CosplayerNames { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of cosplayer IDs associated with the asset, parallel to <see cref="CosplayerNames"/>.
|
||||
/// </summary>
|
||||
public List<Guid>? CosplayerIds { get; set; }
|
||||
public List<Guid>? CosplayerIds { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the original filename of the asset.
|
||||
/// </summary>
|
||||
public string? FileName { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
/// <summary>
|
||||
/// 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; }
|
||||
}
|
||||
|
||||
@@ -26,4 +26,34 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
|
||||
/// Gets or sets whether to order results randomly instead of by date.
|
||||
/// </summary>
|
||||
public bool Random { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether to filter for assets not assigned to any album (unlinked).
|
||||
/// </summary>
|
||||
public bool Unlinked { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the folder ID to filter assets by their scan folder.
|
||||
/// </summary>
|
||||
public Guid? FolderId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the uploader user ID to filter assets by their uploader.
|
||||
/// </summary>
|
||||
public Guid? UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether to filter exclusively for soft-deleted assets.
|
||||
/// Results remain scoped by the requester's access level.
|
||||
/// </summary>
|
||||
public bool DeletedOnly { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the album ID to filter assets belonging to that album.
|
||||
/// </summary>
|
||||
public Guid? AlbumId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID to filter assets in albums owned by that person.
|
||||
/// </summary>
|
||||
public Guid? PersonId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the total matching count should be computed and returned in the <c>X-Total-Count</c> header.
|
||||
/// Skipping it avoids a full count query over the filtered result set.
|
||||
/// </summary>
|
||||
public bool IncludeCount { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
@@ -5,15 +7,17 @@ namespace Butter.Dtos.Asset;
|
||||
/// </summary>
|
||||
public class AssetUpdateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the new owner ID for the asset.
|
||||
/// Gets or sets the ID of the user who uploaded the asset.
|
||||
/// </summary>
|
||||
public Guid? Owner { get; set; }
|
||||
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 whether the asset is publicly shared.
|
||||
/// Gets or sets the visibility level.
|
||||
/// </summary>
|
||||
public bool? IsPublic { get; set; }
|
||||
public EVisibility? Visibility { get; set; }
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a subdirectory entry in the asset file browser.
|
||||
/// </summary>
|
||||
public class DirectoryEntryDto {
|
||||
/// <summary>
|
||||
/// 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>
|
||||
|
||||
@@ -4,6 +4,12 @@ namespace Butter.Dtos;
|
||||
/// Represents pagination parameters for a query.
|
||||
/// </summary>
|
||||
public class PagedParametersDto {
|
||||
|
||||
/// <summary>
|
||||
/// Contains the global constant for max pages.
|
||||
/// </summary>
|
||||
public const int MaxPageSize = 250;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the page number (zero-based).
|
||||
/// </summary>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,21 +9,25 @@ public class PersonCreateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the person.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID to use as the profile picture.
|
||||
/// </summary>
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropX { get; set; }
|
||||
public float? ProfileCropX { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropY { get; set; }
|
||||
public float? ProfileCropY { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the zoom level for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
|
||||
/// </summary>
|
||||
public EVisibility Visibility { get; set; } = EVisibility.Protected;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
@@ -39,7 +40,19 @@ public class PersonDetailedDto {
|
||||
/// </summary>
|
||||
public int TotalAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of album previews associated with this person.
|
||||
/// </summary>
|
||||
public List<AlbumPreviewDto>? Albums { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the IDs of users who maintain this person.
|
||||
/// </summary>
|
||||
public List<Guid>? MaintainerUserIds { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the usernames of users who maintain this person.
|
||||
/// </summary>
|
||||
public List<string>? MaintainerUsernames { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to merge multiple people into a single destination person.
|
||||
/// </summary>
|
||||
public class PersonMergeDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the destination person that will receive all associated data.
|
||||
/// Must not be present in <see cref="SourceIds"/>.
|
||||
/// </summary>
|
||||
public required Guid DestinationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of source person IDs whose albums, faces, and maintainers
|
||||
/// will be reassigned to the destination. Source people will be hard-deleted.
|
||||
/// </summary>
|
||||
public required List<Guid> SourceIds { get; set; } = [];
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,29 +9,33 @@ public class PersonPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the person.
|
||||
/// </summary>
|
||||
public required Guid Id { get; set; }
|
||||
public required Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the person.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID used as the profile picture.
|
||||
/// </summary>
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropX { get; set; }
|
||||
public float? ProfileCropX { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropY { get; set; }
|
||||
public float? ProfileCropY { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the zoom level for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of albums associated with this person.
|
||||
/// </summary>
|
||||
public int TotalAlbums { get; set; }
|
||||
public int TotalAlbums { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,21 +9,29 @@ 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>
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropX { get; set; }
|
||||
public float? ProfileCropX { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropY { get; set; }
|
||||
public float? ProfileCropY { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the zoom level for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of user IDs that should be maintainers of this person (admin/curator only).
|
||||
/// </summary>
|
||||
public List<Guid>? MaintainerUserIds { get; set; }
|
||||
}
|
||||
|
||||
@@ -75,7 +75,11 @@ public class StatsDto {
|
||||
/// </summary>
|
||||
public int PublicAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of non-publicly-shared assets.
|
||||
/// Gets or sets the number of assets with protected visibility (authenticated only).
|
||||
/// </summary>
|
||||
public int ProtectedAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of private assets.
|
||||
/// </summary>
|
||||
public int PrivateAssets { get; set; }
|
||||
|
||||
@@ -104,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; }
|
||||
@@ -116,10 +124,9 @@ public class StatsDto {
|
||||
/// </summary>
|
||||
public int AlbumsMissingCover { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of people/cosplayers without an album that has a cover image.
|
||||
/// Gets or sets the number of people/cosplayers without a profile picture.
|
||||
/// </summary>
|
||||
/// <remarks>Not wired yet — reserved for future use.</remarks>
|
||||
public int CosplayersMissingCover { get; set; }
|
||||
public int CosplayersMissingProfile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the top tags by asset count.
|
||||
|
||||
@@ -46,4 +46,13 @@ public class UserInfoDto {
|
||||
/// Gets or sets the access level of the user.
|
||||
/// </summary>
|
||||
public required EAccessLevel AccessLevel { get; set; } = EAccessLevel.User;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the IDs of persons (cosplayers) this user is a maintainer of.
|
||||
/// </summary>
|
||||
public List<Guid>? MaintainedPersonIds { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the names of persons (cosplayers) this user is a maintainer of.
|
||||
/// </summary>
|
||||
public List<string>? MaintainedPersonNames { get; set; }
|
||||
}
|
||||
|
||||
@@ -38,4 +38,8 @@ public string? OldPassword { get; set; }
|
||||
/// Gets or sets the new access level.
|
||||
/// </summary>
|
||||
public EAccessLevel? AccessLevel { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of person (cosplayer) IDs this user should maintain (admin only).
|
||||
/// </summary>
|
||||
public List<Guid>? MaintainedPersonIds { 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"]),
|
||||
|
||||
@@ -22,6 +22,8 @@ public enum DisplayType {
|
||||
TimePicker = 7,
|
||||
/// <summary>Power of 2 selection.</summary>
|
||||
Po2W = 8,
|
||||
/// <summary>User picker (searchable dropdown).</summary>
|
||||
UserSelect = 9,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -31,13 +31,21 @@ public enum Settings {
|
||||
/// <summary>Quality setting (1–100) for WebP thumbnail encoding.</summary>
|
||||
ThumbnailQuality,
|
||||
/// <summary>Quality setting (1–100) for WebP preview encoding.</summary>
|
||||
PreviewQuality
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for the <see cref="Settings"/> enum.
|
||||
/// </summary>
|
||||
public static class SettingsExtensions {
|
||||
public static class SettingsExtension {
|
||||
/// <summary>
|
||||
/// Converts a <see cref="Settings"/> value to its human-readable display string.
|
||||
/// </summary>
|
||||
@@ -59,6 +67,10 @@ public static class SettingsExtensions {
|
||||
Settings.JobBatchSize => "Job Batch Size",
|
||||
Settings.ThumbnailQuality => "Thumbnail Quality",
|
||||
Settings.PreviewQuality => "Preview Quality",
|
||||
Settings.ConvertedPath => "Converted Path",
|
||||
Settings.ConvertedFormat => "Converted Format",
|
||||
Settings.FfmpegPath => "FFmpeg Path",
|
||||
Settings.SystemUploaderId => "System Uploader Id",
|
||||
_ => setting.ToString() // Fallback to the enum name
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ namespace Butter.Types;
|
||||
/// </summary>
|
||||
public enum EAccessLevel {
|
||||
/// <summary>Standard user with basic permissions.</summary>
|
||||
User,
|
||||
User = 0,
|
||||
/// <summary>Maintainer — scoped cosplayer management and tag management.</summary>
|
||||
Maintainer = 1,
|
||||
/// <summary>Curator with elevated permissions for managing assets.</summary>
|
||||
Curator,
|
||||
Curator = 2,
|
||||
/// <summary>Administrator with full system access.</summary>
|
||||
Admin
|
||||
Admin = 3
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Butter.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Defines visibility levels for entities in the system.
|
||||
/// </summary>
|
||||
public enum EVisibility {
|
||||
/// <summary>Visible to everyone, including anonymous users.</summary>
|
||||
Public = 0,
|
||||
/// <summary>Visible to authenticated users only.</summary>
|
||||
Protected = 1,
|
||||
/// <summary>Visible only to authorized users (maintainers, curators, admins).</summary>
|
||||
Private = 2
|
||||
}
|
||||
+859
@@ -0,0 +1,859 @@
|
||||
# Changelog
|
||||
|
||||
## [1.0.3.2] - 2026-08-17
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add personOwnerId album filter and opt-in asset IncludeCount
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- Page cosplayer albums via the album endpoint and skip count queries
|
||||
- Cache thumbnails and previews in the browser for 24h
|
||||
- Add filtered IX_Assets_VisibleCreatedAt index for the default listing
|
||||
- Server-side seeded shuffle, DTO projection, and opt-in count
|
||||
- Project album assets to DTO in SQL and slim the Find paths
|
||||
- Push album filter/search/sort/pagination to SQL in person FindVisible
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Add auto-save middleware as a safety net for the Save() pattern
|
||||
- Desloppify review fixes: PathFromGuid extraction, RegexHighlighter dedup, port default fix
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- Quality improvements from the desloppify scan
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Update desloppify scorecard and add it to the README
|
||||
|
||||
## [1.0.3.1] - 2026-07-22
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add sliding-window page buffer to AlbumGrid and CosplayerGrid with scroll-based observer reattach
|
||||
- Embed git version in WASM footer via AssemblyInformationalVersion
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Use status CSS variables for progress bar segment colors
|
||||
- Eliminate masonry layout race on infinite scroll and banner double-fetch (#146)
|
||||
- Masonry items stacking on infinite scroll — missing @ on ViewMode binding (#146)
|
||||
- Prevent infinite scroll cascade by debouncing observer reattach and disabling scroll anchoring
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- Replace IntersectionObserver with input-event-based infinite scroll
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Scripts and run configs to automate version injection
|
||||
- Removed outdated agent.md
|
||||
|
||||
## [1.0.3] - 2026-07-21
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Cropper handle size constant mismatch (14→18 px)
|
||||
- Jobs past tab zero-based pagination
|
||||
- Show registration disabled message on 403
|
||||
- Hide register form when registration is disabled
|
||||
- Allow manual folder scan when scheduled scan is off
|
||||
- Prevent masonry layout shift on infinite scroll
|
||||
- Order album assets by CreatedAt in album detail (#144)
|
||||
- Replace overflow-y with JS masonry for stable album layout (#146)
|
||||
- Add aspect-ratio to album cards for stable masonry height (#146)
|
||||
- Use per-card aspect-ratio from cover dimensions for stable masonry layout (#146)
|
||||
- Restore masonryLayout.dispose that was lost in reset edit
|
||||
- Use pre-pagination album count (#143)
|
||||
- Sort album assets by OriginalFilename instead of CreatedAt (#144)
|
||||
|
||||
## [1.0.2] - 2026-07-17
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Remove redundant AddJsonFile calls that overrode env vars
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Auto-run on tags only, manual dispatch on branches
|
||||
|
||||
## [1.0.1] - 2026-07-17
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Use explicit domain git.r3d.codes instead of gitea.server_url
|
||||
- Sanitize tag names for Docker compatibility (replace / with -)
|
||||
- Lowercase repository name for Docker compatibility
|
||||
- Match matrix service names to actual directory case (Lactose/MilkStream)
|
||||
- Remove gha cache config (cache server unreachable from builder)
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- Add BuildKit cache mounts for NuGet packages in Dockerfiles
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Add workflow_dispatch trigger with version input
|
||||
- Also run on push to master
|
||||
- Also run on develop and hotfix/* branches
|
||||
- Pass SixLaborsLicenseKey as build arg for lactose
|
||||
- Use REGISTRY_TOKEN (PAT) instead of GITEA_TOKEN for registry push
|
||||
|
||||
### CI
|
||||
|
||||
- Added sixlabors license key to the docker file for lactose
|
||||
|
||||
## [1.0] - 2026-07-16
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(API)* Adds Authentication System (Needs Testing)
|
||||
- *(API)* Adds UserRepository.cs
|
||||
- *(API)* Adds Password hashing
|
||||
- *(API)* Adds Password hashing
|
||||
- *(API)* Implements user delete endpoint
|
||||
- *(API)* Adds stubs for AssetController.cs
|
||||
- *(API)* Adds Album endpoint Stubs
|
||||
- *(API)* Adds TagController class
|
||||
- *(API)* Adds Tag endpoint implementation
|
||||
- *(API)* Adds Repository implementation (not tested yet)
|
||||
- *(API)* Implements TagController With Bulk Requests
|
||||
- *(API)* Adds person stub endpoint
|
||||
- *(API)* Added Resolution, mimetype and extra media data on asset table
|
||||
- *(API)* Adds computed file hash field in Asset
|
||||
- *(API)* Adds Folder and Asset entities in the DB
|
||||
- *(API)* Implemented FolderController and their respective Mappers/DTO/Repository interface and it's own implementation
|
||||
- [**breaking**] Upgraded project to .Net 9.0
|
||||
- *(API)* Implemented Settings Endpoint
|
||||
- *(API)* Implemented Media endpoint and relative Repository and DTO
|
||||
- *(DB)* Switched to PostgreSQL connector
|
||||
- *(DB)* [**breaking**] Change to use database pgvecto-rs
|
||||
- *(DB)* [**breaking**] Regenerated migrations for PostgreSQL switch
|
||||
- Switched to on the fly connection string generation
|
||||
- *(DbContext)* Adds Initialization for Development environment
|
||||
- *(API)* Adds Authentication Result as DTO for authentication
|
||||
- *(Lactose)* Adds RefreshToken
|
||||
- *(Lactose)* Adds DB Context Snapshot
|
||||
- *(Lactose)* Adds RefreshToken Endpoint
|
||||
- *(DB)* Changed Asset table to have OriginalPath as Unique Index
|
||||
- Added a settings enum + it's own extension class to retrieve interpretation type and string name in DB
|
||||
- *(db)* Adds Migration for Settings in LactoseDbContext
|
||||
- Added disposability to some repositories, changed the FileSystemScanner service to be a singleton.
|
||||
- *(API)* Added Access level to the data returned from authentication actions
|
||||
- *(frontend)* Upgraded login to new API changes
|
||||
- *(API)* Added get-all endpoint for settings controller.
|
||||
- *(frontend)* Added settings service
|
||||
- *(API)* Added Access level to the data returned from authentication actions
|
||||
- *(frontend)* Added login and JWT token auto-refresh
|
||||
- *(frontend)* Adds ServiceBase and AuthServiceBase abstract classes
|
||||
- *(frontend)* Login and logout now properly work
|
||||
- *(frontend)* Added folders service
|
||||
- *(frontend)* Nav menu restyle
|
||||
- *(API)* Adds method to modify the FileWatcherService: folders, time
|
||||
- *(API)* Edits SettingsRepository to manage FileWatcher Service
|
||||
- *(frontend)* Editing of settings and addition/editing of folder paths
|
||||
- *(frontend)* Added folder removal in settings
|
||||
- *(API)* Added get-all endpoint for settings controller.
|
||||
- *(frontend)* Added settings service
|
||||
- *(API)* Added Access level to the data returned from authentication actions
|
||||
- *(frontend)* Added login and JWT token auto-refresh
|
||||
- *(frontend)* Adds ServiceBase and AuthServiceBase abstract classes
|
||||
- *(frontend)* Login and logout now properly work
|
||||
- *(frontend)* Added folders service
|
||||
- *(frontend)* Nav menu restyle
|
||||
- *(API)* Adds method to modify the FileWatcherService: folders, time
|
||||
- *(API)* Edits SettingsRepository to manage FileWatcher Service
|
||||
- *(frontend)* Editing of settings and addition/editing of folder paths
|
||||
- *(frontend)* Added folder removal in settings
|
||||
- Migrate MilkStream frontend from Blazor SSR to Blazor WASM
|
||||
- *(cors)* Support semicolon-separated CorsAllowedOrigins env var
|
||||
- Add ParentJobId to JobStatusDto for job hierarchy tracking
|
||||
- Add ParentJobId to Job base class
|
||||
- Set ParentJobId on child jobs for ThumbnailJob, FileSystemCrawlJob, PHashJob
|
||||
- Map ParentJobId in JobMapper
|
||||
- Add generic reusable components ProgressBar, CollapsibleSection, PollRateSelector
|
||||
- Add JobRow, JobChildrenSummary, JobSection, JobTree components
|
||||
- Redesign Jobs page with compact hierarchical layout, poll rate selector, and PHash button; remove JobCard
|
||||
- Optimize jobs API with pagination, DB persistence, and cleanup
|
||||
- Add GetAssetsMissingMetadata repository method for metadata job
|
||||
- Implement MetadataJob for image resolution extraction
|
||||
- *(docker)* Add named volumes for thumbnails and previews
|
||||
- Unify settings pages into sidebar + detail panel with auto-save
|
||||
- *(assets)* Add ThumbnailSize column to track generated thumbnail dimensions
|
||||
- *(repository)* Add GetAssetsMissingOrWrongThumbnail to interface
|
||||
- *(repository)* Implement GetAssetsMissingOrWrongThumbnail query
|
||||
- *(thumbs)* Read ThumbnailSize setting, invalidate wrong-sized thumbnails, set size after gen
|
||||
- *(db)* Add migration for ThumbnailSize column on Assets
|
||||
- *(jobs)* Add CompletedWithErrors status for parents with failed sub-jobs
|
||||
- *(ui)* Show CompletedWithErrors as orange and group sub-jobs by status
|
||||
- *(jobs)* Apply batching to PHashJob and MetadataJob
|
||||
- *(settings)* Add JobBatchSize setting for batch job sizing
|
||||
- *(settings)* Add JobBatchSize setting for batch job sizing
|
||||
- *(settings)* Add runtime-configurable Po2W slider limits + split thumbnail/preview ranges
|
||||
- *(folders)* Add regex pattern field with named groups, syntax highlighting, and sample path preview
|
||||
- Masonry gallery on homepage with infinite scroll
|
||||
- Pass JWT token in media URLs for authenticated thumbnail/preview requests
|
||||
- Make navbar sticky during infinite scroll
|
||||
- Add PreviewJob and upgrade thumbnails to WebP
|
||||
- Add CreatePersons and CreateAlbums jobs
|
||||
- Add stacked progress bar for parent jobs with sub-job breakdown
|
||||
- Improve logging and progress reporting for album/person jobs
|
||||
- Add statistics page with aggregate asset/user/system stats via fast DB queries
|
||||
- Add thousands separators to job status numbers
|
||||
- Extend AssetPreviewDto with album and cosplayer names
|
||||
- Add cosplayer and album name overlays on home page
|
||||
- Add full-size view and download buttons in preview overlay
|
||||
- Add bidirectional infinite scroll (up and down)
|
||||
- Implement seed-based deterministic random ordering for home page
|
||||
- Add data completeness indicators to statistics page
|
||||
- Add CoverAssetId to albums with resolution data in full DTO
|
||||
- Add AlbumService and PersonService to WASM client
|
||||
- Add albums list page and album detail page with inline preview
|
||||
- Auto-assign album cover from first alphabetically-sorted asset
|
||||
- Make album names clickable links in home tile hover and preview
|
||||
- Justified gallery layout for album detail page
|
||||
- View mode selector for albums page (masonry/grid/list)
|
||||
- *(admin)* Add user management page for admins
|
||||
- *(admin)* Add inline editing for username, email, and password
|
||||
- *(stats)* Add percentage and stale counters to completeness cards
|
||||
- *(backend)* Add ProfileAssetId to Person model + migration
|
||||
- *(dtos)* Update Person DTOs with ProfileAssetId and album previews
|
||||
- *(backend)* Eager-load ProfileAsset and Albums in PersonRepository
|
||||
- *(backend)* Add PersonMapper with ToPersonPreviewDto and ToPersonDetailedDto
|
||||
- *(backend)* Implement person detail, update, and delete endpoints
|
||||
- *(client)* Extract AlbumCard reusable component, update Albums.razor
|
||||
- *(client)* Add PersonService client methods and PersonForm component
|
||||
- *(client)* Add Cosplayers and CosplayerDetail pages, fix NavMenu, add modularity docs
|
||||
- *(css)* Add Cosplayers and CosplayerDetail scoped CSS with banner, grid, and card styles
|
||||
- *(backend)* Add ProfileCropX/Y to Person model for profile picture cropping
|
||||
- *(ui)* Add circular profile picture cropper with drag-to-pan
|
||||
- *(ui)* Redesigned profile cropper with movable circle, dimmed exterior, square boundary, zoom controls; added ProfileCropZoom to model; switched avatars to background-image for zoom support
|
||||
- *(ui)* Add New Cosplayer button on list page with create endpoint and form
|
||||
- *(ui)* Unassigned album selector in PersonForm, Add Albums button in CosplayerDetail
|
||||
- *(dto)* Add RemovePerson flag to AlbumUpdateDto for explicit person unlinking
|
||||
- Person hard delete with album cascade, AlbumCard select mode, cosplayer list multi-select
|
||||
- Infinite scroll for albums on cosplayer detail page
|
||||
- Migrate to UTC timestamps — remove legacy Npgsql timestamp behavior
|
||||
- Make album name in image previewer an anchor element for native navigation
|
||||
- Allow users to update own albums and curators to update any album
|
||||
- Add OldPassword field to UserUpdateDto for self-service password change
|
||||
- Enforce old-password verification on own password change, allow admin override
|
||||
- Add password change UI with strength bar and validation on user profile page
|
||||
- Add server-side password policy validation (admin bypass)
|
||||
- *(ui)* Redesign user profile page with avatar initials and edit profile modal
|
||||
- *(ui)* Rewrite admin users page with sortable table, search, and modal-based editing
|
||||
- *(ui)* Show avatar initial in navbar instead of generic icon
|
||||
- *(ui)* Album detail multi-select edit mode, cosplayer link, move edit button (#63)
|
||||
- *(api)* Add BulkDeleteAlbumsAsync for bulk album deletion
|
||||
- *(ui)* Add album select/edit mode with bulk deletion
|
||||
- *(ui)* Redesign jobs page with tabs, search, filters, and mobile cards
|
||||
- Add LastChange tracking to JobStatus
|
||||
- Add ModifiedAt to JobRecord with delta query support
|
||||
- Implement delta-based children endpoint with ?since parameter
|
||||
- Add delta merge logic to job tree components
|
||||
- Poll children for all active roots and fix segment progress bar colors
|
||||
- Return-url login redirects + keepalive + cosplayers fix
|
||||
- Add password strength validation to registration page
|
||||
- Hide cosplayers with no visible content from regular users
|
||||
- *(css)* SCSS foundation with Bootstrap 5.3.7 source, new dark navy theme, Inter font
|
||||
- *(ui)* CSS variables for .razor.css, job type colors, interactive states, gradients
|
||||
- *(jobs)* Extract all job-type colors to configurable SCSS variables
|
||||
- *(ui)* CosplayerDetail banner empty gradient uses accent theme
|
||||
- *(ui)* Aggressive gradients on navbar, buttons, modal headers
|
||||
- *(ui)* Page background gradient, status/strength CSS vars, nav/image polish
|
||||
- *(ui)* Replace default favicon with MilkyShot logo SVG
|
||||
- *(cropper)* Circle handles with accent color, black border, drop shadow
|
||||
- *(gradients)* Strengthen all gradients for more aggressive effect
|
||||
- *(ui)* Apply metallic gradient to all buttons and surfaces
|
||||
- *(ui)* Add eye toggle button to show/hide password on all password fields
|
||||
- *(api)* Add sort parameters to pagination DTOs
|
||||
- *(api)* Add sorting and unassigned filter to AlbumRepository.SearchQuery
|
||||
- *(api)* Pass sort and filter params through AlbumController.Search
|
||||
- *(ui)* Add SortFilterBar component and update Albums page with search/sort
|
||||
- *(ui)* Extract AlbumGrid to isolate grid re-renders from SortFilterBar
|
||||
- *(api)* Add SearchQuery to PersonRepository with sort/search/pagination
|
||||
- *(api)* Paginate PersonController.GetAll with sort/search params
|
||||
- *(ui)* Convert Cosplayers page to infinite scroll with sort/search toolbar
|
||||
- *(ui)* Extract CosplayerGrid to isolate grid re-renders from SortFilterBar
|
||||
- Add SearchDropdown typeahead component
|
||||
- Wire SearchDropdown into NavMenu
|
||||
- Make search dropdown headers selectable with keyboard and mouse
|
||||
- Parse ?search= query param on Cosplayers and Albums pages
|
||||
- *(cosplayer-detail)* Add SortFilterBar for album search and sort
|
||||
- *(butter)* Add EVisibility enum, Maintainer access level, and SystemUploaderId setting
|
||||
- *(butter)* Update DTOs for visibility, renamed Owner→UploadedBy, removed IsPublic/Owner fields
|
||||
- *(models)* Update models for auth redesign — rename Owner→UploadedBy, drop UserOwnerId/IsPubliclyShared, add EVisibility, PersonMaintainer
|
||||
- *(infra)* Update DbContext and mappers for auth redesign — new relationships, PersonMaintainer, conditional Visibility
|
||||
- *(repos,controllers)* Update repositories, interfaces, controllers for auth redesign — visibility filtering, UploadedBy/Uploader rename, Maintainer support
|
||||
- *(db)* Add migration for auth redesign — drop IsPubliclyShared/UserOwnerId/SharedWith, add Visibility, PersonMaintainer, rename OwnerId→UploadedBy
|
||||
- *(auth)* Complete Maintainer role — add PersonMaintainer checks, update controller authz, pass uploadedBy search param
|
||||
- Add MaintainedPersonIds to UserInfoDto with backend plumbing
|
||||
- Add Maintainer to admin user dropdowns and role badges
|
||||
- Add Visibility dropdown to album and person create/edit forms
|
||||
- Add Protected Assets card to stats page
|
||||
- Expand UI gating to include Maintainer with scope-aware CanEdit
|
||||
- Maintainer cosplayer assignment in admin user edit modal
|
||||
- Maintainer assignment UI for cosplayers, maintainer display on user page
|
||||
- Expand user page edit modal for admins with access level, maintainer assignment, and danger zone
|
||||
- Make usernames clickable on admin users page, linking to /User/{id}
|
||||
- Add docker-compose.debug.yml and UseWebAssemblyDebugging for WASM debugging
|
||||
- Add role-visible colored borders on albums and assets based on item state (#78)
|
||||
- Add bulk delete and visibility change to AlbumDetail select mode
|
||||
- Show visibility/deleted breakdown in album header in select mode
|
||||
- Add Select All / Deselect All button in album select mode
|
||||
- Add album-level bulk actions to CosplayerDetail page
|
||||
- Cascade visibility to albums and assets with lower-only rule
|
||||
- Add multiselect editing controls to Cosplayers page
|
||||
- Wire up SystemUploaderId setting — seed, UI, and crawl job backfill
|
||||
- Bulk cosplayer assignment for albums from multiselect mode
|
||||
- Add reusable ConfirmDialog component
|
||||
- Add visibility-colored borders to cosplayer cards in selection mode
|
||||
- Add rate limiting with configurable buckets
|
||||
- Apply rate-limit buckets to controllers
|
||||
- Add AlbumMergeDto and PersonMergeDto
|
||||
- Add MergeAlbums to album repository
|
||||
- Add MergePeople to person repository
|
||||
- Add merge endpoints to AlbumController and PersonController
|
||||
- Add frontend service methods for merge, bulk update, and bulk delete people
|
||||
- Add MergeModal shared component for merge destination selection
|
||||
- Add Merge button to Albums.razor with AlbumGrid preview access
|
||||
- Add Merge button to Cosplayers.razor with CosplayerGrid preview access
|
||||
- Add Merge Albums button to CosplayerDetail.razor
|
||||
- Add Unlink cosplayer button to AlbumDetail.razor
|
||||
- Add UnlinkedAssetGroupDto and extend AssetSearchOptionsDto with unlinked/folder/uploader filters
|
||||
- Add trigram GIN index on Asset.OriginalFilename for efficient ILike search
|
||||
- Extend AssetRepository with unassigned, folderId, uploadedBy, search filters and GetUnlinkedGroups
|
||||
- Extend AssetController with unlinked-groups endpoint and pass new search/unassigned/folder/uploader params
|
||||
- Extend AssetService with unassigned/folder/uploader/search params and GetUnlinkedGroupsAsync
|
||||
- Add AlbumAssetPicker modal component with drillable folder sidebar, search, and multi-select
|
||||
- Integrate AlbumAssetPicker into AlbumDetail with Add Assets button and merge handler
|
||||
- Add file-browser BrowseUnlinkedAssets endpoint with directory tree from OriginalPath
|
||||
- Redesign AlbumAssetPicker with breadcrumb navigation, directory drill-down, and file browser UX
|
||||
- Add collapsible sidebar toggle to AlbumAssetPicker folder browser
|
||||
- Add list/grid view toggle, bulk delete in maintenance mode, and /maintenance page
|
||||
- Add visibility and create-album actions, fix embedded viewport sizing
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(API)* Split authentication from user endpoint
|
||||
- *(API)* Fix retrieve of user authentication data
|
||||
- *(API)* Split authentication from user endpoint
|
||||
- *(API)* Fixes null exception against anonymous users requests
|
||||
- *(API)* Fix exception for UserRepository.cs
|
||||
- *(API)* Fix Password hasher dependency resolver
|
||||
- *(API)* Adds last login update on login
|
||||
- *(API)* Fixes null exception against anonymous users requests
|
||||
- *(API)* Fix exception for UserRepository.cs
|
||||
- *(API)* Fix Password hasher dependency resolver
|
||||
- *(API)* Adds last login update on login
|
||||
- *(API)* Remove login permission to Banned or Disabled Users
|
||||
- *(API)* User endpoints to be in line with new structure
|
||||
- *(API)* Switches tagSearch method from uint to int
|
||||
- *(docker)* Fixed Dockerfile stuck on old project name and build configs
|
||||
- *(API)* Added support for pagination on Asset GetAll Query
|
||||
- *(API)* Added deleted at field in UserInfo mappers
|
||||
- *(API)* Implements GetAll tags in tag Repository
|
||||
- *(API)* Wrong logger in album controller
|
||||
- *(API)* Standardized DTO namespaces
|
||||
- *(API)* Added required instead of null initialized strings where it didn't make sense to have a null string in Users DTOs
|
||||
- *(API)* Added missing Email field in UserInfoDto
|
||||
- *(API)* Marked as required both fields of BulkDto
|
||||
- *(API)* Marked as required both fields of credentials DTOs
|
||||
- *(API)* Marked as required the tag name in TagCreateDto
|
||||
- *(API)* Marked as required all the various required fields in person related DTOs
|
||||
- *(API)* Added null checking to folderRepository get operations in FolderController
|
||||
- *(API)* Added GetAll method to Folder Repository and controller
|
||||
- *(API)* [**breaking**] Marked required properties in Asset as required
|
||||
- *(API)* Added FindByPath in AssetRepository
|
||||
- *(API)* Allow Anonymous to use MediaEndpoint
|
||||
- *(docker)* Dockerfiles updated to use Net 8 as base images
|
||||
- *(docker)* Solved mismatch with base folders in both dockerfile and compose file
|
||||
- *(docker)* Applied "Docker" as environment when building the application in a container thus avoiding not getting the correct configuration overloads at run time.
|
||||
- *(Docker)* Removes Env var from where is not needed and add it to teh compose
|
||||
- *(Docker)* Add internal db port in appsettings.Development.json
|
||||
- *(docker)* [**breaking**] Postgres volume data was not named properly
|
||||
- *(db)* Added postgres switch for Legacy Timestamp behaviour
|
||||
- *(API)* Correct reference to the return type of result
|
||||
- *(API)* Fixes the Db Initializer on Production Environment
|
||||
- *(DB)* Added check for already existing assets on DB entry.
|
||||
- *(Services)* Added FileSystem scan service to the startup of the webserver.
|
||||
- *(frontend)* Adds the new services to the app startup configuration
|
||||
- *(frontend)* Added correct headers and response format to LoginService
|
||||
- *(frontend)* Adjusted SettingsService to work with List<SettingDto> instead of SettingsDto object.
|
||||
- *(API)* Added SaveChanges to Settings repository, reflecting changes to he database
|
||||
- *(API)* Solved filesystem scanner not scanning
|
||||
- *(frontend)* Adds the new services to the app startup configuration
|
||||
- *(frontend)* Added correct headers and response format to LoginService
|
||||
- *(frontend)* Adjusted SettingsService to work with List<SettingDto> instead of SettingsDto object.
|
||||
- *(API)* Added SaveChanges to Settings repository, reflecting changes to he database
|
||||
- *(API)* Solved filesystem scanner not scanning
|
||||
- Address code review findings (blocking .Result, async void handlers, email logging)
|
||||
- Handle stale ProtectedLocalStorage value crashing NavMenu deserialization
|
||||
- Serve appsettings.json dynamically from MilkStream to fix WASM BaseUrl in Docker
|
||||
- Support wildcard CORS origin for self-hosted Docker deployments
|
||||
- Replace CORS wildcard with explicit safe origins per environment
|
||||
- Add DB healthcheck and service_healthy condition to prevent lactose startup race condition
|
||||
- Catch OperationCanceledException from Task.Delay to prevent incorrect Completed status on cancellation
|
||||
- Notify auth info changed after token refresh and handle polling errors
|
||||
- Change ParentJobId to get/set for WASM compat, remove @key from JobTree, add debug panel
|
||||
- Handle 401 responses in JwtTokenRefresher by refreshing token and retrying
|
||||
- Redirect to login when token refresh fails in JwtTokenRefresher
|
||||
- Handle 401 in JobsService directly with refresh+retry
|
||||
- Add missing Butter.Types import in AssetRepository for EAssetType
|
||||
- Remove completed sub-jobs from list so master job can finish
|
||||
- Prevent master PHashJob from spinning indefinitely on sub-job completion
|
||||
- Use oninput for live slider value update and widen range layout
|
||||
- Add minutes unit to Folder Scan Interval description in default settings
|
||||
- Handle 401 transparently at AuthServiceBase level for all services
|
||||
- Redirect unauthenticated users from settings page to /login
|
||||
- Redirect unauthenticated users from jobs page to /login
|
||||
- *(ui)* Render child jobs in JobTree by skipping root filter at deeper levels
|
||||
- *(db)* Always override ThumbnailPath/PreviewPath values with local paths on init
|
||||
- *(docker)* Create thumbnails/previews dirs as app user so subdirs can be created at runtime
|
||||
- *(ui)* Restore auth in App.razor before rendering to prevent race redirect to /login
|
||||
- *(phash)* Display child jobs grouped by status
|
||||
- *(jobs)* Prevent silent crash on large thumbnail jobs and auto-refresh child jobs
|
||||
- *(ui)* Apply poll interval to child jobs, add @key for DOM reuse, guard admin pages
|
||||
- *(ui)* Submit login form on Enter key press
|
||||
- *(jobs)* Fix IntegrityCheckJob conditional + use soft-delete
|
||||
- *(jobs)* Add delay to JobManager main loop to prevent CPU spin
|
||||
- Add DeletedAt filter to asset batch queries to exclude soft-deleted assets
|
||||
- Add AssetUser many-to-many join table for SharedWith
|
||||
- Remove AspNetCore.SassCompiler to fix Docker startup crash
|
||||
- Proper bidirectional SharedWith many-to-many migration
|
||||
- *(Lactose)* Admin and Curators media visibility
|
||||
- Wire MaxConcurrentJobs to database setting on startup and runtime changes
|
||||
- Propagate cancellation to sub-jobs in batch processing jobs
|
||||
- Cancel past refresh loop when poll rate is set to Off
|
||||
- Make job cancellation work for all job states and types
|
||||
- Per-batch progress messages and counting bug in album/person jobs
|
||||
- Skip existing AlbumAsset relationships in CreateAlbumsJob to prevent duplicate key violations on re-run
|
||||
- Add JobBatchSize to DefaultSettings.json to prevent reset on restart
|
||||
- Populate LastLogin/BannedAt/UpdatedAt in UserInfoDto mapping
|
||||
- Rewrite TopTags query to avoid EF Core collection navigation translation error
|
||||
- Remove destructive People soft-delete in IntegrityCheckJob and drop duplicate hash stats
|
||||
- Move preview info text below image instead of overlaying on bottom border
|
||||
- Download button now fetches blob and triggers actual file download
|
||||
- Remove scroll position correction from LoadPreviousPage for smoother scroll-up
|
||||
- Propagate cancellation tokens through linked child job CTS
|
||||
- Album controller Save calls, person list endpoint, media empty path guard
|
||||
- Scoped CSS deep selectors for preview info text and filename binding
|
||||
- Switch album detail page to row-based flexbox masonry
|
||||
- Remove preventDefault from album links so they actually navigate
|
||||
- Remove content-visibility and contain-intrinsic-size from album detail tiles
|
||||
- Switch album detail grid from flexbox to CSS Grid
|
||||
- Use flexbox with align-items:flex-start for album detail grid
|
||||
- Unlock body scroll on navigation, reorder masonry for left-to-right flow
|
||||
- JS-based masonry for left-to-right item flow with varying heights
|
||||
- Faulty migration that could cause lactose crash on migration apply
|
||||
- Use CSS columns for albums page, keep JS masonry only for album detail
|
||||
- Override masonry inline styles with !important for grid mode
|
||||
- Force square aspect-ratio in grid mode over inline style
|
||||
- *(controllers)* Tighten asset visibility and media access checks
|
||||
- *(AssetController)* Correct search visibility filter for #11
|
||||
- *(AlbumController)* Exclude deleted assets from search visibility for #11
|
||||
- *(admin)* Add confirm/cancel for role change and prevent admin self-demotion
|
||||
- *(user)* Hash password in Create endpoint instead of storing raw
|
||||
- *(ui)* Remove broken onfocusout handler from role edit dropdown
|
||||
- *(admin)* Show inline errors instead of replacing entire page
|
||||
- *(auth)* Show ban/disabled message on login and force-logout on refresh
|
||||
- *(auth)* Detect banned/deleted user on page load via InitializeAsync
|
||||
- *(frontend)* Sorts setting by alphabetical
|
||||
- *(Lactose)* Change to use UtcNow, even if alredy working without timeline
|
||||
- *(backend)* Add [FromQuery] to person list endpoint to fix 415 error
|
||||
- *(css)* Move card styles to AlbumCard.razor.css, use ::deep for grid-to-card selectors
|
||||
- *(ui)* Larger cosplayer cards with profile pics, move edit buttons top-right, add masonry/grid toggle
|
||||
- *(auth)* Force logout on any refresh failure, redirect to login
|
||||
- *(ui)* Add scoped modal CSS to PersonForm for proper overlay rendering
|
||||
- *(ui)* Cosplayer cards now match album card style — image fills square, text overlay at bottom
|
||||
- *(js)* Set onload before src to handle cached images
|
||||
- *(ui)* Remove empty filler cells from banner mosaic, let overlay cover gaps naturally
|
||||
- *(ui)* Always render ProfileCropper in tree, use OnParametersSet to trigger JS init
|
||||
- *(ui)* Random 3 album covers max in banner, redesigned cropper with drag-to-move and drag-handle resize, image stays static
|
||||
- *(ui)* 4 corner handles on square boundary for resizing instead of single circular handle
|
||||
- *(js)* Handle positions relative to square, not viewport
|
||||
- *(js)* Corner-drag resize with fixed opposite corner, proper visual rate matching
|
||||
- *(css)* Show full image in cropper viewport (contain instead of cover)
|
||||
- *(ui)* Correct geometry — square outer, circle inscribed inside; image-relative crop coords with background-image viewport; correct display formula for background-position
|
||||
- *(js)* Adjust stored cropZoom by image aspect ratio factor so background-size formula matches circle
|
||||
- *(js)* Store cropZoom as image-relative fraction (square_side / image_displayed_width), no adjustment needed on save
|
||||
- *(ui)* Use pixel-exact transform positioning for avatar display instead of background-size formula
|
||||
- *(js)* Use correct opposite corners for TL/TR/BL resize handles
|
||||
- *(js)* Skip resize on first mousemove if mouse hasn't moved from mousedown position
|
||||
- *(js)* Resize calculations now in viewport pixels to avoid image-relative X/Y scale mismatch on non-square images
|
||||
- *(api)* Allow unlinking person from album by directly assigning null PersonOwnerId
|
||||
- *(build)* Remove stray semicolon in onclick lambda
|
||||
- Prevent album title from being overwritten when only setting Person in update DTO
|
||||
- *(pr)* Implement BulkUpdate, BulkDelete, revert docker-compose, explain [FromQuery]
|
||||
- *(tests)* Fix pre-existing auth route tests and handle empty unassigned albums gracefully
|
||||
- *(test)* Use response.body directly instead of JSON.parse (already parsed object)
|
||||
- *(test)* Update name assertion to match renamed person
|
||||
- Create AlbumSearchParametersDto, add album pagination to person detail, fix duplicate XML param
|
||||
- Cache random banner covers in field, not computed on every render
|
||||
- *(ui)* Responsive layout fixes for mobile portrait mode (closes #48)
|
||||
- *(ui)* Collapse AdminUsers table columns and actions on mobile (#48)
|
||||
- *(frontend)* Removed faulty d-flex tag on main object
|
||||
- *(milkystream)* Fix sorted masonry for albums
|
||||
- *(ui)* Prevent JobRow horizontal overflow on mobile portrait
|
||||
- *(ui)* Change AdminUsers mobile actions dropdown from dropup to dropdown
|
||||
- *(ui)* Reduce password input width on AdminUsers to prevent cell overflow
|
||||
- *(ui)* Stack setting card description and control vertically on mobile
|
||||
- *(ui)* Prevent layout shift from save indicator width change
|
||||
- *(ui)* Prevent save indicator from shifting adjacent controls
|
||||
- *(ui)* Stack setting card body vertically — description above, control + indicator below
|
||||
- *(ui)* Right-align number inputs, let them grow to fill card width
|
||||
- *(ui)* Replace inline save indicator with Bootstrap toast notification
|
||||
- *(ui)* Restore progress bar visibility on job rows
|
||||
- Resolve UI issues #61, #62, #66
|
||||
- *(#62)* Move mobile modal fix to global MilkyShot.css — scoped ::deep cannot pierce PersonForm root element
|
||||
- *(#62)* Use explicit top/left/right/bottom instead of inset, add !important to ensure position:fixed takes effect on mobile
|
||||
- *(#62)* Lock body scroll when modal forms open to prevent position:fixed breakage on mobile
|
||||
- Propagate server-side error messages from UpdateUserAsync to UI
|
||||
- Auto-close modal on password change success, add page-level success message
|
||||
- Center profile card on page
|
||||
- Wire profile icon in navbar to user profile page
|
||||
- Default sort users table by role descending (admin on top)
|
||||
- Use bold (700) instead of semi-bold for reliable font rendering
|
||||
- *(mobile)* Keep back button inline with title, collapse admin actions into dropdown
|
||||
- Batch-processing subjobs report correct status on asset failures
|
||||
- Remove double-count of failed assets in master Done handler
|
||||
- Metadata icon in dropdown uses text-light for visibility
|
||||
- Metadata icon visible in job list rows
|
||||
- Pass null since on first child fetch instead of DateTime.MinValue
|
||||
- Eagerly fetch children on load and hide sub-jobs placeholder
|
||||
- Re-render after eager child fetch so icons appear immediately
|
||||
- Eager-fetch children for all roots including past jobs
|
||||
- Update home logged-out message to match cosplayers wording
|
||||
- Update albums logged-out message to match home/cosplayers
|
||||
- Translate GetAllVisible to client-side eval for SharedWith navigation
|
||||
- Suppress nullable warnings on Include chain in GetAllVisible
|
||||
- Remove soft-delete from Person model
|
||||
- *(jobs)* Add hover/active variants for all job types via darken()
|
||||
- *(ui)* Modal icon order, fill all outline buttons
|
||||
- *(favicon)* Render MilkyShot logo as PNG favicon
|
||||
- *(ui)* Page bg radial gradient, navbar radial glow, favicon cache
|
||||
- *(cropper)* Revert ProfileCropper to inline modal
|
||||
- *(cropper)* Restore missing cropper CSS styles
|
||||
- *(cropper)* Add pointer-events:auto on handles, fix cursors
|
||||
- *(ui)* Fix float values in the cosplayer thumbnail and cosplayer card
|
||||
- *(ui)* Fix remaining float culture issues in CSS style attributes
|
||||
- *(ui)* Prevent infinite loading loop on cosplayers page with no data
|
||||
- *(ui)* Keep header and toolbar visible when albums search returns empty
|
||||
- *(ui)* Debounce search input and prevent backspace navigation
|
||||
- *(ui)* Refocus search input after debounce triggers reload
|
||||
- *(ui)* Move album grid CSS to AlbumGrid.razor.css for scoped isolation
|
||||
- *(ui)* Add height:100% to album-card-fallback so icon centers vertically in grid mode
|
||||
- *(ui)* Add margin-bottom to SortFilterBar for spacing from grid
|
||||
- *(ui)* Add ActionButtons slot to SortFilterBar, move album actions into bar
|
||||
- *(ui)* Push ActionButtons to right with ms-auto wrapper
|
||||
- *(ui)* Keep header and toolbar visible when cosplayers search returns empty
|
||||
- *(ui)* Move cosplayer grid CSS to CosplayerGrid.razor.css for scoped isolation
|
||||
- *(ui)* Move cosplayer actions into SortFilterBar, fix sortAsc always sending
|
||||
- Close outer div and remove invalid @{} blocks in SearchDropdown
|
||||
- Pressing enter with nothing selected does nothing
|
||||
- Move query param parsing to OnParametersSet
|
||||
- Parse search query before await in Albums OnInitializedAsync
|
||||
- Use case-insensitive ILike search for persons and albums
|
||||
- Override Bootstrap dropdown-menu display:none
|
||||
- Return 401 Unauthorized when user data is null in PersonController
|
||||
- Allow anonymous search for persons and albums
|
||||
- Materialize person search query before client-side visibility filter
|
||||
- Restrict user list to admin only
|
||||
- Add [FromQuery] to TagController.GetAll parameter
|
||||
- Use zero-based page numbers in AssetController to match API convention
|
||||
- Guard ILIKE filter in AlbumRepository.SearchQuery against null/empty query
|
||||
- Restore Include(p => p.Albums) in PersonRepository queries
|
||||
- Paginate PersonRepository.SearchQuery by ID before loading albums
|
||||
- Prevent duplicate @key errors across all list renderings
|
||||
- Filter album count by user visibility in person search
|
||||
- Restore ToAlbumPreviewDto mapper method per review feedback #91
|
||||
- Standardize pagination to zero-based across all layers
|
||||
- Move SharedWith visibility check to client side for translatable queries
|
||||
- Only reject Page < 0, keep original PageSize validation
|
||||
- Restore PageSize < 1 validation with upper limit of 250
|
||||
- *(cosplayer-detail)* Prevent banner image shuffle on album search/sort
|
||||
- *(cosplayer-detail)* Debounce search, keep page intact on filter changes
|
||||
- *(cosplayer-detail)* Remove search debounce, instant inline reload
|
||||
- Replace untranslatable Album navigation in stats queries with direct join table queries
|
||||
- Replace untranslatable Tags navigation in TopTags stats query with direct join table query
|
||||
- *(albums)* Use MaxPageSize constant for unassigned album fetch
|
||||
- Implement CosplayersMissingProfile stat
|
||||
- Make card components middle-clickable
|
||||
- Remove underline from album card person link
|
||||
- *(album-form)* Searchable person combobox with modal-safe positioning
|
||||
- *(jobs)* Use grey for queued segments in layered progress bar
|
||||
- *(repo)* Scope Maintainer to only maintained persons in GetAllVisible and SearchQuery
|
||||
- *(controller)* Scope Maintainer album visibility to only maintained persons
|
||||
- *(controller)* Scope Maintainer asset access to only maintained cosplayers in Get
|
||||
- *(controller)* Add Maintainer scope to Album BulkUpdate, Delete, BulkDelete
|
||||
- *(mapper)* Allow Maintainer to see Visibility field in DTO responses
|
||||
- *(media)* Replace bool parameter with EAccessLevel, add deleted asset check
|
||||
- *(stats)* Add ProtectedAssets count, fix PrivateAssets to count only Private
|
||||
- *(dto)* Default AssetCreateDto.Visibility to Private instead of Protected
|
||||
- *(repo)* Use PagedParametersDto.MaxPageSize const instead of hardcoded 150
|
||||
- *(migration)* Remap existing EAccessLevel values to prevent data corruption
|
||||
- *(migration)* Default Visibility to Protected instead of Public, preserve old IsPubliclyShared mapping
|
||||
- *(dto)* Make AssetCreateDto.UploadedBy nullable for scanner-created assets
|
||||
- *(model)* Add [PrimaryKey] annotation to PersonMaintainer for clarity
|
||||
- *(repo)* Scope AlbumRepository.SearchQuery maintainer visibility to maintained persons
|
||||
- *(controller)* Scope AssetController maintainer permissions via album→person chain
|
||||
- *(mapper)* Replace null-forgiving operator with null-conditional in AlbumMapper
|
||||
- *(controller)* Split Maintainer from User in AssetController.GetAll visibility filter
|
||||
- *(media)* Allow Maintainer to access media files for cosplayers they maintain
|
||||
- *(mapper)* Scope asset counts in album/ person previews to visible assets
|
||||
- Add missing app.css link to index.html
|
||||
- Null reference in User page maintainer display
|
||||
- Remove underline from user links, hide email from non-admin viewers
|
||||
- Remove underline from maintainer links, load assigned person names in user edit modal
|
||||
- NRE on user page when user data not yet loaded
|
||||
- Show email to self-viewing users on user page
|
||||
- Enforce data visibility per access level with test coverage
|
||||
- Replace jsonPath with response.body for null-value checks in visibility tests
|
||||
- Replace remaining jsonPath calls in asset visibility tests
|
||||
- Allow maintainer assignment for all user roles in UI
|
||||
- Address PR #127 review issues — fallback, eager load, indentation, Maintainer tests
|
||||
- Show maintained cosplayers section for all user types in admin edit modal
|
||||
- Show maintained cosplayers section for all user types in user profile edit modal
|
||||
- Add entity-level visibility gating for albums and persons
|
||||
- Update border colors and browse-mode visibility in AlbumDetail
|
||||
- Show borders for maintainers in select mode
|
||||
- Prevent jagged wrapping of toolbar and action buttons
|
||||
- Album-card-select and cosplayer-card-select only on selected cards
|
||||
- Wrap CosplayerDetail action buttons into mobile dropdown
|
||||
- Add cascade checkbox to CosplayerDetail album visibility modal
|
||||
- Cosplayers page bugs
|
||||
- Prevent private assets with null UploadedBy leaking to anonymous users
|
||||
- Homepage preview navigation, grid observer crash, and cascade labels
|
||||
- Label Delete button and cropper modal overlay
|
||||
- Album detail back button uses history.back() with fallback to /albums
|
||||
- Soft-delete users in UserController.Delete instead of hard-delete
|
||||
- Add ConfirmDialog to AdminUsers delete user flow
|
||||
- Add ConfirmDialog to User delete user flow
|
||||
- Add ConfirmDialog to AlbumDetail delete flows
|
||||
- Add ConfirmDialog to CosplayerDetail delete flows
|
||||
- Add ConfirmDialog to Albums bulk delete flow
|
||||
- Add ConfirmDialog to Cosplayers bulk delete flow
|
||||
- Add ConfirmDialog to Settings folder deletion flow
|
||||
- *(UI)* Make the delete cosplayer button in cosplayer detail page be just an icon to conform with albums detail page
|
||||
- Apply Visibility and MaintainerUserIds in PersonController.BulkUpdate
|
||||
- Split multi-request test blocks into single-request blocks
|
||||
- Guard against null PersonOwnerId in MergePeople albums query
|
||||
- Gate registration behind UserRegistrationEnabled setting
|
||||
- Handle 204/empty responses in AssetService and always reset isLoading in AlbumAssetPicker
|
||||
- Use Albums.Count==0 instead of !Any() for unlinked filter, add trace logging
|
||||
- Use OnParametersSetAsync instead of fire-and-forget to ensure Blazor re-renders after init
|
||||
- Use parameterized SqlQueryRaw instead of unquoted string.Format to prevent SQL error
|
||||
- Filter directory names to only paths containing a slash after prefix, excluding filenames
|
||||
- Restrict maintenance page and nav link to admin only
|
||||
- Add scoped CSS for maintenance page to make embedded picker fill viewport
|
||||
- Inline all markup in .razor template to restore CSS isolation
|
||||
- Change LoginService from Singleton to Scoped to match Blazored.LocalStorage lifetime
|
||||
- Simplify maintenance page CSS to flex-fill container without vh calc
|
||||
- Adjust embedded picker viewport height offset
|
||||
- Address PR #142 review issues
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- *(API)* Switch to repository pattern
|
||||
- *(API)* Switch to use UserRepository AuthController.cs
|
||||
- *(API)* Switch to repository pattern
|
||||
- *(API)* Switch to use UserRepository AuthController.cs
|
||||
- *(API)* Cleans the code
|
||||
- *(API)* Cleans the code
|
||||
- *(API)* Extract BulkDtoinseparate file
|
||||
- *(API)* Switch to use a class for paged search
|
||||
- *(API)* Adds Bulk capabilities
|
||||
- *(API)* Move classes and Standardlize
|
||||
- *(API)* Rename Asset Dtos
|
||||
- *(API)* AlbumController explicitly marked all of the x in lambdas as they were accidentally hiding previous stage, also improved null checking
|
||||
- Reformatted AssetController and AssetCreateDto files
|
||||
- *(API)* Asset controller reformatted params
|
||||
- Improve readability of CorsAllowedOrigins parsing block
|
||||
- *(jobs)* Flatten ThumbnailJob to batch-based sub-jobs
|
||||
- *(jobs)* Flatten PHashJob to batch-based sub-jobs
|
||||
- *(jobs)* Flatten MetadataJob to batch-based sub-jobs
|
||||
- Make LoginService singleton, centralize auth state and persistence
|
||||
- Extract ImagePreview shared component from Home and AlbumDetail
|
||||
- Remove list view, add grid/masonry toggle to album detail
|
||||
- *(css)* Move shared modal styles to global MilkyShot.css/SCSS, remove duplicated scoped CSS
|
||||
- *(css)* Remove SCSS, switch to plain CSS with Bootstrap custom properties
|
||||
- *(api)* Fold unassigned album filter into search endpoint via query param
|
||||
- *(dto)* Move Unassigned filter into PagedSearchParametersDto
|
||||
- Replace individual albumPage/albumSize params with PagedParametersDto in person detail endpoint
|
||||
- Replace custom masonry JS with Bootstrap grid + Masonry.js approach
|
||||
- *(auth)* Extract AuthServiceBase, replace retry with re-auth on 401
|
||||
- *(auth)* Remove SendWithRefreshAsync, rely solely on JwtTokenRefresher
|
||||
- *(ui)* Replace inline password form with modal
|
||||
- *(ui)* Complete user profile page redesign with card layout
|
||||
- Move cosplayer link to same line as album name
|
||||
- Center-align header, album name on top, cosplayer smaller below
|
||||
- Extract password strength UI into shared PasswordField component
|
||||
- Replace tuple + custom header with ChildrenResponse DTO
|
||||
- *(ui)* ModalFrame + EmptyState components, remove duplicate CSS, remove main border
|
||||
- *(jobs)* Use darken() for phash and create-albums hover/active too
|
||||
- Project PersonPreviewDto directly in repository instead of [NotMapped] entity property
|
||||
- Project AlbumPreviewDto directly in repository with visibility-aware asset count
|
||||
- Clean up imports, simplify checks, and add SearchDropdown component
|
||||
- *(albums)* Extract visibility-aware AssetCount into AlbumMapper overload
|
||||
- Replace hardcoded 250 page size limit with PagedParametersDto.MaxPageSize
|
||||
- Use MaxPageSize constant as default pageSize across all frontend services
|
||||
- Reuse AlbumGrid in CosplayerDetail via FetchAlbums delegate
|
||||
- Move all visibility filtering from mappers and controllers to repositories
|
||||
- Rename AlbumSearchParametersDto.UploadedBy to AssetUploadedBy
|
||||
- Extract role badge colors to SCSS theme layer
|
||||
- Generate access level dropdown from enum values
|
||||
- Extract role checks to LoginService, update all consumers
|
||||
- Move CanEdit logic to LoginService, remove local page properties
|
||||
- Extract person search/assign UI into shared SearchAssignBadges component
|
||||
- Extract VisibilityModal shared component, add editing controls to Albums page
|
||||
- Centralize cascade visibility logic in AlbumService.CascadeVisibilityToAssetsAsync
|
||||
- Replace inline visibility modal with shared VisibilityModal in Cosplayers.razor
|
||||
- Move Unlink cosplayer button from album header to album edit form
|
||||
- Add Embedded mode, UnlinkedOnly param, rename methods from Unlinked to generic names
|
||||
- Rename UnlinkedAssetGroupDto to AssetGroupDto
|
||||
- Replace individual [FromQuery] params with AssetBrowseOptionsDto
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Update agents.md for Blazor WASM architecture
|
||||
- Add CORS configuration section to README
|
||||
- *(readme)* Add wildcard CORS warning and MilkStream CORS setup guide
|
||||
- Add XML documentation to all public APIs across all projects
|
||||
- Update AGENTS.md with XML doc enforcement and CORS guidance
|
||||
- Update AGENTS.md with verified infrastructure gotchas
|
||||
- Add API reuse convention to AGENTS.md — prefer optional query params over new endpoints
|
||||
- Add Gitea repo owner note to AGENTS.md
|
||||
- Add granular commit convention to AGENTS.md
|
||||
- Document REST Client tests in AGENTS.md
|
||||
- Update AGENTS.md with missing conventions and project info
|
||||
- Add use case catalog for new auth/permissions model
|
||||
- Add Gitea label levels note to AGENTS.md
|
||||
- Add PR workflow section to AGENTS.md
|
||||
- Reconcile AGENTS.md with current codebase
|
||||
- *(repo)* Fix stale pageSize default in IAlbumRepository XML doc
|
||||
- Add data size estimates to AGENTS.md
|
||||
- Clarify Curators and Admins can be maintainers for credited attribution
|
||||
- Fix stale column references and tooling versions in AGENTS.md
|
||||
- Add visibility rules (R1–R7) to AGENTS.md
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- Push visibility filter into SQL for PersonRepository queries
|
||||
- Add partial index on Assets for visibility-aware search queries
|
||||
- Simplify ORDER BY to total count and add trigram GIN indexes
|
||||
- Add AsSplitQuery to repository queries with multiple Include calls
|
||||
- Replace in-memory path parsing with SQL SPLIT_PART aggregation and double-LIKE filter
|
||||
- Replace in-memory path parsing with SELECT DISTINCT SQL, remove dir count badges
|
||||
|
||||
### 🎨 Styling
|
||||
|
||||
- Style cosplayer link with inherited color and person icon
|
||||
- Match cosplayer link font size to h3, remove icon, keep regular weight
|
||||
- Consistent inline typography for album title and cosplayer name with dash separator
|
||||
- Borderless edit pencil with hover reveal on header
|
||||
- Cosplayer link dims on hover instead of blue/underline
|
||||
- Replace badge backgrounds with colored icons + matching launch buttons
|
||||
- Custom job color palette based on purpose, avoid red
|
||||
- Mobile launch button cyan for better contrast
|
||||
- Soften accent gradient stops (5%/10% instead of 15%/30%)
|
||||
- Use rounded-square thumbnails for album results
|
||||
- Add chevron-right indicator to search dropdown headers
|
||||
- Use Bootstrap dropdown-menu and dropdown-item classes
|
||||
- *(repo)* Convert visibility filters to switch expressions for readability
|
||||
- *(repo)* Expand switch expression arms for readability
|
||||
- *(repo)* Use explicit named enum cases in PersonRepository switch expressions
|
||||
- Standardize access level comparison to switch expressions
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
- More unit testing on HTTP client
|
||||
- *(api)* Add person endpoint tests to WepApiTest.http
|
||||
- *(api)* Add comprehensive http tests for all person and album endpoints
|
||||
- Add album update test without removePerson field
|
||||
- Use real album from search for update test, not zero-GUID fallback
|
||||
- All tests create their own data — create album returns ID, all mutations use self-created resources
|
||||
- Add endpoint tests for refresh token, albums, curator auth levels
|
||||
- Add anonymous search tests for persons and albums
|
||||
- Handle empty person search results gracefully
|
||||
- Set personId from admin search to ensure it's always populated
|
||||
- Add user id to profile update request body
|
||||
- Fix curator user access level from Admin (2) to Curator (1)
|
||||
- Restructure WepApiTest.http with three clearly separated users
|
||||
- Add cleanup of regular test user in REST Client suite
|
||||
- Add page=0&pageSize=5 to paginated list endpoints
|
||||
- Add merge and bulk visibility tests to WepApiTest.http
|
||||
- Flatten test numbering to sequential 1–137
|
||||
- Added rate limiting stress test
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Removes auto generated docker-compose file for deployment
|
||||
- *(docker)* Removes auto generated docker-compose file for deployment
|
||||
- *(API)* Changes the category name of the logger
|
||||
- Added datagrip files
|
||||
- *(config)* Separates connection string defaults for docker and local webserver run
|
||||
- Removed unused using in Program.cs
|
||||
- Swapped empty array initializer with empty collection initializer in Asset hash
|
||||
- Commented out unused logger in Album controller and added a TODO: about adding logging capabilities
|
||||
- Marked navigation property in folder as nullable
|
||||
- Commented out logger in FolderController and added a TODO regarding logging
|
||||
- Removed redundant type cast in tag search
|
||||
- Removed useless using declarations in TagMapper
|
||||
- Commented out the logger in tagController and added a TODO regarding logging
|
||||
- Removed more unused using directives
|
||||
- *(docker)* Added NoCache build option for lactose
|
||||
- *(docker)* Updated dockerfile to build using dotnet 9.0
|
||||
- *(NuGet)* [**breaking**] Added dependency to Magick.NET-Q16-HDRI-OpenMP-x64
|
||||
- Updated nuget packages to latest releases supporting Net 8.0, removed ImageMagick package.
|
||||
- Removed MySql package from required nuget packages
|
||||
- Refined db connector params
|
||||
- *(API)* Removes Redundant Check
|
||||
- Split TokenGeneratorProvider file in their respective classes
|
||||
- Moved milkstream services under a services namespace
|
||||
- *(frontend)* Switched to a more recent session storage implementation which doesn't have a security vulnerability.
|
||||
- Updated run profiles
|
||||
- Moved milkstream services under a services namespace
|
||||
- *(frontend)* Switched to a more recent session storage implementation which doesn't have a security vulnerability.
|
||||
- Updated run profiles
|
||||
- Remove temporary debug panel from Jobs page
|
||||
- Remove redundant XML doc comments from ServiceBase
|
||||
- Upgrade target framework to net10.0
|
||||
- Add dotnet-tools.json for ef tool
|
||||
- *(dev)* Fix ovveride PM Compose deploy
|
||||
- Remove debug auth logging middleware from Program.cs
|
||||
- Remove unused banner-cell-empty CSS class
|
||||
- *(test)* Remove Swagger test — disabled in container environments
|
||||
- Added the test images folder to the dockerignore avoiding them being sent every time a docker build starts
|
||||
- Revert docker-compose.yml to match develop
|
||||
- Updated packages, removed unused packages. ImageSharp now requires a license
|
||||
- Commit compiled CSS for role badge classes
|
||||
- Fix Dockerfiles, add debug compose to solution
|
||||
- Use record for config classes
|
||||
- Adjusted rate-limits of thumbs, preview and jobs to avoid triggering them during normal usage
|
||||
- Add Gitea Actions workflow to build and push containers on version tags
|
||||
|
||||
### 🛡️ Security
|
||||
|
||||
- Upgrade to .NET 10 — packages, Docker images, and Swagger fix
|
||||
|
||||
### ◀️ Revert
|
||||
|
||||
- Switch album detail back to masonry layout
|
||||
- Restore docker-compose.yml — testing-only CORS/baseUrl changes committed by accident
|
||||
- Restore docker-compose.yml LactoseBaseUrl to 192.168.50.100
|
||||
- Remove AllowAnonymous from AlbumController.Search and PersonController.GetAll
|
||||
- Change LoginService back to Singleton (DI error was from docker fast mode)
|
||||
|
||||
### CI
|
||||
|
||||
- Added build + push to internal registry action
|
||||
|
||||
### Fix
|
||||
|
||||
- *(API)* Disables Asset Create
|
||||
- *(API)* Refresh token being changed the last 10 minutes instead of every moment
|
||||
- *(API)* Refresh token being changed the last 10 minutes instead of every moment
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.6.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace Lactose.Analyzers;
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Composition;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CodeActions;
|
||||
using Microsoft.CodeAnalysis.CodeFixes;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Editing;
|
||||
using System.Collections.Immutable;
|
||||
using System.Composition;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Lactose.Analyzers;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace Lactose.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for rate-limit buckets applied to different API route groups.
|
||||
/// </summary>
|
||||
public record RateLimitBucketOptions {
|
||||
/// <summary>
|
||||
/// The configuration section name for rate limiting.
|
||||
/// </summary>
|
||||
public const string SectionName = "RateLimiting";
|
||||
|
||||
/// <summary>
|
||||
/// Default bucket applied to all routes that are not explicitly assigned.
|
||||
/// </summary>
|
||||
public BucketOptions Global { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Strict bucket for authentication endpoints (login, register, refresh).
|
||||
/// </summary>
|
||||
public BucketOptions Auth { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bucket for serving original (full-size) media files.
|
||||
/// </summary>
|
||||
public BucketOptions MediaOriginal { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bucket for serving thumbnail media files.
|
||||
/// </summary>
|
||||
public BucketOptions MediaThumb { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bucket for serving preview media files.
|
||||
/// </summary>
|
||||
public BucketOptions MediaPreview { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bucket for the long-polling jobs endpoint. Uses a shorter window.
|
||||
/// </summary>
|
||||
public BucketOptions Jobs { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settings for an individual rate-limit bucket.
|
||||
/// </summary>
|
||||
public class BucketOptions {
|
||||
/// <summary>
|
||||
/// Maximum number of permits allowed within the window.
|
||||
/// </summary>
|
||||
public int PermitLimit { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Length of the fixed window in seconds.
|
||||
/// </summary>
|
||||
public int WindowSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of queued requests waiting for a permit. Null disables queueing.
|
||||
/// </summary>
|
||||
public int? QueueLimit { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Lactose.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configures <see cref="RateLimitBucketOptions"/> from the application configuration.
|
||||
/// </summary>
|
||||
public class RateLimitBucketOptionsProvider(IConfiguration configuration) : IConfigureOptions<RateLimitBucketOptions> {
|
||||
/// <inheritdoc />
|
||||
public void Configure(RateLimitBucketOptions options) {
|
||||
var section = configuration.GetSection(RateLimitBucketOptions.SectionName);
|
||||
if (!section.Exists())
|
||||
return;
|
||||
|
||||
section.Bind(options);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,18 @@ public class LactoseDbContext : DbContext {
|
||||
/// Gets or sets the job records DbSet.
|
||||
/// </summary>
|
||||
public DbSet<JobRecord> JobRecords { get; set; }
|
||||
/// <summary>
|
||||
/// 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.
|
||||
@@ -60,18 +72,66 @@ public class LactoseDbContext : DbContext {
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||
modelBuilder.HasPostgresExtension("vector");
|
||||
modelBuilder.HasPostgresExtension("pg_trgm");
|
||||
modelBuilder.HasDbFunction(() => PgFunctions.Md5(default!));
|
||||
//Album Relationships
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.UserOwner).WithMany(e => e.OwnedAlbums);
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.CoverAsset);
|
||||
modelBuilder.Entity<Album>().HasMany(e => e.Assets).WithMany(e => e.Albums);
|
||||
//Asset Relationships
|
||||
modelBuilder.Entity<Asset>().HasOne(e => e.Owner).WithMany(e => e.OwnedAssets);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.SharedWith).WithMany(e => e.SharedAssets);
|
||||
modelBuilder.Entity<Asset>().HasOne(e => e.Uploader).WithMany(e => e.UploadedAssets).HasForeignKey(e => e.UploadedBy);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.Albums).WithMany(e => e.Assets);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.Tags).WithMany(e => e.Assets);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.Faces).WithOne(e => e.Asset);
|
||||
modelBuilder.Entity<Asset>().HasOne(e => e.Folder).WithMany(e => e.Assets);
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.CreatedAt })
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(a => a.OriginalFilename)
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm")
|
||||
.HasMethod("gin")
|
||||
.HasOperators("gin_trgm_ops");
|
||||
// Stats-supporting partial indexes
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.CreatedAt)
|
||||
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.Type)
|
||||
.HasDatabaseName("IX_Assets_Stats_Type")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.ThumbnailPath)
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
|
||||
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.PreviewPath)
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
|
||||
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.Hash)
|
||||
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
|
||||
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.ResolutionWidth, e.ResolutionHeight, e.Type })
|
||||
.HasDatabaseName("IX_Assets_Stats_Resolution")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
// Broken assets
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => e.ProcessFailedAt)
|
||||
.HasDatabaseName("IX_Assets_ProcessFailedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
// Keyset pagination for scan jobs:
|
||||
// WHERE FolderId = X AND DeletedAt IS NULL AND IngestedAt >= @cursor AND Id > @last ORDER BY Id
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.FolderId, e.IngestedAt, e.Id })
|
||||
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
// People
|
||||
modelBuilder.Entity<Person>().HasIndex(p => p.Name)
|
||||
.HasDatabaseName("IX_People_Name_Trgm")
|
||||
.HasMethod("gin")
|
||||
.HasOperators("gin_trgm_ops");
|
||||
// Albums
|
||||
modelBuilder.Entity<Album>().HasIndex(a => a.Title)
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm")
|
||||
.HasMethod("gin")
|
||||
.HasOperators("gin_trgm_ops");
|
||||
//Face Relationships
|
||||
modelBuilder.Entity<Face>().HasOne(e => e.Asset).WithMany(e => e.Faces);
|
||||
modelBuilder.Entity<Face>().HasOne(e => e.Person).WithMany(e => e.Faces);
|
||||
@@ -79,13 +139,29 @@ public class LactoseDbContext : DbContext {
|
||||
modelBuilder.Entity<Person>().HasOne(e => e.ProfileAsset).WithMany().HasForeignKey(e => e.ProfileAssetId);
|
||||
modelBuilder.Entity<Person>().HasMany(e => e.Faces).WithOne(e => e.Person);
|
||||
modelBuilder.Entity<Person>().HasMany(e => e.Albums).WithOne(e => e.PersonOwner);
|
||||
modelBuilder.Entity<Person>().HasMany(e => e.Maintainers).WithMany(e => e.MaintainedPersons)
|
||||
.UsingEntity<PersonMaintainer>(
|
||||
j => j.HasOne(pm => pm.User).WithMany().HasForeignKey(pm => pm.UserId),
|
||||
j => j.HasOne(pm => pm.Person).WithMany().HasForeignKey(pm => pm.PersonId)
|
||||
);
|
||||
//Tag Relationships
|
||||
modelBuilder.Entity<Tag>().HasOne(e => e.Parent);
|
||||
modelBuilder.Entity<Tag>().HasMany(e => e.Assets).WithMany(e => e.Tags);
|
||||
//User Relationships
|
||||
modelBuilder.Entity<User>().HasMany(e => e.OwnedAssets).WithOne(e => e.Owner);
|
||||
modelBuilder.Entity<User>().HasMany(e => e.OwnedAlbums).WithOne(e => e.UserOwner);
|
||||
modelBuilder.Entity<User>().HasMany(e => e.UploadedAssets).WithOne(e => e.Uploader).HasForeignKey(e => e.UploadedBy);
|
||||
//Folder Relationships
|
||||
modelBuilder.Entity<Folder>().HasMany(e => e.Assets).WithOne(e => e.Folder);
|
||||
//Scan state
|
||||
modelBuilder.Entity<DirectoryScanState>().HasOne(e => e.Folder).WithMany().HasForeignKey(e => e.FolderId);
|
||||
modelBuilder.Entity<DirectoryScanState>()
|
||||
.HasIndex(e => new { e.FolderId, e.Path })
|
||||
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path")
|
||||
.IsUnique();
|
||||
//Discovery staging
|
||||
modelBuilder.Entity<AssetAlbumStaging>().HasOne(e => e.Asset).WithMany().HasForeignKey(e => e.AssetId);
|
||||
modelBuilder.Entity<AssetAlbumStaging>().HasIndex(e => e.AssetId)
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
|
||||
modelBuilder.Entity<AssetAlbumStaging>().HasIndex(e => e.PersonName)
|
||||
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Maps PostgreSQL functions not covered by the Npgsql provider for use in LINQ-to-SQL queries.
|
||||
/// </summary>
|
||||
public static class PgFunctions {
|
||||
/// <summary>
|
||||
/// Maps to the PostgreSQL <c>md5</c> function. Only callable inside LINQ-to-SQL queries.
|
||||
/// </summary>
|
||||
/// <param name="value">The string to hash.</param>
|
||||
/// <returns>The md5 hex digest of <paramref name="value"/>.</returns>
|
||||
[DbFunction("md5", "pg_catalog")]
|
||||
public static string Md5(string value) => throw new NotSupportedException("PgFunctions.Md5 is only usable inside LINQ-to-SQL queries.");
|
||||
}
|
||||
@@ -16,6 +16,7 @@ namespace Lactose.Controllers;
|
||||
/// <param name="authService">Authentication service.</param>
|
||||
/// <param name="albumRepository">Album repository.</param>
|
||||
/// <param name="assetRepository">Asset repository.</param>
|
||||
/// <param name="personRepository">Person repository.</param>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/[controller]")]
|
||||
@@ -24,87 +25,56 @@ public class AlbumController(
|
||||
//ILogger<AlbumController> logger,
|
||||
LactoseAuthService authService,
|
||||
IAlbumRepository albumRepository,
|
||||
IAssetRepository assetRepository
|
||||
IAssetRepository assetRepository,
|
||||
IPersonRepository personRepository
|
||||
) : ControllerBase {
|
||||
/// <summary>
|
||||
/// 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.Find(id);
|
||||
var album = await albumRepository.FindVisibleAsync(id, uid, accessLevel, cancellationToken);
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
|
||||
// If the album has no assets, return an empty list
|
||||
if (album.Assets == null) return Ok(album.ToAlbumFullDto());
|
||||
|
||||
//Storage for assets that will be sent out;
|
||||
List<Asset> assets = new();
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User:
|
||||
// Only publicly shared assets or owned by user
|
||||
assets.AddRange(album.Assets.Where(asset => (asset.IsPubliclyShared || asset.OwnerId == uid) && asset.DeletedAt == null));
|
||||
// Add shared assets
|
||||
assets.AddRange(album.Assets.Where(asset => asset.SharedWith!.Any(user => user.Id == uid) && asset.DeletedAt == null));
|
||||
break;
|
||||
case EAccessLevel.Curator:
|
||||
// all assets minus deleted
|
||||
assets.AddRange(album.Assets.Where(asset => asset.DeletedAt == null));
|
||||
break;
|
||||
case EAccessLevel.Admin:
|
||||
// All assets
|
||||
assets.AddRange(album.Assets);
|
||||
break;
|
||||
}
|
||||
|
||||
// changes the retrieved album to the new filtered
|
||||
album.Assets = assets.OrderBy(a => a.OriginalFilename).ToList();
|
||||
return Ok(album.ToAlbumFullDto());
|
||||
return Ok(album.ToAlbumFullDto(accessLevel, uid));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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]
|
||||
[AllowAnonymous]
|
||||
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 < 0) return BadRequest();
|
||||
|
||||
var albums = albumRepository.SearchQuery(
|
||||
|
||||
if(pagingOptions.Page < 0 || pagingOptions.PageSize < 1 || pagingOptions.PageSize > PagedParametersDto.MaxPageSize) return BadRequest();
|
||||
|
||||
var albums = await albumRepository.SearchQueryAsync(
|
||||
pagingOptions.Search ?? string.Empty,
|
||||
pagingOptions.Page,
|
||||
pagingOptions.PageSize,
|
||||
pagingOptions.SortBy,
|
||||
pagingOptions.SortAsc,
|
||||
pagingOptions.Unassigned
|
||||
).ToList();
|
||||
pagingOptions.Unassigned,
|
||||
uid ?? default,
|
||||
accessLevel,
|
||||
pagingOptions.AssetUploadedBy,
|
||||
pagingOptions.PersonOwnerId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
switch (accessLevel) {
|
||||
default:
|
||||
case EAccessLevel.User:
|
||||
List<Album> filteredAlbums;
|
||||
// Only publicly shared albums or owned by user
|
||||
filteredAlbums = albums.Where(album => album.Assets != null && (album.UserOwnerId == uid || album.Assets.Any(asset => asset.IsPubliclyShared && asset.DeletedAt == null))).ToList();
|
||||
// Add shared albums
|
||||
filteredAlbums.AddRange(albums.Where(album => album.Assets != null && album.Assets.Any(x => x.DeletedAt == null && x.SharedWith!.Any(user => user.Id == uid))));
|
||||
return Ok(filteredAlbums.Select(x => x.ToAlbumPreviewDto()).ToList());
|
||||
case EAccessLevel.Curator:
|
||||
case EAccessLevel.Admin:
|
||||
// All albums
|
||||
return Ok(albums.Select(x => x.ToAlbumPreviewDto()).ToList());
|
||||
}
|
||||
return Ok(albums);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -112,40 +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 = "Admin, Curator")]
|
||||
public ActionResult Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto) {
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public async Task<ActionResult> Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto, CancellationToken cancellationToken) {
|
||||
Guid? uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var album = albumRepository.Find(id);
|
||||
var album = await albumRepository.FindWithAssetsAsync(id, cancellationToken);
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User:
|
||||
// Users can't update albums they don't own
|
||||
if(album.UserOwnerId != uid) return Forbid();
|
||||
break;
|
||||
// Admins and Curator can update any album
|
||||
case EAccessLevel.Curator:
|
||||
case EAccessLevel.Admin:
|
||||
break;
|
||||
}
|
||||
// Maintainer scope check — must maintain the album's person
|
||||
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.UserOwnerId = albumDto.Owner ?? album.UserOwnerId;
|
||||
album.Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : album.Assets;
|
||||
album.Visibility = albumDto.Visibility ?? album.Visibility;
|
||||
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();
|
||||
}
|
||||
@@ -154,96 +120,148 @@ 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 = "Admin, Curator")]
|
||||
public ActionResult BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto) {
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
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: {
|
||||
// Maintainers can only update albums of persons they maintain
|
||||
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 only update albums they own
|
||||
albums = albums.Where(x => x.UserOwnerId == uid).ToList();
|
||||
// Curators can update any album
|
||||
break;
|
||||
case EAccessLevel.Admin:
|
||||
// Admins can update any album
|
||||
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.UserOwnerId = bulkDto.Data.Owner ?? album.UserOwnerId;
|
||||
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 = "Admin, Curator")]
|
||||
public ActionResult Create([FromBody] AlbumCreateDto albumDto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public async Task<ActionResult> Create([FromBody] AlbumCreateDto albumDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
// If not admin, the owner of a new album is the current user
|
||||
albumDto.Owner = accessLevel == EAccessLevel.Admin ? albumDto.Owner : uid;
|
||||
|
||||
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(),
|
||||
Title = albumDto.Name,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
PersonOwnerId = albumDto.Person ?? null,
|
||||
UserOwnerId = albumDto.Owner,
|
||||
Visibility = albumDto.Visibility,
|
||||
CoverAssetId = albumDto.CoverAssetId ?? assets.OrderBy(a => a.OriginalFilename).FirstOrDefault()?.Id,
|
||||
Assets = assets
|
||||
};
|
||||
|
||||
albumRepository.Insert(album);
|
||||
albumRepository.Save();
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok(album.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple source albums into a destination album.
|
||||
/// 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 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 (await albumRepository.FindAsync(dto.DestinationId, cancellationToken) == null)
|
||||
return NotFound("Destination album not found.");
|
||||
|
||||
await albumRepository.MergeAlbumsAsync(dto.DestinationId, dto.SourceIds, cancellationToken);
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 = "Admin, Curator")]
|
||||
public ActionResult BulkDelete([FromBody] List<Guid> albumIds) {
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
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);
|
||||
|
||||
// If the user is not an admin, filter out albums they do not own
|
||||
if (accessLevel != EAccessLevel.Admin) { albums = albums.Where(x => x.UserOwnerId == uid).ToList(); }
|
||||
// Filter by access level
|
||||
switch (accessLevel) {
|
||||
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();
|
||||
}
|
||||
|
||||
// If no albums are left after filtering, return a 403
|
||||
if (albumIds.Count == 0) return Forbid();
|
||||
if (albums.Count == 0) return Forbid();
|
||||
|
||||
albums.ForEach(albumRepository.Remove);
|
||||
albumRepository.Save();
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -252,22 +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 = "Admin, Curator")]
|
||||
public ActionResult Delete(Guid id) {
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
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();
|
||||
// If the user is not the owner or an admin, return a 403
|
||||
if (accessLevel != EAccessLevel.Admin && album.UserOwnerId != uid) 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();
|
||||
}
|
||||
|
||||
@@ -18,21 +18,26 @@ namespace Lactose.Controllers;
|
||||
/// <param name="authService">Authentication service.</param>
|
||||
/// <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(
|
||||
ILogger<AssetController> logger,
|
||||
LactoseAuthService authService,
|
||||
IAssetRepository assetRepository,
|
||||
IUserRepository userRepository
|
||||
IUserRepository userRepository,
|
||||
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;
|
||||
|
||||
@@ -44,50 +49,14 @@ public class AssetController(
|
||||
"""
|
||||
);
|
||||
|
||||
var asset = assetRepository.Find(id);
|
||||
var asset = await assetRepository.FindVisibleAsync(id, uid, accessLevel, cancellationToken);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||
logger.LogWarning($"Request asset {id} not found or not visible!");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User: {
|
||||
//Not publicly shared && Not shared with the user
|
||||
if (!asset.IsPubliclyShared && asset.SharedWith?.Find(x => x.Id == uid) == null) {
|
||||
logger.LogTrace($"{asset.Id} is not shared with user {uid}");
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
//Deleted asset
|
||||
if (asset.DeletedAt != null) {
|
||||
logger.LogTrace($"{asset.Id} is deleted and thus not exists for this user");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.Curator: {
|
||||
//Deleted asset owned by the user
|
||||
if (asset.DeletedAt != null)
|
||||
if (asset.Owner?.Id != uid) {
|
||||
logger.LogTrace(
|
||||
$"{asset.Id} is deleted and it's not owned by this user so it does not exists for this user"
|
||||
);
|
||||
|
||||
return NotFound();
|
||||
} else { logger.LogTrace($"{asset.Id} is deleted but it's owned by this user so it exists for this user"); }
|
||||
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.Admin: {
|
||||
//Admins will see this regardless
|
||||
logger.LogTrace($"{asset.Id} is being requested by an admin, sending it regardless of the visibility state");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
AssetDto dto = asset.ToFullAssetsDto(accessLevel);
|
||||
AssetDto dto = asset.ToFullAssetsDto(accessLevel, uid);
|
||||
|
||||
logger.LogTrace(
|
||||
$"""
|
||||
@@ -95,11 +64,11 @@ public class AssetController(
|
||||
dto.Id: {dto.Id}
|
||||
dto.FileName: {dto.FileName}
|
||||
dto.AssetType: {dto.AssetType}
|
||||
dto.IsPublic: {dto.IsPublic}
|
||||
dto.Visibility: {dto.Visibility}
|
||||
dto.CreatedAt: {dto.CreatedAt}
|
||||
dto.UpdatedAt: {dto.UpdatedAt}
|
||||
dto.DeletedAt: {dto.DeletedAt}
|
||||
dto.Owner: {dto.Owner.Id} {dto.Owner.Username}
|
||||
dto.UploadedBy: {dto.UploadedBy.Id} {dto.UploadedBy.Username}
|
||||
"""
|
||||
);
|
||||
|
||||
@@ -107,12 +76,13 @@ public class AssetController(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches assets with optional date range, type filter, and random ordering with pagination.
|
||||
/// 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, random ordering, and pagination.</param>
|
||||
/// <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;
|
||||
@@ -124,14 +94,18 @@ public class AssetController(
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
if (searchOptionsDto.Page < 0 || searchOptionsDto.PageSize < 1) {
|
||||
if (searchOptionsDto.Page < 0 || searchOptionsDto.PageSize < 1 || searchOptionsDto.PageSize > PagedParametersDto.MaxPageSize) {
|
||||
logger.LogWarning("Invalid pagination parameters provided");
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
var assets = assetRepository.GetAssets(
|
||||
var (assets, total) = await assetRepository.GetAssetsAsync(
|
||||
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
|
||||
searchOptionsDto.Page + 1, searchOptionsDto.PageSize, out int total
|
||||
searchOptionsDto.Page, searchOptionsDto.PageSize,
|
||||
uid, accessLevel,
|
||||
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search,
|
||||
searchOptionsDto.IncludeCount, searchOptionsDto.DeletedOnly, searchOptionsDto.AlbumId, searchOptionsDto.PersonId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
logger.LogTrace(
|
||||
@@ -146,25 +120,94 @@ public class AssetController(
|
||||
"""
|
||||
);
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User: {
|
||||
assets = assets.Where(
|
||||
a => a.DeletedAt == null && (a.IsPubliclyShared || a.SharedWith?.Find(x => x.Id == uid) != null)
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.Curator:
|
||||
case EAccessLevel.Admin: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var dtoList = assets.ToAssetPreviewDto().ToList();
|
||||
|
||||
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
|
||||
logger.LogTrace($"Returning {assets.Count} assets (total: {total})");
|
||||
Response.Headers["X-Total-Count"] = total.ToString();
|
||||
return Ok(dtoList);
|
||||
return Ok(assets);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
|
||||
/// Admins and curators receive all folders with their unlinked asset counts. Maintainers receive a single
|
||||
/// "My Uploads" group with their own unlinked upload count.
|
||||
/// </summary>
|
||||
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
|
||||
[HttpGet("unlinked-groups")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
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 = await assetRepository.GetFolderGroupsAsync(uid, accessLevel, unlinkedOnly, cancellationToken);
|
||||
logger.LogTrace("GetFolderGroups: returned {Count} groups", groups.Count);
|
||||
return Ok(groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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, 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;
|
||||
|
||||
if (options.Page < 0 || options.PageSize < 1 || options.PageSize > PagedParametersDto.MaxPageSize)
|
||||
return BadRequest();
|
||||
|
||||
logger.LogTrace("FsBrowse: folderId={FolderId}, path={Path}, page={Page}, unlinkedOnly={UnlinkedOnly}", options.FolderId, options.Path, options.Page, 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>
|
||||
@@ -172,39 +215,53 @@ public class AssetController(
|
||||
/// </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 = "Curator,Admin")]
|
||||
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto) {
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
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.Find(id);
|
||||
var asset = await assetRepository.FindWithAlbumsAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
|
||||
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
|
||||
&& (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();
|
||||
}
|
||||
|
||||
string log = "";
|
||||
|
||||
asset.IsPubliclyShared = dto.IsPublic ?? asset.IsPubliclyShared;
|
||||
log += $"PubliclyShared: {asset.IsPubliclyShared} -> {dto.IsPublic ?? asset.IsPubliclyShared}\n";
|
||||
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();
|
||||
}
|
||||
|
||||
asset.Owner = dto.Owner != null ? userRepository.Find(dto.Owner.Value) : asset.Owner;
|
||||
log += $"Owner: {asset.Owner?.Id} -> {dto.Owner ?? asset.Owner?.Id}\n";
|
||||
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;
|
||||
log += $"UploadedBy: {asset.UploadedBy} -> {dto.UploadedBy ?? asset.UploadedBy}\n";
|
||||
}
|
||||
|
||||
logger.LogTrace($"Updating asset {id}\n{log}");
|
||||
|
||||
assetRepository.Update(asset);
|
||||
assetRepository.Save();
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -213,13 +270,14 @@ public class AssetController(
|
||||
/// Updates multiple assets in a bulk operation.
|
||||
/// </summary>
|
||||
/// <param name="bulkDto">The bulk update data with asset IDs and shared values.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, or an error response.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto) {
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
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();
|
||||
|
||||
@@ -234,45 +292,93 @@ public class AssetController(
|
||||
}
|
||||
|
||||
//If the user is not an admin, they can only update their own assets
|
||||
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
|
||||
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
|
||||
enumerable = accesslevel == EAccessLevel.Maintainer
|
||||
? 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.IsPubliclyShared = bulkDto.Data.IsPublic ?? x.IsPubliclyShared;
|
||||
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
|
||||
x.Owner = bulkDto.Data.Owner != null ? userRepository.Find(bulkDto.Data.Owner.Value) : x.Owner;
|
||||
x.Visibility = bulkDto.Data.Visibility ?? x.Visibility;
|
||||
x.DeletedAt = bulkDto.Data.IsDeleted == true ? DateTime.UtcNow : x.DeletedAt;
|
||||
|
||||
if (accesslevel == EAccessLevel.Admin) {
|
||||
x.UploadedBy = bulkDto.Data.UploadedBy ?? x.UploadedBy;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assetRepository.UpdateBulk(enumerable);
|
||||
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 = "Curator,Admin")]
|
||||
public IStatusCodeActionResult Delete([FromRoute] Guid id) {
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
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.Find(id);
|
||||
var asset = await assetRepository.FindWithAlbumsAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
|
||||
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
|
||||
&& (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();
|
||||
}
|
||||
@@ -281,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 = "Curator,Admin")]
|
||||
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
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();
|
||||
|
||||
@@ -302,11 +409,15 @@ public class AssetController(
|
||||
}
|
||||
|
||||
//If the user is not an admin, they can only delete their own assets
|
||||
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
|
||||
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
|
||||
enumerable = accesslevel == EAccessLevel.Maintainer
|
||||
? await FilterForMaintainerAsync(enumerable, uid.Value, cancellationToken)
|
||||
: enumerable.Where(x => x.UploadedBy == uid).ToList();
|
||||
|
||||
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
|
||||
|
||||
assetRepository.UpdateBulk(enumerable);
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -314,31 +425,31 @@ 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 (userRepository.Find(dto.Owner) == null) {
|
||||
logger.LogWarning($"User {dto.Owner} does not exist in the database");
|
||||
return BadRequest();
|
||||
}
|
||||
if (dto.UploadedBy.HasValue) {
|
||||
if (await userRepository.FindAsync(dto.UploadedBy.Value, cancellationToken) == null) {
|
||||
logger.LogWarning($"User {dto.UploadedBy} does not exist in the database");
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
if (accesslevel != EAccessLevel.Admin && dto.Owner != uid) {
|
||||
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.Owner}");
|
||||
return Unauthorized();
|
||||
if (accesslevel != EAccessLevel.Admin && dto.UploadedBy != uid) {
|
||||
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.UploadedBy}");
|
||||
return Unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: This makes exactly zero sense. How are we supposed to create an asset externally here?
|
||||
// Does this even need to exist?
|
||||
var asset = new Asset {
|
||||
Id = Guid.NewGuid(),
|
||||
Type = dto.AssetType,
|
||||
IsPubliclyShared = dto.IsPublic,
|
||||
OwnerId = dto.Owner,
|
||||
Visibility = dto.Visibility,
|
||||
UploadedBy = dto.UploadedBy,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
MimeType = dto.MimeType,
|
||||
ResolutionWidth = dto.ResolutionWidth,
|
||||
@@ -347,12 +458,48 @@ 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a Maintainer has access to an asset through the cosplayers they maintain.
|
||||
/// A Maintainer can access assets that are in any album belonging to a person they maintain.
|
||||
/// </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 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.User;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
@@ -7,6 +8,7 @@ using Lactose.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Lactose.Controllers;
|
||||
|
||||
@@ -17,30 +19,34 @@ namespace Lactose.Controllers;
|
||||
/// <param name="authService">Authentication service.</param>
|
||||
/// <param name="userRepository">User repository.</param>
|
||||
/// <param name="passwordHasher">Password hasher.</param>
|
||||
/// <param name="settingsRepository">Settings repository.</param>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[EnableRateLimiting("auth")]
|
||||
public class AuthController(
|
||||
ILogger<AuthController> logger,
|
||||
LactoseAuthService authService,
|
||||
IUserRepository userRepository,
|
||||
IPasswordHasher<User> passwordHasher
|
||||
IPasswordHasher<User> passwordHasher,
|
||||
ISettingsRepository settingsRepository
|
||||
) : ControllerBase {
|
||||
|
||||
/// <summary>
|
||||
/// 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() {
|
||||
@@ -74,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,
|
||||
@@ -92,14 +98,30 @@ public class AuthController(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether user registration is currently enabled.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 if enabled, 403 if disabled.</returns>
|
||||
[HttpGet("register")]
|
||||
public async Task<ActionResult> CheckRegistrationEnabled(CancellationToken cancellationToken) {
|
||||
var regSetting = await settingsRepository.GetAsync(Settings.UserRegistrationEnabled.AsString(), cancellationToken);
|
||||
return regSetting?.Value == "true" ? Ok() : StatusCode(403);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new user account.
|
||||
/// </summary>
|
||||
/// <param name="dto">The registration data.</param>
|
||||
/// <returns>200 with the new user ID, or 409 if the user already exists.</returns>
|
||||
/// <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) {
|
||||
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");
|
||||
|
||||
logger.LogDebug(
|
||||
$"""
|
||||
Registering user:
|
||||
@@ -108,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");
|
||||
}
|
||||
|
||||
@@ -123,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);
|
||||
}
|
||||
|
||||
@@ -133,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();
|
||||
}
|
||||
@@ -153,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(
|
||||
@@ -206,7 +230,7 @@ public class AuthController(
|
||||
authDto.RefreshToken = refreshToken;
|
||||
user.RefreshToken = refreshToken;
|
||||
user.RefreshTokenExpires = DateTime.UtcNow.AddMinutes(LactoseAuthService.LongExpirationTime);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return authDto;
|
||||
|
||||
@@ -29,10 +29,11 @@ namespace Lactose.Controllers;
|
||||
/// Creates a new folder for asset scanning.
|
||||
/// </summary>
|
||||
/// <param name="folderCreateDto">The folder creation data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Create([FromBody] FolderCreateDto folderCreateDto) {
|
||||
public async Task<ActionResult> Create([FromBody] FolderCreateDto folderCreateDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
@@ -43,40 +44,39 @@ namespace Lactose.Controllers;
|
||||
Active = false
|
||||
};
|
||||
|
||||
folderRepository.Create(newFolder);
|
||||
await folderRepository.InsertAsync(newFolder, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tracked folders.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of all folders.</returns>
|
||||
[HttpGet]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<List<FolderFullDto>> GetAll() {
|
||||
public async Task<ActionResult<List<FolderFullDto>>> GetAll(CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
return Ok(
|
||||
folderRepository.GetAll()
|
||||
.Select(folder => folder.ToFolderDto())
|
||||
.ToList()
|
||||
);
|
||||
List<Folder> folders = await folderRepository.GetAllAsync(cancellationToken);
|
||||
return Ok(folders.Select(folder => folder.ToFolderDto()).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific folder by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The folder details, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<FolderFullDto> Get([FromRoute] Guid id) {
|
||||
public async Task<ActionResult<FolderFullDto>> Get([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
var folder = folderRepository.Get(id);
|
||||
var folder = await folderRepository.GetAsync(id, cancellationToken);
|
||||
|
||||
if (folder == null) return NotFound();
|
||||
|
||||
@@ -88,22 +88,23 @@ namespace Lactose.Controllers;
|
||||
/// </summary>
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <param name="folderUpdateDto">The folder update data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 404 if not found.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Update([FromRoute] Guid id, [FromBody] FolderUpdateDto folderUpdateDto) {
|
||||
public async Task<ActionResult> Update([FromRoute] Guid id, [FromBody] FolderUpdateDto folderUpdateDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
Folder? folder = folderRepository.Get(id);
|
||||
Folder? folder = await folderRepository.GetAsync(id, cancellationToken);
|
||||
if (folder == null) return NotFound();
|
||||
|
||||
|
||||
folder.BasePath = folderUpdateDto.BasePath ?? folder.BasePath;
|
||||
folder.Active = folderUpdateDto.Active ?? folder.Active;
|
||||
folder.RegexPattern = folderUpdateDto.RegexPattern ?? folder.RegexPattern;
|
||||
|
||||
folderRepository.Update(id, folder);
|
||||
await folderRepository.UpdateAsync(id, folder, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -112,20 +113,21 @@ namespace Lactose.Controllers;
|
||||
/// </summary>
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <param name="count">Number of sample paths to return (default 10, max 50).</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of up to <paramref name="count"/> random asset paths, or 404 if the folder is not found.</returns>
|
||||
[HttpGet("{id}/sample-paths")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<FolderSamplePathsDto> GetSamplePaths([FromRoute] Guid id, [FromQuery] int count = 10) {
|
||||
public async Task<ActionResult<FolderSamplePathsDto>> GetSamplePaths([FromRoute] Guid id, [FromQuery] int count = 10, CancellationToken cancellationToken = default) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
var folder = folderRepository.Get(id);
|
||||
var folder = await folderRepository.GetAsync(id, cancellationToken);
|
||||
if (folder == null) return NotFound();
|
||||
|
||||
count = Math.Clamp(count, 1, 50);
|
||||
|
||||
var paths = assetRepository.GetRandomPathsByFolder(id, count);
|
||||
var paths = await assetRepository.GetRandomPathsByFolderAsync(id, count, cancellationToken);
|
||||
|
||||
return Ok(new FolderSamplePathsDto {
|
||||
FolderId = id,
|
||||
@@ -137,15 +139,16 @@ namespace Lactose.Controllers;
|
||||
/// Deletes a folder (stops tracking it).
|
||||
/// </summary>
|
||||
/// <param name="id">The folder ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Delete([FromRoute] Guid id) {
|
||||
public async Task<ActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
folderRepository.Delete(id);
|
||||
await folderRepository.RemoveAsync(id, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.Jobs;
|
||||
using Butter.Types;
|
||||
using Lactose.Jobs;
|
||||
using Lactose.Mapper;
|
||||
using Lactose.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Lactose.Controllers;
|
||||
|
||||
@@ -17,6 +18,7 @@ namespace Lactose.Controllers;
|
||||
[ApiController]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[Route("api/[controller]")]
|
||||
[EnableRateLimiting("jobs")]
|
||||
public class JobsController(JobManager jobManager, JobScheduler jobScheduler, LactoseAuthService authService) : ControllerBase {
|
||||
|
||||
/// <summary>
|
||||
@@ -33,12 +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 = 1, [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 });
|
||||
}
|
||||
|
||||
@@ -47,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 });
|
||||
}
|
||||
|
||||
@@ -93,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)
|
||||
@@ -132,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ using Lactose.Repositories;
|
||||
using Lactose.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
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;
|
||||
@@ -15,7 +17,9 @@ namespace Lactose.Controllers;
|
||||
/// </summary>
|
||||
/// <param name="authService">Authentication service.</param>
|
||||
/// <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]
|
||||
@@ -24,48 +28,105 @@ public class MediaController(
|
||||
LactoseAuthService authService,
|
||||
IMediaRepository mediaRepository,
|
||||
IAssetRepository assetRepository,
|
||||
IOptions<SignKeyConfiguration> signKeyOptions
|
||||
IOptions<SignKeyConfiguration> signKeyOptions,
|
||||
ILogger<MediaController> logger
|
||||
) : ControllerBase {
|
||||
|
||||
/// <summary>
|
||||
/// Gets the original image file for an asset.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The physical image file, or 404 if not found or access denied.</returns>
|
||||
[HttpGet("{id}")]
|
||||
[HttpGet("original/{id}")]
|
||||
public ActionResult GetImage(Guid id) {
|
||||
[EnableRateLimiting("media_original")]
|
||||
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);
|
||||
|
||||
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}")]
|
||||
public ActionResult GetThumb(Guid id) {
|
||||
[EnableRateLimiting("media_thumb")]
|
||||
public async Task<ActionResult> GetThumb(Guid id, CancellationToken cancellationToken) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
|
||||
var asset = await assetRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
// Thumbnails are immutable per asset — allow browser/proxy caching
|
||||
Response.Headers.CacheControl = "public, max-age=86400";
|
||||
|
||||
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
|
||||
return PhysicalFile(asset.ThumbnailPath, "image/webp");
|
||||
}
|
||||
var data = mediaRepository.GetThumbData(id, user?.Id);
|
||||
var data = await mediaRepository.GetThumbDataAsync(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User, cancellationToken);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
@@ -75,19 +136,24 @@ public class MediaController(
|
||||
/// Gets the preview image for an asset.
|
||||
/// </summary>
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The physical preview file, or 404 if not found or access denied.</returns>
|
||||
[HttpGet("preview/{id}")]
|
||||
public ActionResult GetPreview(Guid id) {
|
||||
[EnableRateLimiting("media_preview")]
|
||||
public async Task<ActionResult> GetPreview(Guid id, CancellationToken cancellationToken) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
|
||||
var asset = await assetRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
// Previews are immutable per asset — allow browser/proxy caching
|
||||
Response.Headers.CacheControl = "public, max-age=86400";
|
||||
|
||||
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
|
||||
return PhysicalFile(asset.PreviewPath, "image/webp");
|
||||
}
|
||||
var data = mediaRepository.GetPreviewData(id, user?.Id);
|
||||
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>
|
||||
@@ -27,65 +28,45 @@ public class PersonController(
|
||||
/// Gets a person by ID with their associated albums, filtered by access level and paginated.
|
||||
/// </summary>
|
||||
/// <param name="id">The person ID.</param>
|
||||
/// <param name="albumPaging">Pagination parameters for albums.</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] PagedParametersDto 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.Find(id);
|
||||
var person = await personRepository.FindVisibleAsync(id, uid, accessLevel,
|
||||
albumPaging.Search, albumPaging.SortBy, albumPaging.SortAsc,
|
||||
albumPaging.Page, albumPaging.PageSize, cancellationToken);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
if (person.Albums != null && accessLevel == EAccessLevel.User) {
|
||||
var owned = person.Albums.Where(a => a.UserOwnerId == uid).ToList();
|
||||
var publicShared = person.Albums
|
||||
.Where(a => a.Assets != null && a.Assets.Any(asset => asset is { IsPubliclyShared: true, DeletedAt: null }))
|
||||
.ToList();
|
||||
var shared = person.Albums
|
||||
.Where(a => a.Assets != null && a.Assets.Any(asset => asset is {
|
||||
DeletedAt: null, SharedWith: { Count: > 0 }
|
||||
} && asset.SharedWith!.Any(u => u.Id == uid)))
|
||||
.ToList();
|
||||
|
||||
person.Albums = owned
|
||||
.Union(publicShared)
|
||||
.Union(shared)
|
||||
.DistinctBy(a => a.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (person.Albums != null) {
|
||||
person.Albums = person.Albums
|
||||
.Skip(albumPaging.Page * albumPaging.PageSize)
|
||||
.Take(albumPaging.PageSize)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return Ok(person.ToPersonDetailedDto());
|
||||
return Ok(person.ToPersonDetailedDto(accessLevel, uid));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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]
|
||||
[AllowAnonymous]
|
||||
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;
|
||||
|
||||
var people = personRepository.SearchQuery(
|
||||
if (pagedSearch.Page < 0 || pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
|
||||
|
||||
var people = await personRepository.SearchQueryAsync(
|
||||
pagedSearch.Search ?? string.Empty,
|
||||
pagedSearch.Page,
|
||||
pagedSearch.PageSize,
|
||||
pagedSearch.SortBy,
|
||||
pagedSearch.SortAsc,
|
||||
uid ?? default,
|
||||
accessLevel
|
||||
).Select(p => p.ToPersonPreviewDto())
|
||||
.ToList();
|
||||
accessLevel,
|
||||
cancellationToken
|
||||
);
|
||||
return Ok(people);
|
||||
}
|
||||
|
||||
@@ -94,99 +75,150 @@ 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 = "Admin, Curator")]
|
||||
public IActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) {
|
||||
var person = personRepository.Find(id);
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
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 = await personRepository.FindAsync(id, cancellationToken);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
person.Name = dto.Name;
|
||||
if (accessLevel == EAccessLevel.Maintainer) {
|
||||
if (uid == null || !await personRepository.IsMaintainerOfAsync(uid.Value, id, cancellationToken))
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
person.Name = dto.Name ?? person.Name;
|
||||
person.ProfileAssetId = dto.ProfileAssetId;
|
||||
person.ProfileCropX = dto.ProfileCropX;
|
||||
person.ProfileCropY = dto.ProfileCropY;
|
||||
person.ProfileCropZoom = dto.ProfileCropZoom;
|
||||
|
||||
if (dto.Visibility != null && accessLevel >= EAccessLevel.Maintainer)
|
||||
person.Visibility = dto.Visibility.Value;
|
||||
|
||||
if (dto.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
|
||||
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) {
|
||||
var people = personRepository.FindBulk(personBulkDto.Ids).ToList();
|
||||
public async Task<IActionResult> BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
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.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
var result = await personRepository.ExecuteInTransactionAsync(async () => {
|
||||
var people = await personRepository.FindBulkAsync(personBulkDto.Ids, CancellationToken.None);
|
||||
|
||||
personRepository.Save();
|
||||
return Ok();
|
||||
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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple source people into a destination person.
|
||||
/// 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 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 (await personRepository.FindAsync(dto.DestinationId, cancellationToken) == null)
|
||||
return NotFound("Destination person not found.");
|
||||
|
||||
await personRepository.MergePeopleAsync(dto.DestinationId, dto.SourceIds, cancellationToken);
|
||||
await personRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -194,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,
|
||||
@@ -205,7 +238,7 @@ public class PersonController(
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
personRepository.Insert(person);
|
||||
personRepository.Save();
|
||||
await personRepository.SaveAsync(cancellationToken);
|
||||
return Ok(person.Id);
|
||||
}
|
||||
}
|
||||
@@ -27,22 +27,24 @@ public class SettingsController(
|
||||
/// Creates a new application setting.
|
||||
/// </summary>
|
||||
/// <param name="settingDto">The setting to create.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Create([FromBody] SettingDto settingDto) {
|
||||
public async Task<ActionResult> Create([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
settingsRepository.Create(
|
||||
await settingsRepository.CreateAsync(
|
||||
new Setting {
|
||||
Name = settingDto.Name,
|
||||
Value = settingDto.Value,
|
||||
Description = settingDto.Description,
|
||||
Type = settingDto.Type,
|
||||
DisplayType = settingDto.DisplayType
|
||||
}
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Ok();
|
||||
@@ -51,28 +53,30 @@ public class SettingsController(
|
||||
/// <summary>
|
||||
/// Gets all application settings.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of all settings.</returns>
|
||||
[HttpGet]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<SettingDto> Get() {
|
||||
public async Task<ActionResult<SettingDto>> Get(CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||
|
||||
return Ok(settingsRepository.Get().Select(s => s.ToSettingDto()).ToList());
|
||||
return Ok((await settingsRepository.GetAllAsync(cancellationToken)).Select(s => s.ToSettingDto()).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing application setting.
|
||||
/// </summary>
|
||||
/// <param name="settingDto">The updated setting data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Update([FromBody] SettingDto settingDto) {
|
||||
public async Task<ActionResult> Update([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||
|
||||
settingsRepository.Update(settingDto.ToSetting());
|
||||
await settingsRepository.UpdateAsync(settingDto.ToSetting(), cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -80,29 +84,31 @@ public class SettingsController(
|
||||
/// Deletes a specific application setting.
|
||||
/// </summary>
|
||||
/// <param name="settingDto">The setting to delete.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult Delete([FromBody] SettingDto settingDto) {
|
||||
public async Task<ActionResult> Delete([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||
|
||||
settingsRepository.Delete(settingDto.ToSetting());
|
||||
await settingsRepository.DeleteAsync(settingDto.ToSetting(), cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all application settings.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[Route("delete-all")]
|
||||
public ActionResult DeleteAll() {
|
||||
public async Task<ActionResult> DeleteAll(CancellationToken cancellationToken) {
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
|
||||
|
||||
settingsRepository.Delete();
|
||||
await settingsRepository.DeleteAllAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,12 @@ public class StatsController(
|
||||
/// <summary>
|
||||
/// Returns comprehensive aggregate statistics about the instance.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A <see cref="StatsDto"/> with all gathered statistics.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<StatsDto> Get() {
|
||||
public async Task<ActionResult<StatsDto>> Get(CancellationToken cancellationToken) {
|
||||
logger.LogTrace("Stats requested");
|
||||
var stats = statsRepository.GetStats();
|
||||
var stats = await statsRepository.GetStatsAsync(cancellationToken);
|
||||
return Ok(stats);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@ public class TagController(
|
||||
/// <param name="id">The tag ID.</param>
|
||||
/// <param name="children">Whether to include child tags.</param>
|
||||
/// <param name="ancestors">Whether to include ancestor tags.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>The tag with its hierarchy, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<TagDto> Get(Guid id, bool children = false, bool ancestors = false) {
|
||||
Tag? tag = tagRepository.Find(id);
|
||||
public async Task<ActionResult<TagDto>> Get(Guid id, bool children = false, bool ancestors = false, CancellationToken cancellationToken = default) {
|
||||
Tag? tag = await tagRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
if (tag == null) { return NotFound(); }
|
||||
var tagDto = TagsMapper.ToTagDto(tag);
|
||||
@@ -40,7 +41,7 @@ public class TagController(
|
||||
|
||||
if (children) {
|
||||
tagDto.Children = [];
|
||||
tagRepository.GetChildren(tag.Id).ForEach(t => tagDto.Children.Add(TagsMapper.ToTagDto(t)));
|
||||
(await tagRepository.GetChildrenAsync(tag.Id, cancellationToken)).ForEach(t => tagDto.Children.Add(TagsMapper.ToTagDto(t)));
|
||||
}
|
||||
|
||||
//TODO: Protect from overflow
|
||||
@@ -65,14 +66,15 @@ public class TagController(
|
||||
/// Searches tags with pagination and optional search term.
|
||||
/// </summary>
|
||||
/// <param name="pagedSearch">Pagination and search parameters.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of tags matching the search criteria.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<List<TagDto>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) {
|
||||
public async Task<ActionResult<List<TagDto>>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch, CancellationToken cancellationToken) {
|
||||
pagedSearch.Search ??= string.Empty;
|
||||
if(pagedSearch.Page < 0) { return BadRequest("Page must be greater than 0"); }
|
||||
if(pagedSearch.PageSize < 0) { return BadRequest("PageSize must be greater than 0"); }
|
||||
|
||||
List<Tag> tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize);
|
||||
if(pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest($"PageSize must be between 1 and {PagedParametersDto.MaxPageSize}"); }
|
||||
|
||||
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 = "Admin, Curator")]
|
||||
[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 = "Admin, Curator")]
|
||||
[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 = "Admin, Curator")]
|
||||
[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 = "Admin, Curator")]
|
||||
[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 = "Admin, Curator")]
|
||||
[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) {
|
||||
@@ -83,26 +86,30 @@ namespace Lactose.Controllers {
|
||||
return Unauthorized("Unauthorized");
|
||||
}
|
||||
|
||||
//TODO: Lower level of access should fill less information of users
|
||||
logger.LogDebug($"Sending user with id {id}");
|
||||
|
||||
User? user = userRepository.Find(id);
|
||||
User? user = await userRepository.FindWithMaintainedPersonsAsync(id, cancellationToken);
|
||||
|
||||
if (user is null) return NotFound();
|
||||
|
||||
return Ok(user.ToGetUsersDto());
|
||||
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all users.
|
||||
/// Gets all users (admin/curator only).
|
||||
/// </summary>
|
||||
/// <returns>A list of all users.</returns>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[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()).ToList());
|
||||
List<User> users = await userRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
return Ok(users.Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
|
||||
}
|
||||
|
||||
|
||||
@@ -110,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) {
|
||||
@@ -134,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; }
|
||||
@@ -168,20 +176,25 @@ namespace Lactose.Controllers {
|
||||
}
|
||||
|
||||
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
|
||||
|
||||
if (dto.MaintainedPersonIds != null) {
|
||||
await userRepository.SetMaintainedPersonsAsync(user.Id, dto.MaintainedPersonIds, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
userRepository.Save();
|
||||
return Ok(user.ToGetUsersDto());
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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) {
|
||||
@@ -191,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();
|
||||
|
||||
userRepository.Delete(user);
|
||||
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,11 +125,39 @@
|
||||
"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",
|
||||
"Description": "Number of assets to process per sub-job batch.",
|
||||
"Type": 1,
|
||||
"DisplayType": 2
|
||||
},
|
||||
{
|
||||
"Name": "System Uploader Id",
|
||||
"Value": "",
|
||||
"Description": "User ID to attribute scanner-created assets to. Leave empty for no default owner.",
|
||||
"Type": 0,
|
||||
"DisplayType": 9
|
||||
}
|
||||
]
|
||||
+12
-5
@@ -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
|
||||
@@ -8,16 +11,20 @@ EXPOSE 5162
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
ARG SixLaborsLicenseKey
|
||||
|
||||
WORKDIR "/src"
|
||||
COPY ["Lactose/Lactose.csproj", "Lactose/"]
|
||||
RUN dotnet restore "Lactose/Lactose.csproj"
|
||||
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
||||
dotnet restore "Lactose/Lactose.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/Lactose"
|
||||
RUN dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
||||
dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
||||
dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
|
||||
@@ -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}"
|
||||
};
|
||||
}
|
||||
+248
-290
@@ -1,400 +1,336 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
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 System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
|
||||
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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+244
-126
@@ -1,185 +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;
|
||||
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="jobManager">The job manager.</param>
|
||||
public FileSystemCrawlJob(
|
||||
Guid folderId,
|
||||
string workingPath,
|
||||
ILogger<FileSystemCrawlJob> logger,
|
||||
IAssetRepository assetRepository,
|
||||
JobManager jobManager
|
||||
) {
|
||||
JobStatus = new(this);
|
||||
this.logger = logger;
|
||||
this.assetRepository = assetRepository;
|
||||
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;
|
||||
Guid? uploaderId = null;
|
||||
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;
|
||||
assetRepository.Update(existing);
|
||||
assetRepository.Save();
|
||||
logger.LogInformation("Updated asset {Asset} — file modification detected.", 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);
|
||||
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.");
|
||||
}
|
||||
|
||||
Asset? AssetFromPath(string filePath) {
|
||||
/// <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,
|
||||
IsPubliclyShared = false,
|
||||
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()
|
||||
@@ -187,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();
|
||||
|
||||
+18
-1
@@ -1,5 +1,4 @@
|
||||
using Butter.Types;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
@@ -147,4 +146,22 @@ public abstract class Job {
|
||||
/// Raised when the job is done, regardless of the final status (Completed, Canceled, or Failed).
|
||||
/// </summary>
|
||||
public virtual event EventHandler<JobStatus>? Done;
|
||||
|
||||
/// <summary>
|
||||
/// Raises the StartedWaiting event.
|
||||
/// </summary>
|
||||
/// <param name="e">The job status.</param>
|
||||
protected virtual void OnStartedWaiting(JobStatus e) { StartedWaiting?.Invoke(this, e); }
|
||||
|
||||
/// <summary>
|
||||
/// Raises the FinishedWaiting event.
|
||||
/// </summary>
|
||||
/// <param name="e">The job status.</param>
|
||||
protected virtual void OnFinishedWaiting(JobStatus e) { FinishedWaiting?.Invoke(this, e); }
|
||||
|
||||
/// <summary>
|
||||
/// Raises the ProgressChanged event.
|
||||
/// </summary>
|
||||
/// <param name="e">The job status.</param>
|
||||
protected virtual void OnProgressChanged(JobStatus e) { ProgressChanged?.Invoke(this, e); }
|
||||
}
|
||||
|
||||
@@ -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
-12
@@ -3,7 +3,6 @@ using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Mapper;
|
||||
using Lactose.Repositories;
|
||||
using Lactose.Utils;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
@@ -36,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);
|
||||
}
|
||||
@@ -50,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
|
||||
@@ -72,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);
|
||||
@@ -179,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.");
|
||||
@@ -198,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);
|
||||
@@ -228,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();
|
||||
|
||||
@@ -248,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);
|
||||
}
|
||||
@@ -267,6 +267,6 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
);
|
||||
|
||||
lifetime.ApplicationStarted.Register(() => service.Start());
|
||||
return service;
|
||||
await service;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using SixLabors.ImageSharp;
|
||||
@@ -56,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;
|
||||
@@ -85,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;
|
||||
}
|
||||
@@ -96,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);
|
||||
@@ -124,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) {
|
||||
@@ -140,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;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using CoenM.ImageHash.HashAlgorithms;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using SixLabors.ImageSharp;
|
||||
using CoenM.ImageHash.HashAlgorithms;
|
||||
using Lactose.Utils;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using System.Collections;
|
||||
|
||||
@@ -59,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;
|
||||
@@ -94,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);
|
||||
@@ -134,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) {
|
||||
@@ -150,7 +153,7 @@ public sealed class PHashJob : Job {
|
||||
int pending = 0;
|
||||
|
||||
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
||||
var batch = AssetRepository.GetAssetsMissingPHash(batchSize, offset);
|
||||
var batch = await AssetRepository.GetAssetsMissingPHashAsync(batchSize, offset, token);
|
||||
var batchList = batch.ToList();
|
||||
|
||||
if (batchList.Count == 0) continue;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
static class PathUtils {
|
||||
internal static string PathFromGuid(Guid id, string root, string extension = "webp") {
|
||||
var s = id.ToString("N");
|
||||
|
||||
return Path.Combine(
|
||||
root,
|
||||
s[..2],
|
||||
s.Substring(2, 2),
|
||||
s.Substring(4, 2),
|
||||
$"{s}.{extension}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
-24
@@ -1,6 +1,4 @@
|
||||
using Butter.Dtos.Settings;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using SixLabors.ImageSharp;
|
||||
@@ -84,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;
|
||||
@@ -121,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;
|
||||
}
|
||||
@@ -142,7 +141,7 @@ public class PreviewJob : Job {
|
||||
)
|
||||
);
|
||||
|
||||
var path = PathFromGuid(asset.Id, PreviewPath!);
|
||||
var path = PathUtils.PathFromGuid(asset.Id, PreviewPath!);
|
||||
|
||||
if (!Directory.Exists(Path.GetDirectoryName(path)))
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
@@ -161,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);
|
||||
@@ -189,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.");
|
||||
@@ -216,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) {
|
||||
@@ -228,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;
|
||||
@@ -281,16 +281,4 @@ public class PreviewJob : Job {
|
||||
else
|
||||
JobStatus.Complete("All previews generated successfully.");
|
||||
}
|
||||
|
||||
static string PathFromGuid(Guid id, string root) {
|
||||
var s = id.ToString("N");
|
||||
|
||||
return Path.Combine(
|
||||
root,
|
||||
s[..2],
|
||||
s.Substring(2, 2),
|
||||
s.Substring(4, 2),
|
||||
$"{s}.webp"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Butter.Dtos.Settings;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using SixLabors.ImageSharp;
|
||||
@@ -84,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;
|
||||
@@ -121,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;
|
||||
}
|
||||
@@ -142,7 +141,7 @@ public class ThumbnailJob : Job {
|
||||
)
|
||||
);
|
||||
|
||||
var path = PathFromGuid(asset.Id, ThumbnailPath!);
|
||||
var path = PathUtils.PathFromGuid(asset.Id, ThumbnailPath!);
|
||||
|
||||
if (!Directory.Exists(Path.GetDirectoryName(path)))
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
@@ -161,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);
|
||||
@@ -189,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.");
|
||||
@@ -216,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) {
|
||||
@@ -228,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;
|
||||
@@ -281,16 +281,4 @@ public class ThumbnailJob : Job {
|
||||
else
|
||||
JobStatus.Complete("All thumbnails generated successfully.");
|
||||
}
|
||||
|
||||
static string PathFromGuid(Guid id, string root) {
|
||||
var s = id.ToString("N");
|
||||
|
||||
return Path.Combine(
|
||||
root,
|
||||
s[..2],
|
||||
s.Substring(2, 2),
|
||||
s.Substring(4, 2),
|
||||
$"{s}.webp"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,19 +14,17 @@
|
||||
<PackageReference Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.15">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.7.5" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Mapper;
|
||||
@@ -8,44 +9,60 @@ namespace Lactose.Mapper;
|
||||
/// </summary>
|
||||
public static class AlbumMapper {
|
||||
/// <summary>
|
||||
/// Maps an Album object to an AlbumFullDto object.
|
||||
/// </summary>
|
||||
/// <param name="album">The album to map.</param>
|
||||
/// <returns>An AlbumFullDto object.</returns>
|
||||
public static AlbumFullDto ToAlbumFullDto(this Album album) => new AlbumFullDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Owner = album.UserOwnerId,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = album.Assets?.Count ?? 0,
|
||||
Images = album.Assets!.Select(asset => asset.Id).ToList(),
|
||||
AssetPreviews = album.Assets!.Select(asset => new AlbumAssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||
FileName = asset.OriginalFilename
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps an Album object to an AlbumPreviewDto object.
|
||||
/// Maps an Album object to an AlbumPreviewDto with visibility-aware fields.
|
||||
/// </summary>
|
||||
/// <param name="album">The album to map.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>An AlbumPreviewDto object.</returns>
|
||||
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album) => new AlbumPreviewDto {
|
||||
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album, EAccessLevel accessLevel, Guid? viewerId = null) => new AlbumPreviewDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Owner = album.UserOwnerId,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = album.Assets?.Count ?? 0
|
||||
CoverWidth = album.CoverAsset?.ResolutionWidth,
|
||||
CoverHeight = album.CoverAsset?.ResolutionHeight,
|
||||
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||
DeletedAt = null
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Maps an Album object to an AlbumFullDto object.
|
||||
/// Uses pre-projected <see cref="Album.VisibleAssetPreviews"/> when available; otherwise falls back to mapping loaded assets.
|
||||
/// </summary>
|
||||
/// <param name="album">The album to map.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>An AlbumFullDto object.</returns>
|
||||
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel, Guid? viewerId) {
|
||||
var previews = album.VisibleAssetPreviews
|
||||
?? album.Assets?.Select(asset => new AlbumAssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||
MimeType = string.IsNullOrEmpty(asset.ConvertedMimeType) ? asset.MimeType : asset.ConvertedMimeType,
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
|
||||
}).ToList()
|
||||
?? [];
|
||||
|
||||
return new AlbumFullDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = previews.Count(p => p.DeletedAt == null),
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||
DeletedAt = null,
|
||||
Images = previews.Select(preview => preview.Id).ToList(),
|
||||
AssetPreviews = previews
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos.Asset;
|
||||
using Butter.Dtos.User;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
@@ -9,41 +10,45 @@ namespace Lactose.Mapper;
|
||||
/// </summary>
|
||||
public static class AssetsMapper {
|
||||
/// <summary>
|
||||
/// Maps an Asset object to a GetFullAssetDto object.
|
||||
/// Maps an Asset object to a AssetDto object.
|
||||
/// </summary>
|
||||
/// <param name="asset">The asset to map.</param>
|
||||
/// <param name="accessLevel">The access level of the user.</param>
|
||||
/// <returns>A GetFullAssetDto object.</returns>
|
||||
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) => new AssetDto {
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>A AssetDto object.</returns>
|
||||
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetDto {
|
||||
Id = asset.Id,
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
AssetType = asset.Type,
|
||||
IsPublic = asset.IsPubliclyShared,
|
||||
CreatedAt = asset.CreatedAt,
|
||||
UpdatedAt = asset.UpdatedAt,
|
||||
DeletedAt = asset.DeletedAt,
|
||||
Owner = asset.Owner!.ToGetUsersDto(),
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null,
|
||||
UploadedBy = asset.Uploader?.ToGetUsersDto(accessLevel, viewerId)
|
||||
?? new UserInfoDto { Id = Guid.Empty, Username = "deleted", AccessLevel = EAccessLevel.User },
|
||||
MimeType = asset.MimeType,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
FileSize = asset.FileSize,
|
||||
Duration = asset.Duration ?? 0,
|
||||
FrameRate = asset.FrameRate ?? 0
|
||||
FrameRate = asset.FrameRate ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps an Asset object to a GetAssetPreviewDto object.
|
||||
/// Maps an Asset object to a AssetPreviewDto object.
|
||||
/// </summary>
|
||||
/// <param name="asset">The asset to map.</param>
|
||||
/// <returns>A GetAssetPreviewDto object.</returns>
|
||||
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset) => new AssetPreviewDto {
|
||||
/// <param name="accessLevel">The access level of the user.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <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),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||
FileName = asset.OriginalFilename,
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
AlbumNames = asset.Albums?.Select(a => a.Title).Distinct().ToList(),
|
||||
AlbumIds = asset.Albums?.Select(a => a.Id).Distinct().ToList(),
|
||||
CosplayerNames = asset.Albums?
|
||||
@@ -55,14 +60,21 @@ public static class AssetsMapper {
|
||||
.Where(a => a.PersonOwner != null)
|
||||
.Select(a => a.PersonOwner!.Id)
|
||||
.Distinct()
|
||||
.ToList()
|
||||
.ToList(),
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : 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>
|
||||
/// Maps a collection of Asset objects to a collection of GetAssetPreviewDto objects.
|
||||
/// Maps a collection of Asset objects to a collection of AssetPreviewDto objects.
|
||||
/// </summary>
|
||||
/// <param name="assets">The collection of assets to map.</param>
|
||||
/// <returns>A collection of GetAssetPreviewDto objects.</returns>
|
||||
public static IEnumerable<AssetPreviewDto> ToAssetPreviewDto(this IEnumerable<Asset> assets)
|
||||
=> assets.Select(ToAssetPreviewDto);
|
||||
/// <param name="accessLevel">The access level of the user.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>A collection of AssetPreviewDto objects.</returns>
|
||||
public static IEnumerable<AssetPreviewDto> ToAssetPreviewDto(this IEnumerable<Asset> assets, EAccessLevel accessLevel, Guid? viewerId)
|
||||
=> assets.Select(a => a.ToAssetPreviewDto(accessLevel, viewerId));
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Dtos.Person;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Mapper;
|
||||
@@ -12,9 +13,12 @@ public static class PersonMapper {
|
||||
/// Maps a Person to a PersonDetailedDto with album previews and stats.
|
||||
/// </summary>
|
||||
/// <param name="person">The person to map.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>A PersonDetailedDto.</returns>
|
||||
public static PersonDetailedDto ToPersonDetailedDto(this Person person) {
|
||||
public static PersonDetailedDto ToPersonDetailedDto(this Person person, EAccessLevel accessLevel, Guid? viewerId = null) {
|
||||
var albums = person.Albums ?? [];
|
||||
var maintainers = person.Maintainers ?? [];
|
||||
|
||||
return new PersonDetailedDto {
|
||||
Id = person.Id,
|
||||
@@ -23,9 +27,16 @@ public static class PersonMapper {
|
||||
ProfileCropX = person.ProfileCropX,
|
||||
ProfileCropY = person.ProfileCropY,
|
||||
ProfileCropZoom = person.ProfileCropZoom,
|
||||
TotalAlbums = albums.Count,
|
||||
TotalAssets = albums.Sum(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0),
|
||||
Albums = albums.Select(ToAlbumPreviewDto).ToList()
|
||||
TotalAlbums = person.AlbumTotalCount,
|
||||
TotalAssets = person.TotalAssetCount,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null,
|
||||
Albums = albums.Select(a => {
|
||||
var dto = a.ToAlbumPreviewDto(accessLevel, viewerId);
|
||||
dto.AssetCount = person.AlbumAssetCounts?.GetValueOrDefault(a.Id, 0) ?? 0;
|
||||
return dto;
|
||||
}).ToList(),
|
||||
MaintainerUserIds = maintainers.Select(u => u.Id).ToList(),
|
||||
MaintainerUsernames = maintainers.Select(u => u.Username).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,24 +44,16 @@ public static class PersonMapper {
|
||||
/// Maps a Person to a PersonPreviewDto.
|
||||
/// </summary>
|
||||
/// <param name="person">The person to map.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>A PersonPreviewDto.</returns>
|
||||
public static PersonPreviewDto ToPersonPreviewDto(this Person person) => new() {
|
||||
public static PersonPreviewDto ToPersonPreviewDto(this Person person, EAccessLevel accessLevel) => new() {
|
||||
Id = person.Id,
|
||||
Name = person.Name,
|
||||
ProfileAssetId = person.ProfileAssetId,
|
||||
ProfileCropX = person.ProfileCropX,
|
||||
ProfileCropY = person.ProfileCropY,
|
||||
ProfileCropZoom = person.ProfileCropZoom,
|
||||
TotalAlbums = person.Albums?.Count ?? 0
|
||||
};
|
||||
|
||||
private static AlbumPreviewDto ToAlbumPreviewDto(Album album) => new() {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Owner = album.UserOwnerId,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = album.Assets?.Count(asset => asset.DeletedAt == null) ?? 0
|
||||
TotalAlbums = person.Albums?.Count ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos.User;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Mapper;
|
||||
@@ -8,22 +9,33 @@ namespace Lactose.Mapper;
|
||||
/// </summary>
|
||||
public static class UsersMapper {
|
||||
/// <summary>
|
||||
/// Maps a User object to a UserInfoDto object.
|
||||
/// Maps a User object to a UserInfoDto object, filtering sensitive fields based on the viewer's access.
|
||||
/// Sensitive fields (Email, Ban/Delete status, AccessLevel, MaintainedPersons) are only populated
|
||||
/// when the viewer is an admin or viewing their own profile.
|
||||
/// </summary>
|
||||
/// <param name="user">The User object to map.</param>
|
||||
/// <param name="viewerAccessLevel">The access level of the requesting user.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user (null if anonymous).</param>
|
||||
/// <returns>A UserInfoDto object containing the mapped data.</returns>
|
||||
public static UserInfoDto ToGetUsersDto(this User user) {
|
||||
public static UserInfoDto ToGetUsersDto(this User user, EAccessLevel viewerAccessLevel, Guid? viewerId) {
|
||||
bool isAdmin = viewerAccessLevel >= EAccessLevel.Admin;
|
||||
bool isSelf = viewerId == user.Id;
|
||||
bool showSensitive = isAdmin || isSelf;
|
||||
|
||||
return new UserInfoDto {
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
Username = user.Username,
|
||||
CreatedAt = user.CreatedAt,
|
||||
IsBanned = user.BannedAt != null,
|
||||
BannedAt = user.BannedAt,
|
||||
LastLogin = user.LastLogin,
|
||||
UpdatedAt = user.UpdatedAt,
|
||||
DeletedAt = user.DeletedAt,
|
||||
AccessLevel = user.AccessLevel,
|
||||
Id = user.Id,
|
||||
Username = user.Username,
|
||||
Email = showSensitive ? user.Email : null,
|
||||
CreatedAt = user.CreatedAt,
|
||||
UpdatedAt = showSensitive ? user.UpdatedAt : null,
|
||||
LastLogin = showSensitive ? user.LastLogin : null,
|
||||
BannedAt = showSensitive ? user.BannedAt : null,
|
||||
DeletedAt = isAdmin ? user.DeletedAt : null,
|
||||
IsBanned = showSensitive && user.BannedAt != null,
|
||||
AccessLevel = showSensitive ? user.AccessLevel : EAccessLevel.User,
|
||||
MaintainedPersonIds = showSensitive ? user.MaintainedPersons?.Select(p => p.Id).ToList() : null,
|
||||
MaintainedPersonNames = showSensitive ? user.MaintainedPersons?.Select(p => p.Name).ToList() : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user