Compare commits
802
Commits
64a0fdf81d
...
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 | ||
|
|
41c262d157 | ||
|
|
837491fa4a | ||
|
|
5a2f61d261 | ||
|
|
851cdfa924 | ||
|
|
8dd5e7158f | ||
|
|
9e52cc2351 | ||
|
|
ca8119f356 | ||
|
|
bef29f5b22 | ||
|
|
e27d63cf32 | ||
|
|
fd46faec21 | ||
|
|
648531fcc4 | ||
|
|
dda062d959 | ||
|
|
02c62d0dd8 | ||
|
|
2249e0fb2e | ||
|
|
f626228c2f | ||
|
|
b9fd5e172c | ||
|
|
3b05061a31 | ||
|
|
7ea72e13ac | ||
|
|
faa8e93d55 | ||
|
|
c5ba90202d | ||
|
|
5fba560bec | ||
|
|
f2059e0dd7 | ||
|
|
e02466771a | ||
|
|
0b8c435f7c | ||
|
|
fc50aec996 | ||
|
|
bc72243c3e | ||
|
|
c723a1f265 | ||
|
|
e772e6d2b9 | ||
|
|
48ed84b7bd | ||
|
|
9cfab29e6c | ||
|
|
c177afe078 | ||
|
|
69cdc30ae9 | ||
|
|
0d33be8637 | ||
|
|
9f709e5a2e | ||
|
|
12cb8aa15b | ||
|
|
c96488b1eb | ||
|
|
51bb14862d | ||
|
|
4cffc9b424 | ||
|
|
4ea0afe9f6 | ||
|
|
de049d9d33 | ||
|
|
26a59b4ac2 | ||
|
|
6a3daa397b | ||
|
|
7f0c51edc4 | ||
|
|
46a68d9dd4 | ||
|
|
ff76673ee1 | ||
|
|
b0b1856c01 | ||
|
|
9e40add0d5 | ||
|
|
2886c0d629 | ||
|
|
ed2ce83b58 | ||
|
|
334451238b | ||
|
|
48ebbebee1 | ||
|
|
bd924f9e23 | ||
|
|
3633b418ea | ||
|
|
46382e2aed | ||
|
|
be7dc9bb91 | ||
|
|
d010b51687 | ||
|
|
07c0a15369 | ||
|
|
6e091299e4 | ||
|
|
74882904a0 | ||
|
|
7639c6909a | ||
|
|
b58066d563 | ||
|
|
14df8f1e8d | ||
|
|
188e4568da | ||
|
|
5006d81d00 | ||
|
|
97e5404069 | ||
|
|
aa9aca8f6f | ||
|
|
dddc4baa5d | ||
|
|
3d2c14ef41 | ||
|
|
628cfea773 | ||
|
|
d2d1fc7e50 | ||
|
|
27f89f3820 | ||
|
|
ecac0cdc2e | ||
|
|
5fd338906c | ||
|
|
88476e0409 | ||
|
|
7848923445 | ||
|
|
3b51a35e2c | ||
|
|
7686f04023 | ||
|
|
31d917c56e | ||
|
|
bc71887726 | ||
|
|
89c91b2291 | ||
|
|
4a6bc2f8aa | ||
|
|
ac5b75c0d6 | ||
|
|
7e1a2a7ed6 | ||
|
|
e98aa6167e | ||
|
|
17ddd1c332 | ||
|
|
b0b65e808d | ||
|
|
0a99178416 | ||
|
|
91cb420743 | ||
|
|
f4a62eafc2 | ||
|
|
dc5ac98c68 | ||
|
|
b741f331e9 | ||
|
|
04a528ce55 | ||
|
|
ac7833a069 | ||
|
|
27eec196a0 | ||
|
|
c6d8d2c746 | ||
|
|
71ac41086c | ||
|
|
da3a2a0a4b | ||
|
|
0203644b3e | ||
|
|
dd43d069d5 | ||
|
|
7dc5eff14b | ||
|
|
314ac766e4 | ||
|
|
7456850dec | ||
|
|
30de7d0784 | ||
|
|
3a892f6f7a | ||
|
|
b8be4f7314 | ||
|
|
5514bf89d9 | ||
|
|
326966b774 | ||
|
|
8ad378b649 | ||
|
|
41f977d262 | ||
|
|
5b04f39bfe | ||
|
|
71dba46565 | ||
|
|
c16749bdf6 | ||
|
|
1d107d2de6 | ||
|
|
5b445cbe89 | ||
|
|
3482f5adde | ||
|
|
3dd73d3a63 | ||
|
|
3b4a06709a | ||
|
|
5ffb114532 | ||
|
|
590aef2ece | ||
|
|
e3a2a78b21 | ||
|
|
d0bf3b7ec7 | ||
|
|
2af3995ae7 | ||
|
|
02bdba3891 | ||
|
|
8ba1c9b6b2 | ||
|
|
e188cc35e3 | ||
|
|
75cbe2717f | ||
|
|
4c63e80b6c | ||
|
|
354198c413 | ||
|
|
56c3f9c8e1 | ||
|
|
b59c3b68ec | ||
|
|
47eef8aad0 | ||
|
|
d4e0af2c29 | ||
|
|
9e4b0ecb27 | ||
|
|
64220d0818 | ||
|
|
55af7dccec | ||
|
|
3bddd4385d | ||
|
|
d8927d90c2 | ||
|
|
afad073d0c | ||
|
|
15b3fcedf1 | ||
|
|
3f56f9afdc | ||
|
|
c541c45e9b | ||
|
|
459079d708 | ||
|
|
220581579b | ||
|
|
254e98b37b | ||
|
|
652143d738 | ||
|
|
5a74edd29f | ||
|
|
7f447077b0 | ||
|
|
b813c32756 | ||
|
|
cdd2e9a1ef | ||
|
|
64bee6c92a | ||
|
|
870ebb06f8 | ||
|
|
2f7cd3d7da | ||
|
|
18ddcf681f | ||
|
|
ede759b50b | ||
|
|
9f74bc91a9 | ||
|
|
8922dbcc57 | ||
|
|
095a2bdce0 | ||
|
|
b807e4b636 | ||
|
|
9c17843dec | ||
|
|
c1aafc4223 | ||
|
|
a8d06959b5 | ||
|
|
dbbb25da32 | ||
|
|
9f180742cd | ||
|
|
e8c1a80264 | ||
|
|
7e16c77cb6 | ||
|
|
8f6a4396db | ||
|
|
34fbbc91a0 | ||
|
|
9be49ac9c6 | ||
|
|
c96b22fe62 | ||
|
|
7f50d3c3e2 | ||
|
|
191cba4946 | ||
|
|
a38f627ca4 | ||
|
|
f31b753182 | ||
|
|
1377d02520 | ||
|
|
bac1ad0afc | ||
|
|
fa434b180d | ||
|
|
4d169e6303 | ||
|
|
e1d7822b7c | ||
|
|
cc611828b9 | ||
|
|
9aead51481 | ||
|
|
67d00efb10 | ||
|
|
b71fef147a | ||
|
|
8004dc2b43 | ||
|
|
c9f08ea48e | ||
|
|
52b88a3ba4 | ||
|
|
e39d753a85 | ||
|
|
83025065b3 | ||
|
|
0c65e9de1b | ||
|
|
1559bf081e | ||
|
|
79305646dd | ||
|
|
b9ca9f7b45 | ||
|
|
5aed999671 | ||
|
|
56b80038a3 | ||
|
|
3d3c59d58b | ||
|
|
76bbdbd2f4 | ||
|
|
22b15c387b | ||
|
|
bcfb838b5e | ||
|
|
63a16392d7 | ||
|
|
d3ef5ff4c1 | ||
|
|
f45ef7c8b2 | ||
|
|
8228cc2b1a | ||
|
|
6b9234dfac | ||
|
|
f150f6d863 | ||
|
|
7521f15b63 | ||
|
|
88eb22d454 | ||
|
|
c1c6a4cead | ||
|
|
845751fca1 | ||
|
|
2dc0153b10 | ||
|
|
7143af14f0 | ||
|
|
51a3d0d19b | ||
|
|
53b1d0fbf3 | ||
|
|
20fd6a930b | ||
|
|
8f0af8d544 | ||
|
|
16161ebfb8 | ||
|
|
ca224feafb | ||
|
|
0909842c37 | ||
|
|
9c785423a9 | ||
|
|
d1f545bb59 | ||
|
|
584e24d751 | ||
|
|
ea25eca9fa | ||
|
|
b8f0eea848 | ||
|
|
b417f37780 | ||
|
|
f52b684722 | ||
|
|
5c15f89f59 | ||
|
|
f2e2997988 | ||
|
|
1589c7fe29 | ||
|
|
5e23180c7c | ||
|
|
f8eafed093 | ||
|
|
debd0dd4af | ||
|
|
04d425b88d | ||
|
|
ca21dc3dd9 | ||
|
|
f4d4b6a75a | ||
|
|
4ff3b24df8 | ||
|
|
1da7236d5d | ||
|
|
df14b8b227 | ||
|
|
15508013a3 | ||
|
|
cb47336230 | ||
|
|
76cabe058b | ||
|
|
33175ffdcb | ||
|
|
54cf5eff2b | ||
|
|
94bba45f45 | ||
|
|
23ef7e3ded | ||
|
|
a84d93882d | ||
|
|
0ec6888809 | ||
|
|
977d2325fa | ||
|
|
20975a2872 | ||
|
|
61cf886174 | ||
|
|
bf638ca995 | ||
|
|
a599648d87 | ||
|
|
e5fc1a017e | ||
|
|
44394daebf | ||
|
|
ac76aab168 | ||
|
|
4f56acb320 | ||
|
|
70fe586fc6 | ||
|
|
58bd4ee766 | ||
|
|
3d789d68e7 | ||
|
|
9327a739f8 | ||
|
|
de731095cc | ||
|
|
696ea613de | ||
|
|
9b17b6b231 | ||
|
|
b1d5356220 | ||
|
|
37dad0d1d7 | ||
|
|
c5e41f2c05 | ||
|
|
d45a915ba9 | ||
|
|
b7d1912fc6 | ||
|
|
d51efe8b3c | ||
|
|
48a97beb74 | ||
|
|
e94406932b | ||
|
|
4fe1fb0da2 | ||
|
|
dcc8eba931 | ||
|
|
4c90e553af | ||
|
|
b8f75f9529 | ||
|
|
d10e062373 | ||
|
|
3c1d2ef14b | ||
|
|
b58c8ebce8 | ||
|
|
d602d2d34b | ||
|
|
de938e6d1e | ||
|
|
949dec9bdc | ||
|
|
89c4bfe13f | ||
|
|
7a0b72584c | ||
|
|
ebfab19df4 | ||
|
|
77630f4e83 | ||
|
|
16619f9631 | ||
|
|
9829e02a38 | ||
|
|
632ca0d3e4 | ||
|
|
4d436620a5 | ||
|
|
78da2b7207 | ||
|
|
d4a8b272f2 | ||
|
|
bb01f6526e | ||
|
|
866d01c4e2 | ||
|
|
2159eaec08 | ||
|
|
bf3e5e619b | ||
|
|
458cf70081 | ||
|
|
3d81b7a97e | ||
|
|
03f52cfb5b | ||
|
|
4b0a6df5dd | ||
|
|
744bc9e4d0 | ||
|
|
76ba4fd74a | ||
|
|
132e3d2930 | ||
|
|
7554dbac9c | ||
|
|
defd97e7f0 | ||
|
|
6c9a608ce6 | ||
|
|
dc1e44f2be | ||
|
|
923aaa6865 | ||
|
|
53fe2cc3d5 | ||
|
|
970f99db54 | ||
|
|
96f431fb41 | ||
|
|
604f0e33a0 | ||
|
|
6d75843dcd | ||
|
|
0cb5d097d6 | ||
|
|
0549be6d42 | ||
|
|
5cd16a7416 | ||
|
|
5573be1502 | ||
|
|
99ef2db822 | ||
|
|
0502cbbd76 | ||
|
|
6d29f053f4 | ||
|
|
75c667d1c2 | ||
|
|
740941790b | ||
|
|
5511bc56f2 | ||
|
|
d90a9421e9 | ||
|
|
ba1071ba6c | ||
|
|
29b021bee5 | ||
|
|
07fcd05031 |
+2
-1
@@ -22,4 +22,5 @@
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
README.md
|
||||
/storageImages
|
||||
@@ -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
-1
@@ -4,4 +4,11 @@ obj/
|
||||
riderModule.iml
|
||||
/_ReSharper.Caches/
|
||||
.idea/.idea.MilkyShots/Docker/docker-compose.generated.override.yml
|
||||
storageImages/
|
||||
storageImages/
|
||||
dotnet-tools.json
|
||||
.opencode/
|
||||
*.css.map
|
||||
# SixLabors license file
|
||||
/Lactose/sixlabors.lic
|
||||
.desloppify/
|
||||
.claude/
|
||||
-384
@@ -1,384 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AnalysisUIOptions">
|
||||
<option name="SCOPE_TYPE" value="3" />
|
||||
</component>
|
||||
<component name="AutoGeneratedRunConfigurationManager">
|
||||
<projectFile profileName="IIS Express">BlazorTest/BlazorTest.csproj</projectFile>
|
||||
<projectFile profileName="http">BlazorTest/BlazorTest.csproj</projectFile>
|
||||
<projectFile profileName="https">BlazorTest/BlazorTest.csproj</projectFile>
|
||||
<projectFile profileName="IIS Express">WepApiTest/WepApiTest.csproj</projectFile>
|
||||
<projectFile profileName="http">WepApiTest/WepApiTest.csproj</projectFile>
|
||||
<projectFile profileName="https">WepApiTest/WepApiTest.csproj</projectFile>
|
||||
</component>
|
||||
<component name="AutoImportSettings">
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="8146fea4-834d-47b3-9aaa-32befb744c64" name="Changes" comment="" />
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||
</component>
|
||||
<component name="DatabaseLocalColorSettings">
|
||||
<colors>
|
||||
<entry key="eab007eb-4f50-4961-9bce-5c4940a7cdff" value="Blue" />
|
||||
</colors>
|
||||
</component>
|
||||
<component name="DpaMonitoringSettings">
|
||||
<option name="autoShow" value="false" />
|
||||
</component>
|
||||
<component name="FileTemplateManagerImpl">
|
||||
<option name="RECENT_TEMPLATES">
|
||||
<list>
|
||||
<option value="HTTP Public Environment File" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="HighlightingSettingsPerFile">
|
||||
<setting file="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/post-requests.http" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/requests-with-authorization.http" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/1013dc174d8e4a313727d545767084e3df6affa8d4723b41a5f7881e96b1d/ControllerContext.cs" root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/36f346b6c0454bc8a6afa7aed38119fe5bbffcc983298d9bfa4dbd5f49461f/Monitor.CoreCLR.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/3bd4df5aff92cabbc4d630be64227073db1b8539b3a1e47786b4b189d7cdb7/DbContext.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/449b441523c469ed34ff5a5e14f0bafcd8f097aa463655303dc19048fa44ac3/EntityFrameworkServiceCollectionExtensions.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/7b3b6985aad11c5d633d75b545f5f5f4fc7e50918ad1cb9f6b615682f5bfb76d/SingleQueryingEnumerable.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/96a4cc66aa48dda8a9d8be8febec4a769fc143f7a86dd53ecdebfb9c3177d/ControllerBase.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/9e6ecf3790a24c43487ae724bf9ea8185bdedaf6c28da376d6ea862e66453/WebApplicationBuilder.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/a42ba4db448ed38dcb9fb39194c637a317a8281856b88e139e8a905ee6d12b/Guid.Windows.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/bd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182/ExceptionDispatchInfo.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$PROJECT_DIR$/WebApiTest/GlobalUsings.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
<setting file="file://$PROJECT_DIR$/WebApiTest/Program.cs" root0="FORCE_HIGHLIGHTING" />
|
||||
</component>
|
||||
<component name="HttpClientSelectedEnvironments">
|
||||
<file url="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/post-requests.http" environment="test" />
|
||||
<file url="file://$USER_HOME$/AppData/Local/Temp/whats-new-dir/whats-new.http" environment="test" />
|
||||
<file url="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/requests-with-authorization.http" environment="test" />
|
||||
</component>
|
||||
<component name="KubernetesApiPersistence">{}</component>
|
||||
<component name="KubernetesApiProvider">{
|
||||
"isMigrated": true
|
||||
}</component>
|
||||
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
|
||||
<component name="ProblemsViewState">
|
||||
<option name="selectedTabId" value="CurrentFile" />
|
||||
</component>
|
||||
<component name="ProjectColorInfo">{
|
||||
"associatedIndex": 4
|
||||
}</component>
|
||||
<component name="ProjectId" id="2mIMBmlQ4hFJsyahkv7dCj0zoOT" />
|
||||
<component name="ProjectLevelVcsManager" settingsEditedManually="true">
|
||||
<ConfirmationsSetting value="2" id="Add" />
|
||||
</component>
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
".NET Launch Settings Profile.BlazorTest: http.executor": "Debug",
|
||||
".NET Launch Settings Profile.BlazorTest: https.executor": "Debug",
|
||||
".NET Launch Settings Profile.WepApiTest: http.executor": "Debug",
|
||||
"Docker.MariaDbDev.executor": "Run",
|
||||
"Docker.WebApiDocker.executor": "Run",
|
||||
"Docker.WebApiTest/Dockerfile.executor": "Debug",
|
||||
"HTTP Request.WepApiTest | #1 (1).executor": "Run",
|
||||
"HTTP Request.WepApiTest | #1.executor": "Run",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"git-widget-placeholder": "master",
|
||||
"ignore.virus.scanning.warn.message": "true",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "preferences.lookFeel",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
},
|
||||
"keyToStringList": {
|
||||
"DatabaseDriversLRU": [
|
||||
"mariadb"
|
||||
]
|
||||
}
|
||||
}</component>
|
||||
<component name="RunManager" selected=".NET Launch Settings Profile.WepApiTest: http">
|
||||
<configuration name="WepApiTest | #1 (1)" type="HttpClient.HttpRequestRunConfigurationType" factoryName="HTTP Request" temporary="true" nameIsGenerated="true" path="$PROJECT_DIR$/WebApiTest/WepApiTest.http" requestIdentifier="#1" runType="Run single request">
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="BlazorTest: IIS Express" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BlazorTest/BlazorTest.csproj" />
|
||||
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
|
||||
<option name="LAUNCH_PROFILE_NAME" value="IIS Express" />
|
||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||
<option name="USE_MONO" value="0" />
|
||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
||||
<method v="2">
|
||||
<option name="Build" />
|
||||
</method>
|
||||
</configuration>
|
||||
<configuration name="BlazorTest: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BlazorTest/BlazorTest.csproj" />
|
||||
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
|
||||
<option name="LAUNCH_PROFILE_NAME" value="http" />
|
||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||
<option name="USE_MONO" value="0" />
|
||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
||||
<method v="2">
|
||||
<option name="Build" />
|
||||
</method>
|
||||
</configuration>
|
||||
<configuration name="BlazorTest: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BlazorTest/BlazorTest.csproj" />
|
||||
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
|
||||
<option name="LAUNCH_PROFILE_NAME" value="https" />
|
||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||
<option name="USE_MONO" value="0" />
|
||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
||||
<method v="2">
|
||||
<option name="Build" />
|
||||
</method>
|
||||
</configuration>
|
||||
<configuration name="WepApiTest: IIS Express" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
|
||||
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
|
||||
<option name="LAUNCH_PROFILE_NAME" value="IIS Express" />
|
||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||
<option name="USE_MONO" value="0" />
|
||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
||||
<method v="2">
|
||||
<option name="Build" />
|
||||
</method>
|
||||
</configuration>
|
||||
<configuration name="WepApiTest: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
|
||||
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
|
||||
<option name="LAUNCH_PROFILE_NAME" value="http" />
|
||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||
<option name="USE_MONO" value="0" />
|
||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
||||
<method v="2">
|
||||
<option name="Build" />
|
||||
</method>
|
||||
</configuration>
|
||||
<configuration name="WepApiTest: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
|
||||
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
|
||||
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
|
||||
<option name="LAUNCH_PROFILE_NAME" value="https" />
|
||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||
<option name="USE_MONO" value="0" />
|
||||
<option name="RUNTIME_ARGUMENTS" value="" />
|
||||
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
|
||||
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
|
||||
<option name="SEND_DEBUG_REQUEST" value="1" />
|
||||
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
|
||||
<method v="2">
|
||||
<option name="Build" />
|
||||
</method>
|
||||
</configuration>
|
||||
<configuration name="BlazorTest/Dockerfile" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
|
||||
<deployment type="dockerfile">
|
||||
<settings>
|
||||
<option name="imageTag" value="blazortest" />
|
||||
<option name="containerName" value="blazortest" />
|
||||
<option name="contextFolderPath" value="." />
|
||||
<option name="portBindings">
|
||||
<list>
|
||||
<DockerPortBindingImpl>
|
||||
<option name="containerPort" value="8080" />
|
||||
<option name="hostIp" value="127.0.0.1" />
|
||||
<option name="hostPort" value="8080" />
|
||||
</DockerPortBindingImpl>
|
||||
</list>
|
||||
</option>
|
||||
<option name="sourceFilePath" value="BlazorTest/Dockerfile" />
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="MariaDbDev" type="docker-deploy" factoryName="docker-image" temporary="true" server-name="Docker">
|
||||
<deployment type="docker-image">
|
||||
<settings>
|
||||
<option name="imageTag" value="mariadb:latest" />
|
||||
<option name="containerName" value="MariaDbDev" />
|
||||
<option name="envVars">
|
||||
<list>
|
||||
<DockerEnvVarImpl>
|
||||
<option name="name" value="MYSQL_ROOT_PASSWORD" />
|
||||
<option name="value" value="testOnlyDb" />
|
||||
</DockerEnvVarImpl>
|
||||
</list>
|
||||
</option>
|
||||
<option name="portBindings">
|
||||
<list>
|
||||
<DockerPortBindingImpl>
|
||||
<option name="containerPort" value="3306" />
|
||||
<option name="hostPort" value="3306" />
|
||||
</DockerPortBindingImpl>
|
||||
</list>
|
||||
</option>
|
||||
</settings>
|
||||
</deployment>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="WebApiDocker" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
|
||||
<deployment type="dockerfile">
|
||||
<settings>
|
||||
<option name="imageTag" value="wepapitest" />
|
||||
<option name="containerName" value="wepapitest" />
|
||||
<option name="contextFolderPath" value="." />
|
||||
<option name="portBindings">
|
||||
<list>
|
||||
<DockerPortBindingImpl>
|
||||
<option name="containerPort" value="8080" />
|
||||
<option name="hostIp" value="127.0.0.1" />
|
||||
<option name="hostPort" value="8080" />
|
||||
</DockerPortBindingImpl>
|
||||
<DockerPortBindingImpl>
|
||||
<option name="containerPort" value="5162" />
|
||||
<option name="hostPort" value="5162" />
|
||||
</DockerPortBindingImpl>
|
||||
</list>
|
||||
</option>
|
||||
<option name="sourceFilePath" value="WebApiTest/Dockerfile" />
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration default="true" type="docker-deploy" factoryName="docker-compose.yml" temporary="true">
|
||||
<deployment type="docker-compose.yml">
|
||||
<settings />
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration default="true" type="docker-deploy" factoryName="docker-image" temporary="true">
|
||||
<deployment type="docker-image">
|
||||
<settings />
|
||||
</deployment>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration default="true" type="docker-deploy" factoryName="dockerfile" temporary="true">
|
||||
<deployment type="dockerfile">
|
||||
<settings />
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="docker-compose.yml: Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker">
|
||||
<deployment type="docker-compose.yml">
|
||||
<settings>
|
||||
<option name="envFilePath" value="" />
|
||||
<option name="sourceFilePath" value="docker-compose.yml" />
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<list>
|
||||
<item itemvalue=".NET Launch Settings Profile.BlazorTest: http" />
|
||||
<item itemvalue=".NET Launch Settings Profile.BlazorTest: https" />
|
||||
<item itemvalue=".NET Launch Settings Profile.BlazorTest: IIS Express" />
|
||||
<item itemvalue=".NET Launch Settings Profile.WepApiTest: http" />
|
||||
<item itemvalue=".NET Launch Settings Profile.WepApiTest: https" />
|
||||
<item itemvalue=".NET Launch Settings Profile.WepApiTest: IIS Express" />
|
||||
<item itemvalue="Docker.BlazorTest/Dockerfile" />
|
||||
<item itemvalue="Docker.docker-compose.yml: Compose Deployment" />
|
||||
<item itemvalue="Docker.WebApiDocker" />
|
||||
<item itemvalue="Docker.MariaDbDev" />
|
||||
<item itemvalue="HTTP Request.WepApiTest | #1 (1)" />
|
||||
</list>
|
||||
<recent_temporary>
|
||||
<list>
|
||||
<item itemvalue="HTTP Request.WepApiTest | #1 (1)" />
|
||||
<item itemvalue="Docker.MariaDbDev" />
|
||||
</list>
|
||||
</recent_temporary>
|
||||
</component>
|
||||
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
|
||||
<component name="TaskManager">
|
||||
<task active="true" id="Default" summary="Default task">
|
||||
<changelist id="8146fea4-834d-47b3-9aaa-32befb744c64" name="Changes" comment="" />
|
||||
<created>1726759505353</created>
|
||||
<option name="number" value="Default" />
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1726759505353</updated>
|
||||
<workItem from="1726759506537" duration="8762000" />
|
||||
<workItem from="1726775138143" duration="20326000" />
|
||||
<workItem from="1726877247262" duration="2104000" />
|
||||
<workItem from="1726879467881" duration="2586000" />
|
||||
<workItem from="1726882140090" duration="5969000" />
|
||||
<workItem from="1726888243937" duration="249000" />
|
||||
<workItem from="1726888519041" duration="1373000" />
|
||||
<workItem from="1727188917179" duration="1894000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="BaseFiles">
|
||||
<option name="closed" value="true" />
|
||||
<created>1726786753746</created>
|
||||
<option name="number" value="00001" />
|
||||
<option name="presentableId" value="LOCAL-00001" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1726786753746</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00002" summary="BaseFiles">
|
||||
<option name="closed" value="true" />
|
||||
<created>1726786763975</created>
|
||||
<option name="number" value="00002" />
|
||||
<option name="presentableId" value="LOCAL-00002" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1726786763975</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="3" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
|
||||
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
|
||||
<component name="Vcs.Log.Tabs.Properties">
|
||||
<option name="TAB_STATES">
|
||||
<map>
|
||||
<entry key="MAIN">
|
||||
<value>
|
||||
<State />
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
<component name="VcsManagerConfiguration">
|
||||
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
|
||||
<MESSAGE value="BaseFiles" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="BaseFiles" />
|
||||
</component>
|
||||
<component name="XSLT-Support.FileAssociations.UIState">
|
||||
<expand />
|
||||
<select />
|
||||
</component>
|
||||
</project>
|
||||
+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>
|
||||
Generated
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DiscordProjectSettings">
|
||||
<option name="show" value="ASK" />
|
||||
<option name="show" value="PROJECT_FILES" />
|
||||
<option name="description" value="" />
|
||||
<option name="applicationTheme" value="default" />
|
||||
<option name="iconsTheme" value="default" />
|
||||
|
||||
@@ -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>
|
||||
@@ -19,11 +19,18 @@
|
||||
</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="true" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2">
|
||||
<option name="RunConfigurationTask" enabled="false" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,35 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Lactose (NoCache)" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
|
||||
<deployment type="dockerfile">
|
||||
<settings>
|
||||
<option name="imageTag" value="lactose" />
|
||||
<option name="buildCliOptions" value="--no-cache " />
|
||||
<option name="containerName" value="lactose" />
|
||||
<option name="envVars">
|
||||
<list>
|
||||
<DockerEnvVarImpl>
|
||||
<option name="name" value="ASPNETCORE_ENVIRONMENT" />
|
||||
<option name="value" value="Docker" />
|
||||
</DockerEnvVarImpl>
|
||||
</list>
|
||||
</option>
|
||||
<option name="portBindings">
|
||||
<list>
|
||||
<DockerPortBindingImpl>
|
||||
<option name="containerPort" value="8080" />
|
||||
<option name="hostIp" value="127.0.0.1" />
|
||||
<option name="hostPort" value="8080" />
|
||||
</DockerPortBindingImpl>
|
||||
<DockerPortBindingImpl>
|
||||
<option name="containerPort" value="5162" />
|
||||
<option name="hostPort" value="5162" />
|
||||
</DockerPortBindingImpl>
|
||||
</list>
|
||||
</option>
|
||||
<option name="sourceFilePath" value="Lactose/Dockerfile" />
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,36 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Lactose" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
|
||||
<deployment type="dockerfile">
|
||||
<settings>
|
||||
<option name="imageTag" value="lactose" />
|
||||
<option name="containerName" value="lactose" />
|
||||
<option name="contextFolderPath" value="." />
|
||||
<option name="envVars">
|
||||
<list>
|
||||
<DockerEnvVarImpl>
|
||||
<option name="name" value="ASPNETCORE_ENVIRONMENT" />
|
||||
<option name="value" value="Docker" />
|
||||
</DockerEnvVarImpl>
|
||||
</list>
|
||||
</option>
|
||||
<option name="portBindings">
|
||||
<list>
|
||||
<DockerPortBindingImpl>
|
||||
<option name="containerPort" value="8080" />
|
||||
<option name="hostIp" value="127.0.0.1" />
|
||||
<option name="hostPort" value="8080" />
|
||||
</DockerPortBindingImpl>
|
||||
<DockerPortBindingImpl>
|
||||
<option name="containerPort" value="5162" />
|
||||
<option name="hostPort" value="5162" />
|
||||
</DockerPortBindingImpl>
|
||||
</list>
|
||||
</option>
|
||||
<option name="showCommandPreview" value="true" />
|
||||
<option name="sourceFilePath" value="Lactose/Dockerfile" />
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,23 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="MilkStream/Dockerfile" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
|
||||
<deployment type="dockerfile">
|
||||
<settings>
|
||||
<option name="imageTag" value="milkstream" />
|
||||
<option name="containerName" value="milkstream" />
|
||||
<option name="contextFolderPath" value="." />
|
||||
<option name="portBindings">
|
||||
<list>
|
||||
<DockerPortBindingImpl>
|
||||
<option name="containerPort" value="8080" />
|
||||
<option name="hostIp" value="127.0.0.1" />
|
||||
<option name="hostPort" value="8080" />
|
||||
</DockerPortBindingImpl>
|
||||
</list>
|
||||
</option>
|
||||
<option name="sourceFilePath" value="MilkStream/Dockerfile" />
|
||||
</settings>
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
|
||||
<method v="2" />
|
||||
</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>
|
||||
@@ -1,23 +1,57 @@
|
||||
# MilkyShots – Agent Guide
|
||||
|
||||
## Projects (4 in solution)
|
||||
**Gitea repo owner:** MilkyShots
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **.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` (tool manifest at `dotnet-tools.json` in repo root)
|
||||
|
||||
## SCSS
|
||||
|
||||
SCSS source is in `MilkStream.Client/Styles/`. Compiled via `AspNetCore.SassCompiler` MSBuild target:
|
||||
|
||||
```bash
|
||||
dotnet build # compiles SCSS automatically
|
||||
```
|
||||
|
||||
- Config: `MilkStream.Client/Styles/sasscompiler.json` (expanded in Debug, compressed in Release)
|
||||
- Source maps disabled
|
||||
- Bootstrap 5.3.7 SCSS vendored in `Styles/bootstrap/`
|
||||
- Theme variables adjustable in `Styles/_variables.scss` (`$accent` for primary color)
|
||||
- Aggressive gradient mixins in `Styles/_gradients.scss`
|
||||
- Works in Docker — MSBuild task runs during `dotnet build`/`dotnet publish`, no hosted service
|
||||
|
||||
## 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 | `MilkStream.Client/Program.cs` |
|
||||
| **MilkStream.Client** | Blazor WASM client — Razor components, SCSS, frontend services, shared UI components (ModalFrame, EmptyState) | `MilkStream.Client/Program.cs` |
|
||||
|
||||
## Build & run
|
||||
|
||||
```bash
|
||||
dotnet build MilkyShots.sln # .NET 8, C# 12
|
||||
dotnet build MilkyShots.sln # .NET 10, C# 12
|
||||
dotnet run --project Lactose # API on :5162 (host) / :8080 (container)
|
||||
dotnet run --project MilkStream # WASM host on :8080
|
||||
dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (container)
|
||||
```
|
||||
|
||||
**Prerequisite:** PostgreSQL with pgvector extension. Default DB credentials in `appsettings.json`. Docker compose spins up all services.
|
||||
**Database:** Default credentials in `appsettings.json`. Docker compose spins up all services.
|
||||
|
||||
## Infrastructure gotchas
|
||||
|
||||
- **MilkStream `/appsettings.json`:** Dynamically generated from server config — maps **before** `UseStaticFiles`, so it shadows the static file in `MilkStream.Client/wwwroot/`. This endpoint provides `LactoseBaseUrl` to the WASM client.
|
||||
- **Auth requires claims transformation:** `RefreshTokenTransformation` (registered as `IClaimsTransformation`) must succeed on every authenticated request, or all endpoints return 403.
|
||||
- **`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
|
||||
|
||||
@@ -26,11 +60,63 @@ dotnet run --project MilkStream # WASM host on :8080
|
||||
## Conventions
|
||||
|
||||
- **`HttpPut` = create, `HttpPost("{id}")` = update** — not REST-idiomatic; do not "fix" without team buy-in
|
||||
- **Soft delete only** — set `DeletedAt`, never hard-delete
|
||||
- **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`)
|
||||
- **JWT access token**: 10 min expiry, refresh token: 60 min
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
See issue #52 for the tracking list of candidate components.
|
||||
|
||||
## CORS gotchas
|
||||
|
||||
@@ -42,11 +128,63 @@ dotnet run --project MilkStream # WASM host on :8080
|
||||
|
||||
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,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal asset info needed for displaying album assets.
|
||||
/// </summary>
|
||||
public class AlbumAssetPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution width.
|
||||
/// </summary>
|
||||
public int ResolutionWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a thumbnail.
|
||||
/// </summary>
|
||||
public bool HasThumbnail { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a preview.
|
||||
/// </summary>
|
||||
public bool HasPreview { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset's display media. For animated assets this is the
|
||||
/// converted video MIME type (e.g. "video/mp4"); otherwise the original MIME type.
|
||||
/// </summary>
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the original filename of the asset.
|
||||
/// </summary>
|
||||
public string? FileName { get; set; }
|
||||
/// <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,17 +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; }
|
||||
/// <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;
|
||||
}
|
||||
@@ -8,4 +8,8 @@ public class AlbumFullDto : AlbumPreviewDto {
|
||||
/// Gets or sets the list of asset IDs in the album.
|
||||
/// </summary>
|
||||
public List<Guid> Images { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Gets or sets the asset preview information for rendering.
|
||||
/// </summary>
|
||||
public List<AlbumAssetPreviewDto> AssetPreviews { get; set; } = [];
|
||||
}
|
||||
@@ -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,17 +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; }
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the cover asset for this album.
|
||||
/// </summary>
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover image width, if a cover exists.
|
||||
/// </summary>
|
||||
public int? CoverWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover image height, if a cover exists.
|
||||
/// </summary>
|
||||
public int? CoverHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets in the album.
|
||||
/// </summary>
|
||||
public int AssetCount { get; set; }
|
||||
/// <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; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
/// Search and filter parameters for album queries.
|
||||
/// </summary>
|
||||
public class AlbumSearchParametersDto : PagedSearchParametersDto {
|
||||
/// <summary>
|
||||
/// 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,17 +9,25 @@ public class AlbumUpdateDto {
|
||||
/// <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; }
|
||||
/// <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; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover asset ID for the album.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -7,56 +7,36 @@ namespace Butter.Dtos.Asset;
|
||||
/// Represents the full information needed to display an asset on a detail page.
|
||||
/// </summary>
|
||||
public class AssetDto : AssetPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the original filename of the asset. May be null if the user lacks permission.
|
||||
/// </summary>
|
||||
public string? FileName { get; set; }
|
||||
/// <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;
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution width of the asset in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height of the asset in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
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,9 +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; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height of the asset in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a generated thumbnail.
|
||||
/// </summary>
|
||||
public bool HasThumbnail { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a generated preview.
|
||||
/// </summary>
|
||||
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; }
|
||||
/// <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; }
|
||||
/// <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; }
|
||||
/// <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; }
|
||||
/// <summary>
|
||||
/// Gets or sets the original filename of the asset.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
@@ -12,4 +14,46 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
|
||||
/// Gets or sets the end date for filtering assets by date range.
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the optional asset type filter.
|
||||
/// </summary>
|
||||
public EAssetType? Type { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the seed for deterministic random ordering.
|
||||
/// </summary>
|
||||
public Guid? Seed { get; set; }
|
||||
/// <summary>
|
||||
/// 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; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Butter.Dtos.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a response containing child jobs and a timestamp for delta-based polling.
|
||||
/// </summary>
|
||||
public class ChildrenResponse {
|
||||
/// <summary>
|
||||
/// Gets the list of child job statuses.
|
||||
/// </summary>
|
||||
public List<JobStatusDto> Children { get; init; } = [];
|
||||
/// <summary>
|
||||
/// Gets the server timestamp to use as <c>since</c> for the next delta poll.
|
||||
/// </summary>
|
||||
public DateTime Since { get; init; }
|
||||
}
|
||||
@@ -63,7 +63,13 @@ public enum EJobType {
|
||||
/// <summary>Performs an integrity check on the system.</summary>
|
||||
IntegrityCheck,
|
||||
/// <summary>Computes perceptual hashes for assets.</summary>
|
||||
PHashGeneration
|
||||
PHashGeneration,
|
||||
/// <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,
|
||||
/// <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>
|
||||
@@ -12,6 +18,16 @@ public class PagedParametersDto {
|
||||
/// Gets or sets the number of items per page.
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 150;
|
||||
/// <summary>
|
||||
/// Gets or sets the field to sort by (e.g. "name", "created").
|
||||
/// When <c>null</c>, the default sort order is applied.
|
||||
/// </summary>
|
||||
public string? SortBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the sort direction.
|
||||
/// <c>false</c> = descending (default), <c>true</c> = ascending.
|
||||
/// </summary>
|
||||
public bool SortAsc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,5 +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; }
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropX { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropY { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the zoom level for the profile picture.
|
||||
/// </summary>
|
||||
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,23 +1,58 @@
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the full details of a person, including associated assets and albums.
|
||||
/// Represents the full details of a person, including associated albums.
|
||||
/// </summary>
|
||||
public class PersonDetailedDto {
|
||||
/// <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 list of asset IDs associated with this person.
|
||||
/// Gets or sets the asset ID used as the profile picture.
|
||||
/// </summary>
|
||||
public List<Guid>? Assets { get; set; }
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of album IDs associated with this person.
|
||||
/// Gets or sets the horizontal crop offset for the profile picture (0–100).
|
||||
/// </summary>
|
||||
public List<Guid>? Albums { get; set; }
|
||||
public float? ProfileCropX { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical crop offset for the profile picture (0–100).
|
||||
/// </summary>
|
||||
public float? ProfileCropY { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the zoom level for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of non-deleted albums.
|
||||
/// </summary>
|
||||
public int TotalAlbums { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of non-deleted assets across all albums.
|
||||
/// </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,9 +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; }
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropX { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropY { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the zoom level for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of albums associated with this person.
|
||||
/// </summary>
|
||||
public int TotalAlbums { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
/// Search and filter parameters for person (cosplayer) queries.
|
||||
/// </summary>
|
||||
public class PersonSearchParametersDto : PagedSearchParametersDto {
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,6 +9,29 @@ public class PersonUpdateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the person.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
//TODO: add the missing properties here
|
||||
public string? Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID to use as the profile picture.
|
||||
/// </summary>
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropX { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropY { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the zoom level for the profile picture.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Butter.Dtos.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// A MIME type and its associated asset count for statistics.
|
||||
/// </summary>
|
||||
public class MimeTypeStatDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type string.
|
||||
/// </summary>
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets with this MIME type.
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Butter.Dtos.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Monthly growth statistics for assets and users.
|
||||
/// </summary>
|
||||
public class MonthlyStatDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the year.
|
||||
/// </summary>
|
||||
public int Year { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the month (1-12).
|
||||
/// </summary>
|
||||
public int Month { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of new assets added in this month.
|
||||
/// </summary>
|
||||
public int NewAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of new users registered in this month.
|
||||
/// </summary>
|
||||
public int NewUsers { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Butter.Dtos.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// A resolution bucket with a label and the count of assets that fall into it.
|
||||
/// </summary>
|
||||
public class ResolutionBucketDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the display label for the resolution bucket.
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets in this resolution bucket.
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Comprehensive statistics about the MilkyShots instance, gatherable from fast aggregate queries.
|
||||
/// </summary>
|
||||
public class StatsDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of assets (excluding soft-deleted).
|
||||
/// </summary>
|
||||
public int TotalAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of registered users (excluding soft-deleted).
|
||||
/// </summary>
|
||||
public int TotalUsers { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of albums.
|
||||
/// </summary>
|
||||
public int TotalAlbums { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of tags.
|
||||
/// </summary>
|
||||
public int TotalTags { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of people.
|
||||
/// </summary>
|
||||
public int TotalPeople { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of detected faces.
|
||||
/// </summary>
|
||||
public int TotalFaces { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of watched folders.
|
||||
/// </summary>
|
||||
public int TotalFolders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the asset count broken down by type (Image, Video, Animation, PhotoSphere).
|
||||
/// </summary>
|
||||
public Dictionary<EAssetType, int> AssetsByType { get; set; } = [];
|
||||
/// <summary>
|
||||
/// Gets or sets the total storage used by all assets in bytes.
|
||||
/// </summary>
|
||||
public long TotalStorageBytes { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the storage used broken down by asset type.
|
||||
/// </summary>
|
||||
public Dictionary<EAssetType, long> StorageByType { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user count broken down by access level.
|
||||
/// </summary>
|
||||
public Dictionary<EAccessLevel, int> UsersByAccessLevel { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets added in the last 7 days.
|
||||
/// </summary>
|
||||
public int AssetsAddedLast7Days { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets added in the last 30 days.
|
||||
/// </summary>
|
||||
public int AssetsAddedLast30Days { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of users registered in the last 30 days.
|
||||
/// </summary>
|
||||
public int UsersRegisteredLast30Days { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets with no associated folder.
|
||||
/// </summary>
|
||||
public int OrphanAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of publicly shared assets.
|
||||
/// </summary>
|
||||
public int PublicAssets { get; set; }
|
||||
/// <summary>
|
||||
/// 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; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of image assets missing resolution metadata.
|
||||
/// </summary>
|
||||
public int AssetsMissingMetadata { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets missing a generated thumbnail.
|
||||
/// </summary>
|
||||
public int AssetsMissingThumbnail { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets whose thumbnail was generated at a different size than the current setting.
|
||||
/// </summary>
|
||||
public int AssetsMissingThumbnailStale { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets missing a generated preview.
|
||||
/// </summary>
|
||||
public int AssetsMissingPreviews { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets whose preview was generated at a different size than the current setting.
|
||||
/// </summary>
|
||||
public int AssetsMissingPreviewsStale { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets missing a perceptual hash.
|
||||
/// </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; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets with no album assigned.
|
||||
/// </summary>
|
||||
public int AssetsWithNoAlbum { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of albums without a cover image.
|
||||
/// </summary>
|
||||
public int AlbumsMissingCover { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of people/cosplayers without a profile picture.
|
||||
/// </summary>
|
||||
public int CosplayersMissingProfile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the top tags by asset count.
|
||||
/// </summary>
|
||||
public List<TagStatDto> TopTags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution distribution of assets.
|
||||
/// </summary>
|
||||
public List<ResolutionBucketDto> ResolutionDistribution { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the file format breakdown by MIME type.
|
||||
/// </summary>
|
||||
public List<MimeTypeStatDto> FileFormatBreakdown { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the monthly new-asset and new-user counts for the last 12 months.
|
||||
/// </summary>
|
||||
public List<MonthlyStatDto> MonthlyGrowth { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Butter.Dtos.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// A tag and its associated asset count for statistics.
|
||||
/// </summary>
|
||||
public class TagStatDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the tag identifier.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the tag name.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets associated with the tag.
|
||||
/// </summary>
|
||||
public int AssetCount { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -18,12 +18,16 @@ public class UserUpdateDto {
|
||||
/// Gets or sets the new email address.
|
||||
/// </summary>
|
||||
public string? Email { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the new password.
|
||||
/// </summary>
|
||||
public string? Password { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the user is banned.
|
||||
/// <summary>
|
||||
/// Gets or sets the new password.
|
||||
/// </summary>
|
||||
public string? Password { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the current password (required for self-service password changes).
|
||||
/// </summary>
|
||||
public string? OldPassword { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the user is banned.
|
||||
/// </summary>
|
||||
public bool? IsBanned { get; set; }
|
||||
/// <summary>
|
||||
@@ -34,4 +38,8 @@ public class UserUpdateDto {
|
||||
/// 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>
|
||||
|
||||
@@ -27,13 +27,25 @@ public enum Settings {
|
||||
/// <summary>Number of days to keep completed/failed/canceled jobs.</summary>
|
||||
JobRetentionDays,
|
||||
/// <summary>Number of assets to process per sub-job batch.</summary>
|
||||
JobBatchSize
|
||||
JobBatchSize,
|
||||
/// <summary>Quality setting (1–100) for WebP thumbnail encoding.</summary>
|
||||
ThumbnailQuality,
|
||||
/// <summary>Quality setting (1–100) for WebP preview encoding.</summary>
|
||||
PreviewQuality,
|
||||
/// <summary>Path to store converted videos for animated assets (e.g. GIF → mp4).</summary>
|
||||
ConvertedPath,
|
||||
/// <summary>Container format for converted videos (e.g. "mp4").</summary>
|
||||
ConvertedFormat,
|
||||
/// <summary>Path to the ffmpeg executable (defaults to "ffmpeg" on PATH).</summary>
|
||||
FfmpegPath,
|
||||
/// <summary>User ID used for scanner-created asset attribution.</summary>
|
||||
SystemUploaderId
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -53,6 +65,12 @@ public static class SettingsExtensions {
|
||||
Settings.MaxConcurrentJobs => "Max Concurrent Jobs",
|
||||
Settings.JobRetentionDays => "Job Retention Days",
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<LangVersion>12</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<IsRoslynComponent>true</IsRoslynComponent>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<DevelopmentDependency>true</DevelopmentDependency>
|
||||
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
|
||||
<NoWarn>$(NoWarn);RS1038;RS2008</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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>
|
||||
@@ -0,0 +1,132 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace Lactose.Analyzers;
|
||||
|
||||
[DiagnosticAnalyzer(LanguageNames.CSharp)]
|
||||
public class OnlyUtcDateTimeAnalyzer : DiagnosticAnalyzer
|
||||
{
|
||||
const string Category = "Correctness";
|
||||
|
||||
static readonly DiagnosticDescriptor DateTimeNowRule = new(
|
||||
id: "MS001",
|
||||
title: "Use DateTime.UtcNow instead of DateTime.Now",
|
||||
messageFormat: "Use DateTime.UtcNow instead of DateTime.Now to ensure UTC semantics",
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true);
|
||||
|
||||
static readonly DiagnosticDescriptor DateTimeTodayRule = new(
|
||||
id: "MS002",
|
||||
title: "Use DateTime.UtcNow.Date instead of DateTime.Today",
|
||||
messageFormat: "Use DateTime.UtcNow.Date instead of DateTime.Today to ensure UTC semantics",
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true);
|
||||
|
||||
static readonly DiagnosticDescriptor DateTimeDateRule = new(
|
||||
id: "MS003",
|
||||
title: "DateTime.Date may return non-UTC value",
|
||||
messageFormat: "DateTime.Date produces a DateTime with Kind=Local; ensure this is intentional or use DateTime.UtcNow.Date",
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true);
|
||||
|
||||
static readonly DiagnosticDescriptor SpecifyKindRule = new(
|
||||
id: "MS004",
|
||||
title: "DateTime.SpecifyKind must use DateTimeKind.Utc",
|
||||
messageFormat: "DateTime.SpecifyKind with {0} is not allowed; use DateTimeKind.Utc",
|
||||
category: Category,
|
||||
defaultSeverity: DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true);
|
||||
|
||||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
|
||||
ImmutableArray.Create(DateTimeNowRule, DateTimeTodayRule, DateTimeDateRule, SpecifyKindRule);
|
||||
|
||||
public override void Initialize(AnalysisContext context)
|
||||
{
|
||||
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
|
||||
context.EnableConcurrentExecution();
|
||||
context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression);
|
||||
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
|
||||
}
|
||||
|
||||
void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context)
|
||||
{
|
||||
var memberAccess = (MemberAccessExpressionSyntax)context.Node;
|
||||
if (memberAccess.Name is not IdentifierNameSyntax memberName)
|
||||
return;
|
||||
|
||||
switch (memberName.Identifier.Text)
|
||||
{
|
||||
case "Now":
|
||||
if (IsSystemDateTime(context, memberAccess.Expression))
|
||||
context.ReportDiagnostic(Diagnostic.Create(DateTimeNowRule, memberAccess.GetLocation()));
|
||||
break;
|
||||
|
||||
case "Today":
|
||||
if (IsSystemDateTime(context, memberAccess.Expression))
|
||||
context.ReportDiagnostic(Diagnostic.Create(DateTimeTodayRule, memberAccess.GetLocation()));
|
||||
break;
|
||||
|
||||
case "Date":
|
||||
{
|
||||
var typeInfo = context.SemanticModel.GetTypeInfo(memberAccess.Expression, context.CancellationToken);
|
||||
if (typeInfo.Type is INamedTypeSymbol namedType &&
|
||||
namedType.SpecialType == SpecialType.System_DateTime)
|
||||
{
|
||||
context.ReportDiagnostic(Diagnostic.Create(DateTimeDateRule, memberAccess.GetLocation()));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
|
||||
{
|
||||
var invocation = (InvocationExpressionSyntax)context.Node;
|
||||
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
|
||||
return;
|
||||
|
||||
if (memberAccess.Name is not IdentifierNameSyntax { Identifier.Text: "SpecifyKind" })
|
||||
return;
|
||||
|
||||
if (!IsSystemDateTime(context, memberAccess.Expression))
|
||||
return;
|
||||
|
||||
if (invocation.ArgumentList.Arguments.Count < 2)
|
||||
return;
|
||||
|
||||
var kindArg = invocation.ArgumentList.Arguments[1].Expression;
|
||||
var kindValue = context.SemanticModel.GetConstantValue(kindArg, context.CancellationToken);
|
||||
|
||||
if (!kindValue.HasValue || kindValue.Value is not int intKind)
|
||||
return;
|
||||
|
||||
var kindName = intKind switch
|
||||
{
|
||||
0 => "DateTimeKind.Unspecified",
|
||||
1 => "DateTimeKind.Utc",
|
||||
2 => "DateTimeKind.Local",
|
||||
_ => $"DateTimeKind value {intKind}"
|
||||
};
|
||||
|
||||
if (intKind != 1)
|
||||
{
|
||||
context.ReportDiagnostic(Diagnostic.Create(
|
||||
SpecifyKindRule,
|
||||
kindArg.GetLocation(),
|
||||
kindName));
|
||||
}
|
||||
}
|
||||
|
||||
static bool IsSystemDateTime(SyntaxNodeAnalysisContext context, ExpressionSyntax expression)
|
||||
{
|
||||
var typeInfo = context.SemanticModel.GetTypeInfo(expression, context.CancellationToken);
|
||||
return typeInfo.Type is INamedTypeSymbol named &&
|
||||
named.SpecialType == SpecialType.System_DateTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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;
|
||||
|
||||
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(OnlyUtcDateTimeCodeFix))]
|
||||
[Shared]
|
||||
public class OnlyUtcDateTimeCodeFix : CodeFixProvider
|
||||
{
|
||||
public override ImmutableArray<string> FixableDiagnosticIds =>
|
||||
ImmutableArray.Create("MS001", "MS002", "MS004");
|
||||
|
||||
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
|
||||
|
||||
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
|
||||
{
|
||||
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
|
||||
if (root is null)
|
||||
return;
|
||||
|
||||
var diagnostic = context.Diagnostics[0];
|
||||
var diagnosticSpan = diagnostic.Location.SourceSpan;
|
||||
var node = root.FindNode(diagnosticSpan);
|
||||
|
||||
switch (diagnostic.Id)
|
||||
{
|
||||
case "MS001":
|
||||
context.RegisterCodeFix(
|
||||
CodeAction.Create(
|
||||
title: "Replace with DateTime.UtcNow",
|
||||
createChangedDocument: ct => ReplaceNowWithUtcNow(context.Document, node, ct),
|
||||
equivalenceKey: "MS001"),
|
||||
diagnostic);
|
||||
break;
|
||||
|
||||
case "MS002":
|
||||
context.RegisterCodeFix(
|
||||
CodeAction.Create(
|
||||
title: "Replace with DateTime.UtcNow.Date",
|
||||
createChangedDocument: ct => ReplaceTodayWithUtcNowDate(context.Document, node, ct),
|
||||
equivalenceKey: "MS002"),
|
||||
diagnostic);
|
||||
break;
|
||||
|
||||
case "MS004":
|
||||
context.RegisterCodeFix(
|
||||
CodeAction.Create(
|
||||
title: "Replace with DateTimeKind.Utc",
|
||||
createChangedDocument: ct => ReplaceWithUtcKind(context.Document, node, ct),
|
||||
equivalenceKey: "MS004"),
|
||||
diagnostic);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<Document> ReplaceNowWithUtcNow(Document document, SyntaxNode node, CancellationToken ct)
|
||||
{
|
||||
var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
|
||||
if (node is MemberAccessExpressionSyntax memberAccess)
|
||||
{
|
||||
var utcNow = SyntaxFactory.MemberAccessExpression(
|
||||
SyntaxKind.SimpleMemberAccessExpression,
|
||||
SyntaxFactory.IdentifierName("DateTime"),
|
||||
SyntaxFactory.IdentifierName("UtcNow"));
|
||||
editor.ReplaceNode(memberAccess, utcNow.WithTriviaFrom(memberAccess));
|
||||
}
|
||||
return editor.GetChangedDocument();
|
||||
}
|
||||
|
||||
static async Task<Document> ReplaceTodayWithUtcNowDate(Document document, SyntaxNode node, CancellationToken ct)
|
||||
{
|
||||
var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
|
||||
if (node is MemberAccessExpressionSyntax memberAccess)
|
||||
{
|
||||
var utcNow = SyntaxFactory.MemberAccessExpression(
|
||||
SyntaxKind.SimpleMemberAccessExpression,
|
||||
SyntaxFactory.IdentifierName("DateTime"),
|
||||
SyntaxFactory.IdentifierName("UtcNow"));
|
||||
var utcNowDotDate = SyntaxFactory.MemberAccessExpression(
|
||||
SyntaxKind.SimpleMemberAccessExpression,
|
||||
utcNow,
|
||||
SyntaxFactory.IdentifierName("Date"));
|
||||
editor.ReplaceNode(memberAccess, utcNowDotDate.WithTriviaFrom(memberAccess));
|
||||
}
|
||||
return editor.GetChangedDocument();
|
||||
}
|
||||
|
||||
static async Task<Document> ReplaceWithUtcKind(Document document, SyntaxNode node, CancellationToken ct)
|
||||
{
|
||||
var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
|
||||
if (node is ArgumentSyntax argument)
|
||||
{
|
||||
var utcKind = SyntaxFactory.MemberAccessExpression(
|
||||
SyntaxKind.SimpleMemberAccessExpression,
|
||||
SyntaxFactory.IdentifierName("DateTimeKind"),
|
||||
SyntaxFactory.IdentifierName("Utc"));
|
||||
editor.ReplaceNode(argument.Expression, utcKind.WithTriviaFrom(argument.Expression));
|
||||
}
|
||||
return editor.GetChangedDocument();
|
||||
}
|
||||
}
|
||||
@@ -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.Now) 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.
|
||||
@@ -49,33 +61,107 @@ public class LactoseDbContext : DbContext {
|
||||
/// <param name="options">The database context options.</param>
|
||||
public LactoseDbContext(DbContextOptions options) : base(options) {}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) {
|
||||
configurationBuilder.Properties<DateTime>()
|
||||
.HaveColumnType("timestamp with time zone");
|
||||
configurationBuilder.Properties<DateTime?>()
|
||||
.HaveColumnType("timestamp with time zone");
|
||||
}
|
||||
|
||||
/// <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);
|
||||
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);
|
||||
//Person Relationships
|
||||
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,76 +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));
|
||||
// Add shared assets
|
||||
assets.AddRange(album.Assets.Where(asset => asset.SharedWith!.Any(user => user.Id == uid)));
|
||||
break;
|
||||
case EAccessLevel.Curator:
|
||||
case EAccessLevel.Admin:
|
||||
// All assets
|
||||
assets.AddRange(album.Assets);
|
||||
break;
|
||||
}
|
||||
|
||||
// changes the retrieved album to the new filtered
|
||||
album.Assets = assets;
|
||||
return Ok(album.ToAlbumFullDto());
|
||||
return Ok(album.ToAlbumFullDto(accessLevel, uid));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches albums with pagination and optional search term.
|
||||
/// Searches albums with pagination, optional search term, sort options, and optional unassigned filter.
|
||||
/// </summary>
|
||||
/// <param name="pagingOptions">Pagination and search parameters.</param>
|
||||
/// <param name="pagingOptions">Pagination, search, sort, and filter parameters.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A list of album previews accessible to the user.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<List<AlbumPreviewDto>> Search([FromQuery] PagedSearchParametersDto 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(pagingOptions.Search ?? string.Empty, pagingOptions.Page, pagingOptions.PageSize).ToList();
|
||||
|
||||
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))).ToList();
|
||||
// Add shared albums
|
||||
filteredAlbums.AddRange(albums.Where(album => album.Assets != null && album.Assets.Any(x => 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());
|
||||
}
|
||||
|
||||
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,
|
||||
uid ?? default,
|
||||
accessLevel,
|
||||
pagingOptions.AssetUploadedBy,
|
||||
pagingOptions.PersonOwnerId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Ok(albums);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -101,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
|
||||
return Forbid();
|
||||
case EAccessLevel.Curator:
|
||||
// Curators can only update albums they own
|
||||
if (album.UserOwnerId != uid) return Forbid();
|
||||
break;
|
||||
case EAccessLevel.Admin:
|
||||
// Admins can update any album
|
||||
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 = albumDto.Name ?? album.Title;
|
||||
album.UpdatedAt = DateTime.Now;
|
||||
album.PersonOwnerId = albumDto.Person ?? album.PersonOwnerId;
|
||||
album.UserOwnerId = albumDto.Owner ?? album.UserOwnerId;
|
||||
album.Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : album.Assets;
|
||||
album.Title = string.IsNullOrEmpty(albumDto.Name) ? album.Title : albumDto.Name;
|
||||
album.UpdatedAt = DateTime.UtcNow;
|
||||
album.PersonOwnerId = albumDto.RemovePerson ? null : albumDto.Person ?? album.PersonOwnerId;
|
||||
album.Visibility = albumDto.Visibility ?? album.Visibility;
|
||||
album.Assets = albumDto.Assets != null ? await assetRepository.FindBulkAsync(albumDto.Assets, cancellationToken) : album.Assets;
|
||||
album.CoverAssetId = albumDto.CoverAssetId ?? album.CoverAssetId;
|
||||
|
||||
// Save changes
|
||||
albumRepository.Update(album);
|
||||
await albumRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -143,68 +120,112 @@ 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.Now;
|
||||
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;
|
||||
}
|
||||
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 ? await assetRepository.FindBulkAsync(albumDto.Assets, cancellationToken) : [];
|
||||
|
||||
var album = new Album {
|
||||
Id = Guid.NewGuid(),
|
||||
Title = albumDto.Name,
|
||||
CreatedAt = DateTime.Now,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
PersonOwnerId = albumDto.Person ?? null,
|
||||
UserOwnerId = albumDto.Owner,
|
||||
Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : new List<Asset>()
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -212,23 +233,35 @@ public class AlbumController(
|
||||
/// Removes multiple albums (soft delete).
|
||||
/// </summary>
|
||||
/// <param name="albumIds">The list of album IDs to remove.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>200 on success, 403 if forbidden.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "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();
|
||||
}
|
||||
@@ -237,21 +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);
|
||||
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,55 +76,138 @@ public class AssetController(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches assets by date range 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 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) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
DateTime from = DateTime.Parse(searchOptionsDto.StartDate ?? string.Empty);
|
||||
DateTime to = DateTime.Parse(searchOptionsDto.EndDate ?? string.Empty);
|
||||
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;
|
||||
DateTime? from = string.IsNullOrEmpty(searchOptionsDto.StartDate) ? null : DateTime.Parse(searchOptionsDto.StartDate);
|
||||
DateTime? to = string.IsNullOrEmpty(searchOptionsDto.EndDate) ? null : DateTime.Parse(searchOptionsDto.EndDate);
|
||||
|
||||
if (from > to) {
|
||||
if (from.HasValue && to.HasValue && from > to) {
|
||||
logger.LogWarning("Invalid date range provided");
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
if (searchOptionsDto.Page < 1 || 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.FindByDateRange(from, to, searchOptionsDto.Page, searchOptionsDto.PageSize);
|
||||
var (assets, total) = await assetRepository.GetAssetsAsync(
|
||||
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
|
||||
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(
|
||||
$"""
|
||||
Requested assets
|
||||
by user: {uid}
|
||||
by user: {uid}
|
||||
access level: {accessLevel}
|
||||
type: {searchOptionsDto.Type}
|
||||
random: {searchOptionsDto.Random}
|
||||
date range: {from} - {to}
|
||||
"""
|
||||
page: {searchOptionsDto.Page}
|
||||
"""
|
||||
);
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User: {
|
||||
//Only publicly shared assets and assets shared with the user that are not deleted
|
||||
assets = assets.Where(
|
||||
a => a.IsPubliclyShared || a.SharedWith?.Find(x => x.Id == uid) != null && a.DeletedAt != null
|
||||
);
|
||||
logger.LogTrace($"Returning {assets.Count} assets (total: {total})");
|
||||
Response.Headers["X-Total-Count"] = total.ToString();
|
||||
return Ok(assets);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.Curator:
|
||||
case EAccessLevel.Admin: {
|
||||
//Admins will see this regardless
|
||||
break;
|
||||
}
|
||||
}
|
||||
/// <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;
|
||||
|
||||
return Ok(assets.ToAssetPreviewDto());
|
||||
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>
|
||||
@@ -163,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();
|
||||
}
|
||||
@@ -204,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();
|
||||
|
||||
@@ -225,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.Now;
|
||||
asset.DeletedAt = DateTime.UtcNow;
|
||||
assetRepository.Update(asset);
|
||||
assetRepository.Save();
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -272,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();
|
||||
|
||||
@@ -293,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.Now);
|
||||
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
|
||||
|
||||
assetRepository.UpdateBulk(enumerable);
|
||||
await assetRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -305,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,
|
||||
@@ -338,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() {
|
||||
@@ -50,19 +56,23 @@ public class AuthController(
|
||||
);
|
||||
}
|
||||
if (user.BannedAt != null) {
|
||||
return StatusCode(StatusCodes.Status100Continue,new AuthResultDto() {
|
||||
return StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
new AuthResultDto() {
|
||||
Success = false,
|
||||
ErrorMessage = "User is banned"
|
||||
}
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (user.DeletedAt != null) {
|
||||
return StatusCode(StatusCodes.Status100Continue,new AuthResultDto() {
|
||||
return StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
new AuthResultDto() {
|
||||
Success = false,
|
||||
ErrorMessage = "User is disabled"
|
||||
}
|
||||
);
|
||||
);
|
||||
}
|
||||
PasswordVerificationResult result = passwordHasher.VerifyHashedPassword(user, user.Password, userDto.Password);
|
||||
|
||||
@@ -70,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.Now;
|
||||
|
||||
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,
|
||||
@@ -88,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:
|
||||
@@ -104,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");
|
||||
}
|
||||
|
||||
@@ -112,47 +139,34 @@ public class AuthController(
|
||||
Username = dto.Username,
|
||||
Email = dto.Email,
|
||||
AccessLevel = EAccessLevel.User,
|
||||
CreatedAt = DateTime.Now,
|
||||
UpdatedAt = DateTime.Now
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
user.Password = passwordHasher.HashPassword(user, dto.Password);
|
||||
|
||||
userRepository.Insert(user);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
return Ok(user.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the current authentication status and returns the authenticated user data.
|
||||
/// </summary>
|
||||
/// <returns>The authenticated user's data.</returns>
|
||||
[Authorize]
|
||||
[HttpPost()]
|
||||
public ActionResult<LactoseAuthenticatedUser> Check() {
|
||||
// this.User;
|
||||
//ClaimsPrincipal principal = User;
|
||||
LactoseAuthenticatedUser? identity = authService.GetUserData(User);
|
||||
return Ok(identity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs out the user by invalidating their refresh token.
|
||||
/// </summary>
|
||||
/// <returns>200 on success, 401 if unauthorized.</returns>
|
||||
[Authorize]
|
||||
[HttpPost("logout")]
|
||||
public ActionResult Logout() {
|
||||
public async Task<ActionResult> Logout(CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? identity = authService.GetUserData(User);
|
||||
|
||||
if (identity == null) { return Unauthorized(); }
|
||||
|
||||
|
||||
//Reset token from the database
|
||||
User? user = userRepository.Find(identity.Id);
|
||||
User? user = await userRepository.FindAsync(identity.Id, cancellationToken);
|
||||
if (user == null) { return Unauthorized(); }
|
||||
user.RefreshToken = string.Empty;
|
||||
user.RefreshTokenExpires = null;
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -162,10 +176,11 @@ public class AuthController(
|
||||
/// Refreshes an expired JWT access token using a valid refresh token.
|
||||
/// </summary>
|
||||
/// <param name="token">The refresh token request data.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A new access token, and optionally a new refresh token.</returns>
|
||||
[HttpPost("refresh")]
|
||||
public ActionResult<AuthResultDto> RefreshToken([FromBody] RefreshDto token) {
|
||||
User? user = userRepository.Find(token.UserId);
|
||||
public async Task<ActionResult<AuthResultDto>> RefreshToken([FromBody] RefreshDto token, CancellationToken cancellationToken) {
|
||||
User? user = await userRepository.FindAsync(token.UserId, cancellationToken);
|
||||
|
||||
if (user == null) {
|
||||
return NotFound(
|
||||
@@ -178,7 +193,7 @@ public class AuthController(
|
||||
|
||||
if (user.BannedAt != null) {
|
||||
return StatusCode(
|
||||
StatusCodes.Status100Continue,
|
||||
StatusCodes.Status403Forbidden,
|
||||
new AuthResultDto() {
|
||||
Success = false,
|
||||
ErrorMessage = "User is banned"
|
||||
@@ -188,7 +203,7 @@ public class AuthController(
|
||||
|
||||
if (user.DeletedAt != null) {
|
||||
return StatusCode(
|
||||
StatusCodes.Status100Continue,
|
||||
StatusCodes.Status403Forbidden,
|
||||
new AuthResultDto() {
|
||||
Success = false,
|
||||
ErrorMessage = "User is disabled"
|
||||
@@ -210,12 +225,12 @@ public class AuthController(
|
||||
Token = authService.GenerateAccessToken(user)
|
||||
};
|
||||
|
||||
if ((user.RefreshTokenExpires - TimeSpan.FromMinutes(LactoseAuthService.BaseExpirationTime)) < DateTime.Now) {
|
||||
if ((user.RefreshTokenExpires - TimeSpan.FromMinutes(LactoseAuthService.BaseExpirationTime)) < DateTime.UtcNow) {
|
||||
var refreshToken = authService.GenerateRefreshToken(user);
|
||||
authDto.RefreshToken = refreshToken;
|
||||
user.RefreshToken = refreshToken;
|
||||
user.RefreshTokenExpires = DateTime.Now.AddMinutes(LactoseAuthService.LongExpirationTime);
|
||||
userRepository.Save();
|
||||
user.RefreshTokenExpires = DateTime.UtcNow.AddMinutes(LactoseAuthService.LongExpirationTime);
|
||||
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,24 +35,36 @@ 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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns children of a specific parent job (active + past).
|
||||
/// 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<List<JobStatusDto>> GetChildren(Guid parentId) {
|
||||
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(); }
|
||||
return Ok(jobManager.GetChildren(parentId));
|
||||
var children = await jobManager.GetChildrenAsync(parentId, since, cancellationToken);
|
||||
return Ok(new ChildrenResponse { Children = children, Since = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -90,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)
|
||||
@@ -115,8 +129,7 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
||||
if (jobRequest.Parameters.Count != 0)
|
||||
return BadRequest("PreviewGeneration job does not require any parameters.");
|
||||
|
||||
try { jobScheduler.QueuePreviewGeneration(null); }
|
||||
catch (NotImplementedException) { return StatusCode(501, "PreviewGeneration is not yet implemented."); }
|
||||
jobScheduler.QueuePreviewGeneration(null);
|
||||
return Ok();
|
||||
case EJobType.MetadataExtraction:
|
||||
if (jobRequest.Parameters.Count != 0)
|
||||
@@ -130,12 +143,56 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
||||
|
||||
jobScheduler.QueueIntegrityCheck(null);
|
||||
return Ok();
|
||||
case EJobType.CreateAlbums:
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
using Butter.Types;
|
||||
using Lactose.Configuration;
|
||||
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;
|
||||
|
||||
@@ -10,43 +17,116 @@ 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]
|
||||
[Route("api/[controller]")]
|
||||
public class MediaController(
|
||||
//TODO: Implement logging
|
||||
//ILogger<MediaController> logger
|
||||
LactoseAuthService authService,
|
||||
IMediaRepository mediaRepository
|
||||
IMediaRepository mediaRepository,
|
||||
IAssetRepository assetRepository,
|
||||
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) {
|
||||
var user = authService.GetUserData(User);
|
||||
[EnableRateLimiting("media_original")]
|
||||
public async Task<ActionResult> GetImage(Guid id, CancellationToken cancellationToken) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = await assetRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
var data = mediaRepository.GetOriginalData(id, user?.Id);
|
||||
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)) {
|
||||
if (hasConverted)
|
||||
return ServeFile(asset.ConvertedPath, asset.ConvertedMimeType, asset);
|
||||
return ServeFile(asset.OriginalPath, asset.MimeType, asset);
|
||||
}
|
||||
|
||||
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) {
|
||||
var user = authService.GetUserData(User);
|
||||
[EnableRateLimiting("media_thumb")]
|
||||
public async Task<ActionResult> GetThumb(Guid id, CancellationToken cancellationToken) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = await assetRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
var data = mediaRepository.GetThumbData(id, user?.Id);
|
||||
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 = await mediaRepository.GetThumbDataAsync(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User, cancellationToken);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
@@ -56,14 +136,66 @@ 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) {
|
||||
var user = authService.GetUserData(User);
|
||||
[EnableRateLimiting("media_preview")]
|
||||
public async Task<ActionResult> GetPreview(Guid id, CancellationToken cancellationToken) {
|
||||
var user = GetUserFromRequest();
|
||||
var asset = await assetRepository.FindAsync(id, cancellationToken);
|
||||
|
||||
var data = mediaRepository.GetPreviewData(id, user?.Id);
|
||||
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 = await mediaRepository.GetPreviewDataAsync(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User, cancellationToken);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the authenticated user from the request, first trying the Authorization header,
|
||||
/// then falling back to the <c>token</c> query parameter for browser image requests.
|
||||
/// </summary>
|
||||
private LactoseAuthenticatedUser? GetUserFromRequest() {
|
||||
var user = authService.GetUserData(User);
|
||||
if (user != null) return user;
|
||||
|
||||
var token = Request.Query["token"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
|
||||
try {
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var key = signKeyOptions.Value.GetSecurityKey();
|
||||
var principal = handler.ValidateToken(token, new TokenValidationParameters {
|
||||
IssuerSigningKey = key,
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true
|
||||
}, out _);
|
||||
return authService.GetUserData(principal);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the requester can access asset files directly without repository-level filtering.
|
||||
/// </summary>
|
||||
/// <param name="user">The authenticated user, if any.</param>
|
||||
/// <param name="deletedAt">The asset deletion timestamp.</param>
|
||||
/// <returns><see langword="true"/> for admins, or for curators when the asset is not deleted; otherwise <see langword="false"/>.</returns>
|
||||
private static bool CanAccessAssetDirectly(LactoseAuthenticatedUser? user, DateTime? deletedAt) {
|
||||
return user?.AccessLevel switch {
|
||||
EAccessLevel.Admin => true,
|
||||
EAccessLevel.Curator => deletedAt == null,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,244 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.Person;
|
||||
using Butter.Types;
|
||||
using Lactose.Mapper;
|
||||
using Lactose.Repositories;
|
||||
using Lactose.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
|
||||
namespace Lactose.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages people (faces/individuals identified in assets). Not yet implemented.
|
||||
/// Manages people (faces/individuals identified in assets).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/[controller]")]
|
||||
public class PersonController : ControllerBase {
|
||||
public class PersonController(
|
||||
IPersonRepository personRepository,
|
||||
IAlbumRepository albumRepository,
|
||||
IAssetRepository assetRepository,
|
||||
LactoseAuthService authService
|
||||
) : ControllerBase {
|
||||
/// <summary>
|
||||
/// Gets a person by ID with optional associated assets and albums.
|
||||
/// Gets a person by ID with their associated albums, filtered by access level.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 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="assets">Maximum number of associated assets to include.</param>
|
||||
/// <param name="albums">Maximum number of associated albums to include.</param>
|
||||
/// <returns>The person details.</returns>
|
||||
/// <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, [FromRoute] uint? assets=100, [FromRoute] uint? albums=100) => throw new NotImplementedException();
|
||||
|
||||
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 = await personRepository.FindVisibleAsync(id, uid, accessLevel,
|
||||
albumPaging.Search, albumPaging.SortBy, albumPaging.SortAsc,
|
||||
albumPaging.Page, albumPaging.PageSize, cancellationToken);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
return Ok(person.ToPersonDetailedDto(accessLevel, uid));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches people with pagination.
|
||||
/// 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 and search parameters.</param>
|
||||
/// <returns>A list of people previews.</returns>
|
||||
/// <param name="pagedSearch">Pagination, search, and sort parameters.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the operation.</param>
|
||||
/// <returns>A paginated list of person previews.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<List<PersonPreviewDto>> GetAll(PagedSearchParametersDto pagedSearch) => throw new NotImplementedException();
|
||||
|
||||
public async Task<ActionResult<List<PersonPreviewDto>>> GetAll([FromQuery] PersonSearchParametersDto pagedSearch, CancellationToken cancellationToken) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (pagedSearch.Page < 0 || pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
|
||||
|
||||
var people = await personRepository.SearchQueryAsync(
|
||||
pagedSearch.Search ?? string.Empty,
|
||||
pagedSearch.Page,
|
||||
pagedSearch.PageSize,
|
||||
pagedSearch.SortBy,
|
||||
pagedSearch.SortAsc,
|
||||
uid ?? default,
|
||||
accessLevel,
|
||||
cancellationToken
|
||||
);
|
||||
return Ok(people);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a person.
|
||||
/// </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 IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) =>throw new NotImplementedException();
|
||||
[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();
|
||||
|
||||
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;
|
||||
|
||||
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 IStatusCodeActionResult BulkUpdate([FromBody]BulkDto<PersonUpdateDto> personBulkDto)=>throw new NotImplementedException();
|
||||
public async Task<IActionResult> BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var result = await personRepository.ExecuteInTransactionAsync(async () => {
|
||||
var people = await personRepository.FindBulkAsync(personBulkDto.Ids, CancellationToken.None);
|
||||
|
||||
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.
|
||||
/// Deletes a person by ID (hard delete). Cascades to unlink all associated albums.
|
||||
/// </summary>
|
||||
/// <param name="id">The person ID.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
/// <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 IStatusCodeActionResult Delete([FromRoute] Guid id)=>throw new NotImplementedException();
|
||||
|
||||
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 = await albumRepository.FindByPersonAsync(id, cancellationToken);
|
||||
foreach (var album in albums) {
|
||||
album.PersonOwnerId = null;
|
||||
}
|
||||
|
||||
personRepository.Remove(person);
|
||||
await personRepository.SaveAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes multiple people in a bulk operation.
|
||||
/// 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 IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids)=>throw new NotImplementedException();
|
||||
|
||||
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 = await albumRepository.FindByPersonAsync(person.Id, cancellationToken);
|
||||
foreach (var album in albums) {
|
||||
album.PersonOwnerId = null;
|
||||
}
|
||||
personRepository.Remove(person);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)=>throw new NotImplementedException();
|
||||
public async Task<ActionResult<Guid>> Create([FromBody] PersonCreateDto dto, CancellationToken cancellationToken) {
|
||||
var person = new Models.Person {
|
||||
Id = Guid.NewGuid(),
|
||||
Name = dto.Name,
|
||||
ProfileAssetId = dto.ProfileAssetId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
personRepository.Insert(person);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Butter.Dtos.Stats;
|
||||
using Lactose.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Lactose.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides aggregate statistics about the MilkyShots instance.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="statsRepository">Stats repository.</param>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public class StatsController(
|
||||
ILogger<StatsController> logger,
|
||||
IStatsRepository statsRepository
|
||||
) : ControllerBase {
|
||||
/// <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 async Task<ActionResult<StatsDto>> Get(CancellationToken cancellationToken) {
|
||||
logger.LogTrace("Stats requested");
|
||||
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(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,6 +51,10 @@ namespace Lactose.Controllers {
|
||||
"""
|
||||
);
|
||||
|
||||
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,
|
||||
Email = userCreateDto.Email,
|
||||
@@ -57,11 +62,10 @@ namespace Lactose.Controllers {
|
||||
AccessLevel = userCreateDto.AccessLevel ?? EAccessLevel.User
|
||||
};
|
||||
|
||||
if (userRepository.GetAll().Any(x => x.Email == user.Email)) return Conflict("Email already exists");
|
||||
if (userRepository.GetAll().Any(x => x.Username == user.Username)) return Conflict("Username already exists");
|
||||
user.Password = passwordHasher.HashPassword(user, userCreateDto.Password);
|
||||
|
||||
userRepository.Insert(user);
|
||||
userRepository.Save();
|
||||
await userRepository.SaveAsync(cancellationToken);
|
||||
|
||||
return Ok(user.Id);
|
||||
}
|
||||
@@ -71,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) {
|
||||
@@ -81,34 +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,Curator")]
|
||||
[HttpGet]
|
||||
public ActionResult<List<UserInfoDto>> GetAll() {
|
||||
logger.LogDebug("Sending Users list");
|
||||
public async Task<ActionResult<List<UserInfoDto>>> GetAll(CancellationToken cancellationToken) {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
EAccessLevel viewerAccessLevel = authenticatedUser?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (authenticatedUser == null) {
|
||||
logger.LogWarning(@"Anonymous User hasn't enough permissions to create a User. it shouldn't be here");
|
||||
return Unauthorized("Unauthorized");
|
||||
}
|
||||
logger.LogDebug("Sending Users list");
|
||||
|
||||
//TODO: Lower level of access should fill less information of users
|
||||
//if (false) return Unauthorized();
|
||||
List<User> users = await userRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto()).ToList());
|
||||
return Ok(users.Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
|
||||
}
|
||||
|
||||
|
||||
@@ -116,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) {
|
||||
@@ -140,37 +142,59 @@ 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; }
|
||||
|
||||
if (dto.Email != null) { user.Email = dto.Email; }
|
||||
|
||||
if (dto.Password != null) { user.Password = passwordHasher.HashPassword(user, dto.Password); }
|
||||
if (dto.Password != null) {
|
||||
if (dto.OldPassword != null) {
|
||||
if (passwordHasher.VerifyHashedPassword(user, user.Password, dto.OldPassword) == PasswordVerificationResult.Failed) {
|
||||
return BadRequest("Old password is incorrect");
|
||||
}
|
||||
} else if (authenticatedUser.AccessLevel != EAccessLevel.Admin) {
|
||||
return BadRequest("Old password is required to change your own password");
|
||||
}
|
||||
|
||||
if (dto.IsDeleted != null) { user.DeletedAt = dto.IsDeleted.Value ? DateTime.Now : null; }
|
||||
if (authenticatedUser.AccessLevel != EAccessLevel.Admin) {
|
||||
if (dto.Password.Length < 8) return BadRequest("Password must be at least 8 characters long");
|
||||
if (!dto.Password.Any(char.IsUpper)) return BadRequest("Password must contain at least one uppercase letter");
|
||||
if (!dto.Password.Any(char.IsLower)) return BadRequest("Password must contain at least one lowercase letter");
|
||||
if (!dto.Password.Any(c => char.IsDigit(c) || char.IsPunctuation(c) || char.IsSymbol(c))) return BadRequest("Password must contain at least one number or special character");
|
||||
}
|
||||
|
||||
user.Password = passwordHasher.HashPassword(user, dto.Password);
|
||||
}
|
||||
|
||||
if (dto.IsDeleted != null) { user.DeletedAt = dto.IsDeleted.Value ? DateTime.UtcNow : null; }
|
||||
|
||||
if (authenticatedUser.AccessLevel == EAccessLevel.Admin) {
|
||||
if (dto.IsBanned != null) {
|
||||
if (user.BannedAt != null) user.BannedAt = dto.IsBanned.Value ? DateTime.Now : null;
|
||||
if (user.BannedAt != null) user.BannedAt = dto.IsBanned.Value ? DateTime.UtcNow : null;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -180,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,5 +100,64 @@
|
||||
"365",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Thumbnail Quality",
|
||||
"Value": "75",
|
||||
"Description": "WebP encoding quality for thumbnails (1-100).",
|
||||
"Type": 1,
|
||||
"DisplayType": 7,
|
||||
"Options": [
|
||||
"1",
|
||||
"100",
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Preview Quality",
|
||||
"Value": "85",
|
||||
"Description": "WebP encoding quality for previews (1-100).",
|
||||
"Type": 1,
|
||||
"DisplayType": 7,
|
||||
"Options": [
|
||||
"1",
|
||||
"100",
|
||||
"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
|
||||
}
|
||||
]
|
||||
+15
-7
@@ -1,25 +1,33 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
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
|
||||
EXPOSE 8081
|
||||
EXPOSE 5162
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
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
|
||||
COPY --from=publish /app/publish .
|
||||
RUN mkdir -p thumbnails previews
|
||||
|
||||
ENTRYPOINT ["dotnet", "Lactose.dll"]
|
||||
|
||||
@@ -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}"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
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>
|
||||
/// 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(
|
||||
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 Persons & Albums Job";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task TaskJob(CancellationToken token) {
|
||||
try {
|
||||
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 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 matching active folders with a regex pattern found.");
|
||||
JobStatus.Complete("No matching active folders with a regex pattern found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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)>();
|
||||
|
||||
float folderIndex = 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")) {
|
||||
logger.LogWarning("Folder {FolderId} ({BasePath}) regex lacks a 'person' named group. Skipping.", folder.Id, folder.BasePath);
|
||||
continue;
|
||||
}
|
||||
bool hasGroup = groupNames.Contains("group");
|
||||
|
||||
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));
|
||||
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
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)
|
||||
.Take(ScanBatchSize)
|
||||
.Select(a => new { a.Id, a.OriginalPath })
|
||||
.ToListAsync(token);
|
||||
|
||||
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"];
|
||||
if (!personGroup.Success) return;
|
||||
|
||||
var personName = ToTitleCase(personGroup.Value.Replace('_', ' ').Trim());
|
||||
if (string.IsNullOrWhiteSpace(personName)) return;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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++;
|
||||
}
|
||||
}
|
||||
|
||||
float progress = folderIndex / folders.Count * 0.25f;
|
||||
JobStatus.UpdateProgress(progress,
|
||||
$"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++;
|
||||
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 (totalAssetsMatched == 0)
|
||||
logger.LogInformation("No person/album pairs extracted from asset paths.");
|
||||
|
||||
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 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 };
|
||||
personRepo.Insert(person);
|
||||
existingPersons[name] = person.Id;
|
||||
personCreated++;
|
||||
}
|
||||
if (personCreated > 0) {
|
||||
await personRepo.SaveAsync(token);
|
||||
logger.LogInformation("Created {Count} missing persons.", personCreated);
|
||||
}
|
||||
|
||||
JobStatus.UpdateProgress(0.35f, $"Persons resolved (created {personCreated} new). Checking existing albums...");
|
||||
|
||||
// ── 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);
|
||||
|
||||
// albumName|personId → albumId (existing + newly created)
|
||||
var albumIdByKey = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
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();
|
||||
|
||||
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) {
|
||||
await albumRepo.SaveAsync(token);
|
||||
logger.LogInformation("Created {Count} new albums.", albumCreated);
|
||||
}
|
||||
|
||||
JobStatus.UpdateProgress(0.45f, $"Entities complete: {personCreated} persons, {albumCreated} albums. Assigning assets...");
|
||||
|
||||
// ── Phase 4: Assign assets to albums (bulk, idempotent) ──
|
||||
var personIdByName = existingPersons; // name → id (includes newly created)
|
||||
int assetAssignments = 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);
|
||||
|
||||
while (true) {
|
||||
token.ThrowIfCancellationRequested();
|
||||
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 (rows.Count == 0) break;
|
||||
lastStagingId = rows[^1].Id;
|
||||
processed += rows.Count;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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 (assignmentBuffer.Count > 0) {
|
||||
await InsertAlbumAssetBatch(db, assignmentBuffer, token);
|
||||
assetAssignments += assignmentBuffer.Count;
|
||||
assignmentBuffer.Clear();
|
||||
}
|
||||
|
||||
JobStatus.UpdateProgress(0.70f, $"Assigned {assetAssignments:N0} assets. Setting cover images...");
|
||||
|
||||
// ── Phase 5: Bulk cover assignment (one grouped query) ──
|
||||
int coversAssigned = 0;
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
JobStatus.Complete(
|
||||
$"Created {personCreated} persons, {albumCreated} albums, assigned {assetAssignments:N0} assets.");
|
||||
} catch (OperationCanceledException) {
|
||||
JobStatus.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
@@ -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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+246
-130
@@ -1,187 +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) {
|
||||
lock (childJobs) { childJobs.ForEach(j => j.Cancel()); }
|
||||
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.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) {
|
||||
lock (childJobs) { childJobs.ForEach(j => j.Cancel()); }
|
||||
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.LastWriteTime > (existing.UpdatedAt ?? existing.CreatedAt)) {
|
||||
existing.FileSize = file.Length;
|
||||
existing.UpdatedAt = file.LastWriteTime;
|
||||
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} 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) {
|
||||
lock (childJobs) { childJobs.ForEach(j => j.Cancel()); }
|
||||
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,
|
||||
CreatedAt = finfo.CreationTime,
|
||||
UpdatedAt = finfo.LastWriteTime,
|
||||
MimeType = type.Value switch {
|
||||
Visibility = EVisibility.Private,
|
||||
UploadedBy = uploaderId,
|
||||
CreatedAt = finfo.CreationTimeUtc,
|
||||
UpdatedAt = finfo.LastWriteTimeUtc,
|
||||
IngestedAt = DateTime.UtcNow,
|
||||
MimeType = type.Value switch {
|
||||
EAssetType.Image => mimeImg.MimeType,
|
||||
EAssetType.Video => mimeVid.MimeType,
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
@@ -189,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;
|
||||
}
|
||||
}
|
||||
|
||||
+351
-163
@@ -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;
|
||||
|
||||
@@ -10,191 +11,378 @@ namespace Lactose.Jobs;
|
||||
/// </summary>
|
||||
public class IntegrityCheckJob (ILogger<IntegrityCheckJob> logger, IServiceProvider serviceProvider): Job{
|
||||
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 {
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
logger.LogInformation("Starting integrity check job...");
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
|
||||
|
||||
float progress = 0;
|
||||
logger.LogInformation("Starting integrity check job...");
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
|
||||
|
||||
//Step 1
|
||||
// Check database connection
|
||||
if (!dbContext.Database.CanConnect()) {
|
||||
logger.LogError("Cannot connect to the database.");
|
||||
JobStatus.Fail("Cannot connect to the database.");
|
||||
return;
|
||||
}
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Step 2
|
||||
// Check for pending migrations
|
||||
var pendingMigrations = dbContext.Database.GetPendingMigrations().ToList();
|
||||
|
||||
if (pendingMigrations.Any()) {
|
||||
logger.LogWarning("There are pending migrations:");
|
||||
|
||||
foreach (var migration in pendingMigrations) logger.LogWarning($"- {migration}");
|
||||
|
||||
// Apply migrations
|
||||
try {
|
||||
dbContext.Database.Migrate();
|
||||
logger.LogInformation("Applied pending migrations successfully.");
|
||||
} catch (Exception ex) {
|
||||
logger.LogError(ex, "Error applying migrations.");
|
||||
JobStatus.Fail("Error applying migrations.");
|
||||
// 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);
|
||||
|
||||
// A bit of context: Load() will load all entities from the table into the DbContext.
|
||||
// Essentially it will ask the DB for all rows in the table and create entities for them in the DbContext.
|
||||
// This will basically warm up the cache of the DbContext so that when we later access all the rows in the table,
|
||||
// it won't lazy load them one by one, one call at the time.
|
||||
// This is important because lazy loading one by one would be a huge performance hit,
|
||||
// especially if there are many rows in the table.
|
||||
// While for folders it might not be a big deal, this is just for consistency with all the other steps,
|
||||
// especially the asset one.
|
||||
|
||||
// Step 3
|
||||
// Check for folders existence
|
||||
dbContext.Folders.Load();
|
||||
foreach (Folder folder in dbContext.Folders.ToList()) {
|
||||
if (!Directory.Exists(folder.BasePath)) {
|
||||
logger.LogWarning($"Folder {folder.Id} does not exist. Deactivating.");
|
||||
logger.LogInformation("[1/8] Database connection successful.");
|
||||
ReportStep(1, "Database connection successful.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Step 2 — Pending migrations
|
||||
logger.LogInformation("[2/8] Checking for pending migrations...");
|
||||
var pendingMigrations = dbContext.Database.GetPendingMigrations().ToList();
|
||||
|
||||
if (pendingMigrations.Any()) {
|
||||
logger.LogWarning("Found {Count} pending migrations:", pendingMigrations.Count);
|
||||
|
||||
foreach (var migration in pendingMigrations) logger.LogWarning($"- {migration}");
|
||||
|
||||
try {
|
||||
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.");
|
||||
}
|
||||
ReportStep(2, "Migrations up to date.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// 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();
|
||||
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})");
|
||||
}
|
||||
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 = dbContext.Assets.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
|
||||
.ToList();
|
||||
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);
|
||||
}
|
||||
dbContext.SaveChanges();
|
||||
missingFolderAssets += affectedAssets.Count;
|
||||
await dbContext.SaveChangesAsync(token);
|
||||
logger.LogWarning("Soft-deleted {Count} assets associated with missing folder {FolderId}.", affectedAssets.Count, folder.Id);
|
||||
}
|
||||
}
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Step 4
|
||||
// Check for assets existence
|
||||
dbContext.Assets.Load();
|
||||
foreach (Asset asset in dbContext.Assets.Where(a => a.DeletedAt == null)) {
|
||||
if (File.Exists(asset.OriginalPath)) continue;
|
||||
asset.DeletedAt = DateTime.UtcNow;
|
||||
dbContext.Assets.Update(asset);
|
||||
logger.LogWarning($"Asset {asset.Id} does not exist. Soft-deleting.");
|
||||
}
|
||||
logger.LogInformation("[3/8] Folders checked: {Missing} missing, {Assets} assets soft-deleted.", missingFolders, missingFolderAssets);
|
||||
ReportStep(3, $"Folders checked: {missingFolders} missing.");
|
||||
|
||||
int changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Soft-deleted {Changes} non-existent assets.", changes);
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Step 5
|
||||
// Check for empty albums
|
||||
dbContext.Albums.Load();
|
||||
dbContext.Albums.Include(a => a.Assets)
|
||||
.Where(a => a.Assets!.Count == 0)
|
||||
.ToList().ForEach(a => {
|
||||
logger.LogWarning($"Album {a.Id} is empty. Removing from database.");
|
||||
dbContext.Albums.Remove(a);
|
||||
});
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Removed {Changes} empty albums from the database.", changes);
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Step 6
|
||||
// Check for Persons without faces
|
||||
dbContext.People.Include(p => p.Faces)
|
||||
.Where(p => p.Faces != null && p.Faces.Count == 0 && p.DeletedAt == null)
|
||||
.AsEnumerable()
|
||||
.ForEach(p => {
|
||||
logger.LogWarning($"Person {p.Id} has no faces. Soft-deleting.");
|
||||
p.DeletedAt = DateTime.UtcNow;
|
||||
dbContext.People.Update(p);
|
||||
});
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Removed {Changes} people without faces from the database.", changes);
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Step 7
|
||||
// Check for orphaned thumbnails
|
||||
dbContext.Settings.Load();
|
||||
dbContext.Assets.Load();
|
||||
var thumbnailPath = dbContext.Settings.First(s => s.Name == Settings.ThumbnailPath.AsString()).Value;
|
||||
// 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);
|
||||
|
||||
if (!Directory.Exists(thumbnailPath)) {
|
||||
logger.LogWarning("Thumbnail path does not exist. Creating...");
|
||||
Guid lastId = Guid.Empty;
|
||||
long processed = 0;
|
||||
int deletedAssets = 0;
|
||||
var lastReport = DateTime.UtcNow;
|
||||
|
||||
try {
|
||||
if (thumbnailPath != null)
|
||||
Directory.CreateDirectory(thumbnailPath);
|
||||
} catch (Exception e) {
|
||||
logger.LogError(e, "Could not create thumbnail directory.");
|
||||
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})");
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogWarning("Marking all assets as missing thumbnails.");
|
||||
dbContext.Assets.ForEach(a => a.ThumbnailPath = "");
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Marked {Changes} assets as missing thumbnails.", changes);
|
||||
} else {
|
||||
dbContext.Assets.Where(a => !String.IsNullOrEmpty(a.ThumbnailPath)).AsEnumerable()
|
||||
.Where(a => !File.Exists(a.ThumbnailPath)).AsEnumerable()
|
||||
.ForEach(a => {
|
||||
logger.LogWarning($"Asset {a.Id} has a missing thumbnail. Marking as missing.");
|
||||
a.ThumbnailPath = "";
|
||||
dbContext.Assets.Update(a);
|
||||
});
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Marked {Changes} assets as missing thumbnails.", changes);
|
||||
}
|
||||
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Step 8
|
||||
// Check for orphaned previews
|
||||
|
||||
var previewPath = dbContext.Settings.First(s => s.Name == Settings.PreviewPath.AsString()).Value;
|
||||
if (!Directory.Exists(previewPath)) {
|
||||
logger.LogWarning("Preview path does not exist. Creating...");
|
||||
|
||||
try {
|
||||
if (previewPath != null)
|
||||
Directory.CreateDirectory(previewPath);
|
||||
} catch (Exception e) {
|
||||
logger.LogError(e, "Could not create preview directory.");
|
||||
logger.LogInformation("[4/8] Assets checked: {Deleted} soft-deleted.", deletedAssets);
|
||||
ReportStep(4, $"Assets checked: {deletedAssets:N0} missing.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// 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)
|
||||
.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);
|
||||
}
|
||||
|
||||
logger.LogWarning("Marking all assets as missing previews.");
|
||||
dbContext.Assets.ForEach(a => a.PreviewPath = "");
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Marked {Changes} assets as missing previews.", changes);
|
||||
} else {
|
||||
dbContext.Assets.Where(a => !String.IsNullOrEmpty(a.PreviewPath)).AsEnumerable()
|
||||
.Where(a => !File.Exists(a.PreviewPath)).AsEnumerable()
|
||||
.ForEach(a => {
|
||||
logger.LogWarning($"Asset {a.Id} has a missing preview. Marking as missing.");
|
||||
a.PreviewPath = "";
|
||||
dbContext.Assets.Update(a);
|
||||
});
|
||||
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 — 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 at {Path}. Creating...", thumbnailPath);
|
||||
|
||||
try {
|
||||
if (thumbnailPath != null)
|
||||
Directory.CreateDirectory(thumbnailPath);
|
||||
} catch (Exception e) {
|
||||
logger.LogError(e, "Could not create thumbnail directory.");
|
||||
}
|
||||
|
||||
logger.LogWarning("Marking all assets as missing thumbnails.");
|
||||
missingThumbnails = await dbContext.Assets
|
||||
.Where(a => !string.IsNullOrEmpty(a.ThumbnailPath))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ThumbnailPath, ""), token);
|
||||
} else {
|
||||
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;
|
||||
|
||||
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 — 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 at {Path}. Creating...", previewPath);
|
||||
|
||||
try {
|
||||
if (previewPath != null)
|
||||
Directory.CreateDirectory(previewPath);
|
||||
} catch (Exception e) {
|
||||
logger.LogError(e, "Could not create preview directory.");
|
||||
}
|
||||
|
||||
logger.LogWarning("Marking all assets as missing previews.");
|
||||
missingPreviews = await dbContext.Assets
|
||||
.Where(a => !string.IsNullOrEmpty(a.PreviewPath))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(a => a.PreviewPath, ""), token);
|
||||
} else {
|
||||
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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.LogInformation("[7/8] Previews checked: {Count} marked as missing.", missingPreviews);
|
||||
ReportStep(7, $"Previews: {missingPreviews:N0} missing.");
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// 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();
|
||||
}
|
||||
changes = dbContext.SaveChanges();
|
||||
logger.LogInformation("Marked {Changes} assets as missing previews.", changes);
|
||||
|
||||
IncrementProgress(++progress);
|
||||
|
||||
// Complete
|
||||
logger.LogInformation("Integrity check completed successfully.");
|
||||
JobStatus.Complete("Integrity check completed successfully.");
|
||||
}
|
||||
}
|
||||
|
||||
+47
-4
@@ -1,5 +1,4 @@
|
||||
using Butter.Types;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
@@ -32,22 +31,38 @@ public abstract class Job {
|
||||
public IServiceScope? Scope { get; set; }
|
||||
Task? task;
|
||||
CancellationTokenSource? cts;
|
||||
CancellationToken? _parentToken;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises a new job instance.
|
||||
/// </summary>
|
||||
protected Job() => JobStatus = new JobStatus(this);
|
||||
|
||||
/// <summary>
|
||||
/// Links this job's cancellation token to a parent token. When the parent token is cancelled,
|
||||
/// this job's token is also cancelled automatically — even before <see cref="Start"/> is called.
|
||||
/// </summary>
|
||||
/// <param name="parentToken">The parent job's cancellation token.</param>
|
||||
public void LinkParentToken(CancellationToken parentToken) {
|
||||
_parentToken = parentToken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up the task and the cancellation token, then starts the task (triggering the Started event)
|
||||
/// Also sets up a continuation to handle completion, failure, or cancellation of the task.
|
||||
/// </summary>
|
||||
public virtual void Start() {
|
||||
JobStatus.Start();
|
||||
cts = new CancellationTokenSource();
|
||||
cts = _parentToken.HasValue
|
||||
? CancellationTokenSource.CreateLinkedTokenSource(_parentToken.Value)
|
||||
: new CancellationTokenSource();
|
||||
task = Task.Run(() => TaskJob(cts.Token));
|
||||
|
||||
task.ContinueWith(t => {
|
||||
if (t.IsCanceled && JobStatus.Status != EJobStatus.Canceled) {
|
||||
JobStatus.Cancel();
|
||||
}
|
||||
|
||||
if (t.IsFaulted && JobStatus.Status != EJobStatus.Canceled) {
|
||||
JobStatus.Fail($"Unhandled exception: {t.Exception?.InnerException?.Message}");
|
||||
}
|
||||
@@ -70,6 +85,7 @@ public abstract class Job {
|
||||
Completed?.Invoke(this, JobStatus);
|
||||
break;
|
||||
}
|
||||
cts?.Dispose();
|
||||
Done?.Invoke(this, JobStatus);
|
||||
}
|
||||
);
|
||||
@@ -85,9 +101,18 @@ public abstract class Job {
|
||||
protected abstract Task TaskJob(CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the job if it's running. Canceled event will be called by the callbacks on the task.
|
||||
/// Cancels the job. For running jobs, signals the cancellation token. For queued jobs,
|
||||
/// marks the status as Canceled and fires completion events directly.
|
||||
/// </summary>
|
||||
public virtual void Cancel() => cts?.Cancel();
|
||||
public virtual void Cancel() {
|
||||
if (cts is not null) {
|
||||
cts.Cancel();
|
||||
} else {
|
||||
JobStatus.Cancel();
|
||||
Canceled?.Invoke(this, JobStatus);
|
||||
Done?.Invoke(this, JobStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the job starts running.
|
||||
@@ -121,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;
|
||||
}
|
||||
}
|
||||
+42
-18
@@ -1,8 +1,8 @@
|
||||
using Butter.Dtos.Jobs;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Mapper;
|
||||
using Lactose.Repositories;
|
||||
using Lactose.Utils;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
@@ -35,35 +35,46 @@ 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns children of a given parent job — checks active jobs first, then the database.
|
||||
/// Supports delta sync via the <paramref name="since"/> parameter.
|
||||
/// </summary>
|
||||
public List<JobStatusDto> GetChildren(Guid parentId) {
|
||||
var results = new List<JobStatusDto>();
|
||||
/// <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>
|
||||
/// <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
|
||||
foreach (var job in activeJobs.Values) {
|
||||
if (job.ParentJobId == parentId) {
|
||||
results.Add(job.ToJobStatusDto());
|
||||
}
|
||||
var active = activeJobs.Values
|
||||
.Where(j => j.ParentJobId == parentId)
|
||||
.ToList();
|
||||
|
||||
foreach (var job in active) {
|
||||
seenIds.Add(job.Id);
|
||||
}
|
||||
|
||||
var results = active
|
||||
.Where(j => since is null || j.JobStatus.LastChange > since!.Value)
|
||||
.Select(j => j.ToJobStatusDto())
|
||||
.ToList();
|
||||
|
||||
// Past children in the database
|
||||
try {
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
|
||||
var records = repo.GetChildren(parentId);
|
||||
foreach (var record in records) {
|
||||
results.Add(record.ToJobStatusDto());
|
||||
}
|
||||
var records = since is null
|
||||
? 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);
|
||||
}
|
||||
@@ -168,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.");
|
||||
@@ -183,6 +194,19 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
logger.LogWarning(ex, "Could not load past jobs from database.");
|
||||
}
|
||||
|
||||
// Load MaxConcurrentJobs from the database setting
|
||||
try {
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var settingsRepo = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
|
||||
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);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Could not load MaxConcurrentJobs setting; using default {Default}.", MaxConcurrentJobs);
|
||||
}
|
||||
|
||||
var lifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
|
||||
|
||||
var service = new Task(() => {
|
||||
@@ -204,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();
|
||||
|
||||
@@ -224,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);
|
||||
}
|
||||
@@ -243,6 +267,6 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
|
||||
);
|
||||
|
||||
lifetime.ApplicationStarted.Register(() => service.Start());
|
||||
return service;
|
||||
await service;
|
||||
}
|
||||
}
|
||||
|
||||
+29
-17
@@ -38,6 +38,11 @@ public class JobStatus(Job job) {
|
||||
/// Gets the progress of the job as a value between 0 and 1.
|
||||
/// </summary>
|
||||
public float Progress { get; private set; } = 0;
|
||||
/// <summary>
|
||||
/// Gets the timestamp of the last change to this job's status or progress.
|
||||
/// Used for delta-based children sync.
|
||||
/// </summary>
|
||||
public DateTime LastChange { get; private set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string representation of the job status.
|
||||
@@ -49,10 +54,11 @@ public class JobStatus(Job job) {
|
||||
/// </summary>
|
||||
/// <param name="message">Optional start message.</param>
|
||||
public void Start(string message = "Job started") {
|
||||
Status = EJobStatus.Running;
|
||||
Started = DateTime.UtcNow;
|
||||
Progress = 0;
|
||||
Message = message;
|
||||
Status = EJobStatus.Running;
|
||||
Started = DateTime.UtcNow;
|
||||
Progress = 0;
|
||||
Message = message;
|
||||
LastChange = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -60,8 +66,9 @@ public class JobStatus(Job job) {
|
||||
/// </summary>
|
||||
/// <param name="message">Optional waiting message.</param>
|
||||
public void Wait(string message = "Job waiting") {
|
||||
Status = EJobStatus.Waiting;
|
||||
Message = message;
|
||||
Status = EJobStatus.Waiting;
|
||||
Message = message;
|
||||
LastChange = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -73,7 +80,8 @@ public class JobStatus(Job job) {
|
||||
if (progress < 0 || progress > 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(progress), "Progress must be between 0 and 1");
|
||||
|
||||
Progress = progress;
|
||||
Progress = progress;
|
||||
LastChange = DateTime.UtcNow;
|
||||
if (message != null) Message = message;
|
||||
}
|
||||
|
||||
@@ -82,9 +90,10 @@ public class JobStatus(Job job) {
|
||||
/// </summary>
|
||||
/// <param name="message">Optional completion message.</param>
|
||||
public void Complete(string? message = null) {
|
||||
Status = EJobStatus.Completed;
|
||||
Finished = DateTime.UtcNow;
|
||||
Progress = 1;
|
||||
Status = EJobStatus.Completed;
|
||||
Finished = DateTime.UtcNow;
|
||||
Progress = 1;
|
||||
LastChange = DateTime.UtcNow;
|
||||
if (message != null) Message = message;
|
||||
}
|
||||
|
||||
@@ -93,9 +102,10 @@ public class JobStatus(Job job) {
|
||||
/// </summary>
|
||||
/// <param name="message">Optional completion message.</param>
|
||||
public void CompleteWithErrors(string? message = null) {
|
||||
Status = EJobStatus.CompletedWithErrors;
|
||||
Finished = DateTime.UtcNow;
|
||||
Progress = 1;
|
||||
Status = EJobStatus.CompletedWithErrors;
|
||||
Finished = DateTime.UtcNow;
|
||||
Progress = 1;
|
||||
LastChange = DateTime.UtcNow;
|
||||
if (message != null) Message = message;
|
||||
}
|
||||
|
||||
@@ -104,8 +114,9 @@ public class JobStatus(Job job) {
|
||||
/// </summary>
|
||||
/// <param name="message">Optional failure message.</param>
|
||||
public void Fail(string? message = null) {
|
||||
Status = EJobStatus.Failed;
|
||||
Finished = DateTime.UtcNow;
|
||||
Status = EJobStatus.Failed;
|
||||
Finished = DateTime.UtcNow;
|
||||
LastChange = DateTime.UtcNow;
|
||||
if (message != null) Message = message;
|
||||
}
|
||||
|
||||
@@ -114,8 +125,9 @@ public class JobStatus(Job job) {
|
||||
/// </summary>
|
||||
/// <param name="message">Optional cancellation message.</param>
|
||||
public void Cancel(string? message = null) {
|
||||
Status = EJobStatus.Canceled;
|
||||
Finished = DateTime.UtcNow;
|
||||
Status = EJobStatus.Canceled;
|
||||
Finished = DateTime.UtcNow;
|
||||
LastChange = DateTime.UtcNow;
|
||||
if (message != null) Message = message;
|
||||
}
|
||||
}
|
||||
|
||||
+25
-15
@@ -1,5 +1,4 @@
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using SixLabors.ImageSharp;
|
||||
@@ -21,6 +20,7 @@ public sealed class MetadataJob : Job {
|
||||
int failedAssets;
|
||||
int totalAssets = 1;
|
||||
int batchSize;
|
||||
List<Job> childJobs = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name { get; }
|
||||
@@ -55,11 +55,11 @@ public sealed class MetadataJob : Job {
|
||||
if (ParentJob == null) {
|
||||
await MasterJob(token);
|
||||
} else {
|
||||
SlaveJob(token);
|
||||
await SlaveJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
void SlaveJob(CancellationToken token) {
|
||||
async Task SlaveJob(CancellationToken token) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
@@ -84,7 +84,9 @@ public sealed class MetadataJob : Job {
|
||||
|
||||
try {
|
||||
if (string.IsNullOrEmpty(asset.OriginalPath) || !File.Exists(asset.OriginalPath)) {
|
||||
Logger.LogWarning($"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}");
|
||||
var errorMsg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
|
||||
Logger.LogWarning(errorMsg);
|
||||
JobHelpers.MarkBroken(asset, errorMsg);
|
||||
failedAssets++;
|
||||
continue;
|
||||
}
|
||||
@@ -95,16 +97,23 @@ 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}/{Batch.Length} assets, {failedAssets} failed.";
|
||||
var msg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
||||
Logger.LogInformation(msg);
|
||||
JobStatus.Complete(msg);
|
||||
|
||||
if (failedAssets == Batch.Length)
|
||||
JobStatus.Fail($"All {failedAssets:N0} assets in batch failed.");
|
||||
else if (failedAssets > 0)
|
||||
JobStatus.CompleteWithErrors(msg);
|
||||
else
|
||||
JobStatus.Complete(msg);
|
||||
}
|
||||
|
||||
async Task MasterJob(CancellationToken token) {
|
||||
@@ -117,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) {
|
||||
@@ -133,19 +142,20 @@ 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;
|
||||
|
||||
var job = JobManager.CreateJob<MetadataJob>(this, batchList, batchSize);
|
||||
job.LinkParentToken(token);
|
||||
lock (childJobs) { childJobs.Add(job); }
|
||||
|
||||
job.Done += (o, _) => {
|
||||
var sub = (MetadataJob)o!;
|
||||
if (sub.JobStatus.Status == EJobStatus.Failed)
|
||||
Interlocked.Increment(ref failedAssets);
|
||||
lock (childJobs) { childJobs.Remove(sub); }
|
||||
Interlocked.Add(ref processedAssets, sub.processedAssets);
|
||||
failedAssets += sub.failedAssets;
|
||||
Interlocked.Add(ref failedAssets, sub.failedAssets);
|
||||
if (Interlocked.Decrement(ref pending) == 0)
|
||||
childTcs.TrySetResult();
|
||||
};
|
||||
@@ -155,7 +165,7 @@ public sealed class MetadataJob : Job {
|
||||
|
||||
JobStatus.UpdateProgress(
|
||||
(float)offset / totalAssets,
|
||||
$"Created batch at {offset}/{totalAssets}."
|
||||
$"Created batch at {offset:N0}/{totalAssets:N0}."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,7 +188,7 @@ public sealed class MetadataJob : Job {
|
||||
}
|
||||
|
||||
if (failedAssets > 0)
|
||||
JobStatus.CompleteWithErrors($"{failedAssets} of {totalAssets} metadata extractions failed.");
|
||||
JobStatus.CompleteWithErrors($"{failedAssets:N0} of {totalAssets:N0} metadata extractions failed.");
|
||||
else
|
||||
JobStatus.Complete("All metadata extracted successfully.");
|
||||
}
|
||||
|
||||
+27
-16
@@ -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;
|
||||
|
||||
@@ -24,6 +23,7 @@ public sealed class PHashJob : Job {
|
||||
int failedAssets;
|
||||
int totalAssets = 1;
|
||||
int batchSize;
|
||||
List<Job> childJobs = [];
|
||||
ILogger<PHashJob> Logger { get; }
|
||||
/// <inheritdoc />
|
||||
public override string Name { get; }
|
||||
@@ -58,11 +58,11 @@ public sealed class PHashJob : Job {
|
||||
if (ParentJob == null) {
|
||||
await MasterJob(token);
|
||||
} else {
|
||||
SlaveJob(token);
|
||||
await SlaveJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
void SlaveJob(CancellationToken token) {
|
||||
async Task SlaveJob(CancellationToken token) {
|
||||
if (token.IsCancellationRequested) {
|
||||
JobStatus.Cancel("Cancellation requested from user.");
|
||||
return;
|
||||
@@ -93,28 +93,38 @@ 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}/{Batch.Length} assets, {failedAssets} failed.";
|
||||
var msg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
||||
Logger.LogInformation(msg);
|
||||
JobStatus.Complete(msg);
|
||||
|
||||
if (failedAssets == Batch.Length)
|
||||
JobStatus.Fail($"All {failedAssets:N0} assets in batch failed.");
|
||||
else if (failedAssets > 0)
|
||||
JobStatus.CompleteWithErrors(msg);
|
||||
else
|
||||
JobStatus.Complete(msg);
|
||||
}
|
||||
|
||||
async Task MasterJob(CancellationToken token) {
|
||||
@@ -127,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) {
|
||||
@@ -143,19 +153,20 @@ 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;
|
||||
|
||||
var job = JobManager.CreateJob<PHashJob>(this, batchList, batchSize);
|
||||
job.LinkParentToken(token);
|
||||
lock (childJobs) { childJobs.Add(job); }
|
||||
|
||||
job.Done += (o, _) => {
|
||||
var sub = (PHashJob)o!;
|
||||
if (sub.JobStatus.Status == EJobStatus.Failed)
|
||||
Interlocked.Increment(ref failedAssets);
|
||||
lock (childJobs) { childJobs.Remove(sub); }
|
||||
Interlocked.Add(ref processedAssets, sub.processedAssets);
|
||||
failedAssets += sub.failedAssets;
|
||||
Interlocked.Add(ref failedAssets, sub.failedAssets);
|
||||
if (Interlocked.Decrement(ref pending) == 0)
|
||||
childTcs.TrySetResult();
|
||||
};
|
||||
@@ -165,7 +176,7 @@ public sealed class PHashJob : Job {
|
||||
|
||||
JobStatus.UpdateProgress(
|
||||
(float)offset / totalAssets,
|
||||
$"Created batch at {offset}/{totalAssets}."
|
||||
$"Created batch at {offset:N0}/{totalAssets:N0}."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,7 +199,7 @@ public sealed class PHashJob : Job {
|
||||
}
|
||||
|
||||
if (failedAssets > 0)
|
||||
JobStatus.CompleteWithErrors($"{failedAssets} of {totalAssets} pHash calculations failed.");
|
||||
JobStatus.CompleteWithErrors($"{failedAssets:N0} of {totalAssets:N0} pHash calculations failed.");
|
||||
else
|
||||
JobStatus.Complete("All pHash calculations completed.");
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
using Butter.Settings;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Webp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Generates WebP preview images for assets missing them, with wrong dimensions, or wrong format.
|
||||
/// Master creates one sub-job per batch of assets.
|
||||
/// </summary>
|
||||
public class PreviewJob : Job {
|
||||
PreviewJob? ParentJob;
|
||||
Asset[]? Batch;
|
||||
int PreviewSize;
|
||||
int PreviewQuality;
|
||||
string? PreviewPath;
|
||||
string ExpectedFormat = "webp";
|
||||
int processedAssets;
|
||||
int failedAssets;
|
||||
int totalAssets = 1;
|
||||
int batchSize;
|
||||
List<Job> childJobs = [];
|
||||
ILogger<PreviewJob> Logger;
|
||||
ISettingsRepository SettingsRepository;
|
||||
IAssetRepository AssetRepository;
|
||||
JobManager? JobManager;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a master preview generation job.
|
||||
/// </summary>
|
||||
public PreviewJob(
|
||||
ILogger<PreviewJob> logger,
|
||||
ISettingsRepository settingsRepository,
|
||||
IAssetRepository assetRepository,
|
||||
JobManager jobManager
|
||||
) {
|
||||
Logger = logger;
|
||||
SettingsRepository = settingsRepository;
|
||||
AssetRepository = assetRepository;
|
||||
JobManager = jobManager;
|
||||
Name = "Preview Generation Job";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a sub-job to generate previews for a batch of assets.
|
||||
/// </summary>
|
||||
public PreviewJob(
|
||||
PreviewJob parentJob,
|
||||
IEnumerable<Asset> batch,
|
||||
int previewSize,
|
||||
string previewPath,
|
||||
int previewQuality,
|
||||
string expectedFormat,
|
||||
int batchSize,
|
||||
ILogger<PreviewJob> logger,
|
||||
ISettingsRepository settingsRepository,
|
||||
IAssetRepository assetRepository
|
||||
) {
|
||||
Logger = logger;
|
||||
SettingsRepository = settingsRepository;
|
||||
AssetRepository = assetRepository;
|
||||
Batch = batch.ToArray();
|
||||
Name = $"Preview Batch ({Batch.Length} assets)";
|
||||
ParentJob = parentJob;
|
||||
ParentJobId = parentJob.Id;
|
||||
PreviewSize = previewSize;
|
||||
PreviewPath = previewPath;
|
||||
PreviewQuality = previewQuality;
|
||||
ExpectedFormat = expectedFormat;
|
||||
this.batchSize = batchSize;
|
||||
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 (PreviewPath == null) {
|
||||
Logger.LogError("Preview path is not set. Cannot save previews.");
|
||||
JobStatus.Fail("Preview 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 msg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
|
||||
Logger.LogWarning(msg);
|
||||
JobHelpers.MarkBroken(asset, msg);
|
||||
failedAssets++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(asset.PreviewPath) && File.Exists(asset.PreviewPath)) {
|
||||
Logger.LogInformation("Removing stale preview for asset ID {Id} at {Path}.", asset.Id, asset.PreviewPath);
|
||||
File.Delete(asset.PreviewPath);
|
||||
asset.PreviewPath = "";
|
||||
}
|
||||
|
||||
using var image = Image.Load(asset.OriginalPath);
|
||||
|
||||
image.Mutate(x => x.Resize(
|
||||
new ResizeOptions {
|
||||
Size = new Size(PreviewSize),
|
||||
Mode = ResizeMode.Max,
|
||||
Sampler = KnownResamplers.CatmullRom
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
var path = PathUtils.PathFromGuid(asset.Id, PreviewPath!);
|
||||
|
||||
if (!Directory.Exists(Path.GetDirectoryName(path)))
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
image.SaveAsWebp(
|
||||
path,
|
||||
new WebpEncoder() {
|
||||
Quality = PreviewQuality,
|
||||
SkipMetadata = true
|
||||
}
|
||||
);
|
||||
|
||||
asset.PreviewPath = path;
|
||||
asset.PreviewSize = PreviewSize;
|
||||
asset.PreviewFormat = ExpectedFormat;
|
||||
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);
|
||||
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 Preview job.");
|
||||
|
||||
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.");
|
||||
JobStatus.Fail("Preview path not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(previewSizeSetting?.Value, out var previewSize)) {
|
||||
previewSize = 2048;
|
||||
Logger.LogWarning("Preview Size setting not found or invalid. Defaulting to {Size}.", previewSize);
|
||||
}
|
||||
|
||||
if (!int.TryParse(previewQualitySetting?.Value, out var previewQuality)) {
|
||||
previewQuality = 85;
|
||||
Logger.LogWarning("Preview Quality setting not found or invalid. Defaulting to {Quality}.", previewQuality);
|
||||
}
|
||||
|
||||
previewQuality = Math.Clamp(previewQuality, 1, 100);
|
||||
|
||||
batchSize = 200;
|
||||
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
|
||||
batchSize = Math.Max(1, parsed);
|
||||
|
||||
totalAssets = await AssetRepository.CountAssetsMissingOrWrongPreviewAsync(previewSize, ExpectedFormat, token);
|
||||
Logger.LogInformation("Found {Count} assets needing previews.", totalAssets);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
JobStatus.Complete("No assets need previews.");
|
||||
return;
|
||||
}
|
||||
|
||||
var childTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
int pending = 0;
|
||||
|
||||
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
||||
var batch = await AssetRepository.GetAssetsMissingOrWrongPreviewAsync(previewSize, ExpectedFormat, batchSize, offset, token);
|
||||
var batchList = batch.ToList();
|
||||
|
||||
if (batchList.Count == 0) continue;
|
||||
|
||||
var job = JobManager.CreateJob<PreviewJob>(
|
||||
this, batchList, previewSize, previewPathSetting.Value!, previewQuality, ExpectedFormat, batchSize
|
||||
);
|
||||
job.LinkParentToken(token);
|
||||
lock (childJobs) { childJobs.Add(job); }
|
||||
|
||||
int capturedOffset = offset;
|
||||
job.Done += (o, _) => {
|
||||
var sub = (PreviewJob)o!;
|
||||
lock (childJobs) { childJobs.Remove(sub); }
|
||||
Interlocked.Add(ref processedAssets, sub.processedAssets);
|
||||
Interlocked.Add(ref failedAssets, sub.failedAssets);
|
||||
if (Interlocked.Decrement(ref pending) == 0)
|
||||
childTcs.TrySetResult();
|
||||
};
|
||||
|
||||
Interlocked.Increment(ref pending);
|
||||
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 (failedAssets > 0)
|
||||
JobStatus.CompleteWithErrors($"{failedAssets:N0} of {totalAssets:N0} previews failed.");
|
||||
else
|
||||
JobStatus.Complete("All previews generated successfully.");
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,28 @@
|
||||
using Butter.Dtos.Settings;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||
using SixLabors.ImageSharp.Formats.Webp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Generates JPEG thumbnails for assets missing them or with wrong dimensions.
|
||||
/// Generates WebP thumbnails for assets missing them, with wrong dimensions, or wrong format.
|
||||
/// Master creates one sub-job per batch of assets.
|
||||
/// </summary>
|
||||
public class ThumbnailJob : Job {
|
||||
ThumbnailJob? ParentJob;
|
||||
Asset[]? Batch;
|
||||
int ThumbnailSize;
|
||||
int ThumbnailQuality;
|
||||
string? ThumbnailPath;
|
||||
string ExpectedFormat = "webp";
|
||||
int processedAssets;
|
||||
int failedAssets;
|
||||
int totalAssets = 1;
|
||||
int batchSize;
|
||||
List<Job> childJobs = [];
|
||||
ILogger<ThumbnailJob> Logger;
|
||||
ISettingsRepository SettingsRepository;
|
||||
IAssetRepository AssetRepository;
|
||||
@@ -54,6 +55,8 @@ public class ThumbnailJob : Job {
|
||||
IEnumerable<Asset> batch,
|
||||
int thumbnailSize,
|
||||
string thumbnailPath,
|
||||
int thumbnailQuality,
|
||||
string expectedFormat,
|
||||
int batchSize,
|
||||
ILogger<ThumbnailJob> logger,
|
||||
ISettingsRepository settingsRepository,
|
||||
@@ -68,6 +71,8 @@ public class ThumbnailJob : Job {
|
||||
ParentJobId = parentJob.Id;
|
||||
ThumbnailSize = thumbnailSize;
|
||||
ThumbnailPath = thumbnailPath;
|
||||
ThumbnailQuality = thumbnailQuality;
|
||||
ExpectedFormat = expectedFormat;
|
||||
this.batchSize = batchSize;
|
||||
totalAssets = Batch.Length;
|
||||
}
|
||||
@@ -77,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;
|
||||
@@ -114,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;
|
||||
}
|
||||
@@ -135,35 +141,42 @@ 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)!);
|
||||
|
||||
image.SaveAsJpeg(
|
||||
image.SaveAsWebp(
|
||||
path,
|
||||
new JpegEncoder() {
|
||||
ColorType = JpegEncodingColor.Rgb,
|
||||
Quality = 75,
|
||||
new WebpEncoder() {
|
||||
Quality = ThumbnailQuality,
|
||||
SkipMetadata = true
|
||||
}
|
||||
);
|
||||
|
||||
asset.ThumbnailPath = path;
|
||||
asset.ThumbnailSize = ThumbnailSize;
|
||||
asset.ThumbnailPath = path;
|
||||
asset.ThumbnailSize = ThumbnailSize;
|
||||
asset.ThumbnailFormat = ExpectedFormat;
|
||||
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}/{Batch.Length} assets, {failedAssets} failed.";
|
||||
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
|
||||
Logger.LogInformation(resultMsg);
|
||||
JobStatus.Complete(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) {
|
||||
@@ -176,9 +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 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.");
|
||||
@@ -191,11 +205,18 @@ public class ThumbnailJob : Job {
|
||||
Logger.LogWarning("Thumbnail Size setting not found or invalid. Defaulting to {Size}.", thumbnailSize);
|
||||
}
|
||||
|
||||
if (!int.TryParse(thumbQualitySetting?.Value, out var thumbnailQuality)) {
|
||||
thumbnailQuality = 75;
|
||||
Logger.LogWarning("Thumbnail Quality setting not found or invalid. Defaulting to {Quality}.", thumbnailQuality);
|
||||
}
|
||||
|
||||
thumbnailQuality = Math.Clamp(thumbnailQuality, 1, 100);
|
||||
|
||||
batchSize = 200;
|
||||
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
|
||||
batchSize = Math.Max(1, parsed);
|
||||
|
||||
totalAssets = AssetRepository.CountAssetsMissingOrWrongThumbnail(thumbnailSize);
|
||||
totalAssets = await AssetRepository.CountAssetsMissingOrWrongThumbnailAsync(thumbnailSize, ExpectedFormat, token);
|
||||
Logger.LogInformation("Found {Count} assets needing thumbnails.", totalAssets);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
@@ -207,22 +228,23 @@ public class ThumbnailJob : Job {
|
||||
int pending = 0;
|
||||
|
||||
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
|
||||
var batch = AssetRepository.GetAssetsMissingOrWrongThumbnail(thumbnailSize, batchSize, offset);
|
||||
var batch = await AssetRepository.GetAssetsMissingOrWrongThumbnailAsync(thumbnailSize, ExpectedFormat, batchSize, offset, token);
|
||||
var batchList = batch.ToList();
|
||||
|
||||
if (batchList.Count == 0) continue;
|
||||
|
||||
var job = JobManager.CreateJob<ThumbnailJob>(
|
||||
this, batchList, thumbnailSize, thumbPathSetting.Value!, batchSize
|
||||
this, batchList, thumbnailSize, thumbPathSetting.Value!, thumbnailQuality, ExpectedFormat, batchSize
|
||||
);
|
||||
job.LinkParentToken(token);
|
||||
lock (childJobs) { childJobs.Add(job); }
|
||||
|
||||
int capturedOffset = offset;
|
||||
job.Done += (o, _) => {
|
||||
var sub = (ThumbnailJob)o!;
|
||||
if (sub.JobStatus.Status == EJobStatus.Failed)
|
||||
Interlocked.Increment(ref failedAssets);
|
||||
lock (childJobs) { childJobs.Remove(sub); }
|
||||
Interlocked.Add(ref processedAssets, sub.processedAssets);
|
||||
failedAssets += sub.failedAssets;
|
||||
Interlocked.Add(ref failedAssets, sub.failedAssets);
|
||||
if (Interlocked.Decrement(ref pending) == 0)
|
||||
childTcs.TrySetResult();
|
||||
};
|
||||
@@ -232,7 +254,7 @@ public class ThumbnailJob : Job {
|
||||
|
||||
JobStatus.UpdateProgress(
|
||||
(float)offset / totalAssets,
|
||||
$"Created batch at {offset}/{totalAssets}."
|
||||
$"Created batch at {offset:N0}/{totalAssets:N0}."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -255,20 +277,8 @@ public class ThumbnailJob : Job {
|
||||
}
|
||||
|
||||
if (failedAssets > 0)
|
||||
JobStatus.CompleteWithErrors($"{failedAssets} of {totalAssets} thumbnails failed.");
|
||||
JobStatus.CompleteWithErrors($"{failedAssets:N0} of {totalAssets:N0} thumbnails failed.");
|
||||
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}.jpg"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-11
@@ -1,31 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<UserSecretsId>00b763fc-028f-4464-9478-fc1006aeffa8</UserSecretsId>
|
||||
<LangVersion>12</LangVersion>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.15" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.15" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.15">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" 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="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.2" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -35,13 +35,19 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Migrations\"/>
|
||||
<Folder Include="Migrations\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Butter\Butter.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Lactose.Analyzers\Lactose.Analyzers.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="DefaultSettings.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Mapper;
|
||||
@@ -8,30 +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,
|
||||
Images = album.Assets!.Select(asset => asset.Id).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 {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Owner = album.UserOwnerId,
|
||||
Person = album.PersonOwnerId
|
||||
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album, EAccessLevel accessLevel, Guid? viewerId = null) => new AlbumPreviewDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
CoverWidth = album.CoverAsset?.ResolutionWidth,
|
||||
CoverHeight = album.CoverAsset?.ResolutionHeight,
|
||||
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||
DeletedAt = null
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
/// <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,43 +10,71 @@ 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 {
|
||||
Id = asset.Id,
|
||||
MimeType = asset.MimeType
|
||||
/// <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 = 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 = 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?
|
||||
.Where(a => a.PersonOwner != null)
|
||||
.Select(a => a.PersonOwner!.Name)
|
||||
.Distinct()
|
||||
.ToList(),
|
||||
CosplayerIds = asset.Albums?
|
||||
.Where(a => a.PersonOwner != null)
|
||||
.Select(a => a.PersonOwner!.Id)
|
||||
.Distinct()
|
||||
.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));
|
||||
}
|
||||
@@ -26,9 +26,12 @@ public static class JobMapper {
|
||||
JobType = job switch {
|
||||
FileSystemCrawlJob => EJobType.FileSystemScan,
|
||||
ThumbnailJob => EJobType.ThumbnailGeneration,
|
||||
PreviewJob => EJobType.PreviewGeneration,
|
||||
IntegrityCheckJob => EJobType.IntegrityCheck,
|
||||
PHashJob => EJobType.PHashGeneration,
|
||||
MetadataJob => EJobType.MetadataExtraction,
|
||||
CreateAlbumsJob => EJobType.CreateAlbums,
|
||||
ConvertAnimatedJob => EJobType.AnimatedConversion,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(job))
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user