mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Compare commits
630 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f09224152a | ||
|
|
df93670598 | ||
|
|
073fb3a94a | ||
|
|
53c4165d82 | ||
|
|
8cd4550189 | ||
|
|
2b2e4fefab | ||
|
|
5f93648297 | ||
|
|
8a628f0bd0 | ||
|
|
b67c8598d6 | ||
|
|
0254c9d0e9 | ||
|
|
ecb512995c | ||
|
|
f8b9fa9b20 | ||
|
|
5d4917c8d9 | ||
|
|
a50309c22e | ||
|
|
f5020e081f | ||
|
|
3c0bfcb226 | ||
|
|
9198a23ba9 | ||
|
|
02bac7edfb | ||
|
|
ea1d1a49c9 | ||
|
|
9a789f8f08 | ||
|
|
1971881537 | ||
|
|
4eb46a8d3e | ||
|
|
36f28b3c65 | ||
|
|
2452cc4df1 | ||
|
|
eda1ce9743 | ||
|
|
e24621a0af | ||
|
|
7173a2b9d6 | ||
|
|
d540b21aac | ||
|
|
9952721e76 | ||
|
|
26e4895807 | ||
|
|
c533a8e7bf | ||
|
|
dc820a456f | ||
|
|
07721af87c | ||
|
|
5093c30c06 | ||
|
|
8c77080ae6 | ||
|
|
bcf72c6bcc | ||
|
|
3849f7eef9 | ||
|
|
7eced1e3e9 | ||
|
|
51b5261f40 | ||
|
|
963f6b1383 | ||
|
|
b75baa1d1a | ||
|
|
6d95e93378 | ||
|
|
7117e0c33e | ||
|
|
d261474f3a | ||
|
|
c09d67d2e4 | ||
|
|
1427dc8e38 | ||
|
|
77a7b90dc7 | ||
|
|
e9d55fe146 | ||
|
|
57f369a6de | ||
|
|
059ebeead7 | ||
|
|
831a9da9d7 | ||
|
|
6000e08640 | ||
|
|
3edc65c106 | ||
|
|
655157434e | ||
|
|
3661b11b70 | ||
|
|
0e73db0669 | ||
|
|
8158441a92 | ||
|
|
5600471093 | ||
|
|
354cf03bbc | ||
|
|
645b7c247d | ||
|
|
5f25a29303 | ||
|
|
906d00106d | ||
|
|
7850131969 | ||
|
|
3d5ec4a9f1 | ||
|
|
1cdbb9a851 | ||
|
|
e224be4b88 | ||
|
|
b9d3a4afce | ||
|
|
aa4aa1a613 | ||
|
|
cc8e1c5049 | ||
|
|
41e649415a | ||
|
|
c8f770a86b | ||
|
|
29bb85359e | ||
|
|
4557da8b63 | ||
|
|
09b75de25b | ||
|
|
415fc5720c | ||
|
|
4dd8ce778e | ||
|
|
f81ff2efe9 | ||
|
|
837bb17b08 | ||
|
|
5ee93a27ee | ||
|
|
2e6aa5fe9f | ||
|
|
c14e066f8f | ||
|
|
c09100c22e | ||
|
|
839ed3bda3 | ||
|
|
1f627774c1 | ||
|
|
3b842355c2 | ||
|
|
dd27411ebf | ||
|
|
388ff7f5b4 | ||
|
|
f76343f389 | ||
|
|
ce5a1ae3d0 | ||
|
|
1d40d7400f | ||
|
|
1bb5d0b072 | ||
|
|
c3932538e1 | ||
|
|
a68141adf4 | ||
|
|
fb8ba4c076 | ||
|
|
4ed3bd9039 | ||
|
|
ba6e2eadba | ||
|
|
1c16392367 | ||
|
|
035ad4b473 | ||
|
|
a7ee883227 | ||
|
|
ddf9e33961 | ||
|
|
4301b3455f | ||
|
|
3d6bb432c4 | ||
|
|
6c03aa1430 | ||
|
|
5376fd8724 | ||
|
|
6dea9a76bc | ||
|
|
d73903e82e | ||
|
|
4862419b61 | ||
|
|
e6e7df7454 | ||
|
|
30f9e3e2ec | ||
|
|
707d0cb8a4 | ||
|
|
56ea7594ce | ||
|
|
389e46c251 | ||
|
|
6db17e682a | ||
|
|
94e0308a12 | ||
|
|
1f9f821576 | ||
|
|
57933dfba6 | ||
|
|
c50bee7757 | ||
|
|
4e3ee843f9 | ||
|
|
7e40f6fcb9 | ||
|
|
7976956b6b | ||
|
|
adce5293d5 | ||
|
|
c2db5eb6df | ||
|
|
f958ecdf18 | ||
|
|
ef0bcc6cf1 | ||
|
|
285428ad3a | ||
|
|
ee18cff3d9 | ||
|
|
1be3235564 | ||
|
|
a92883509a | ||
|
|
ce42d83ce9 | ||
|
|
077cf7b574 | ||
|
|
b99d78bda6 | ||
|
|
39586f4a20 | ||
|
|
4ef750b206 | ||
|
|
9d3d93823d | ||
|
|
45c1113b72 | ||
|
|
e10717dcda | ||
|
|
315ab6f70b | ||
|
|
cf4d654c4b | ||
|
|
569c829709 | ||
|
|
de05b59f29 | ||
|
|
70a282a6c0 | ||
|
|
b10bcf7e78 | ||
|
|
5fb10263f3 | ||
|
|
9e76c9783e | ||
|
|
7770976513 | ||
|
|
dc1f7ab6fe | ||
|
|
32b1d6c561 | ||
|
|
5264e49f2a | ||
|
|
ce3adaf831 | ||
|
|
e2f3e57f5c | ||
|
|
5c2349ff42 | ||
|
|
50eee8c373 | ||
|
|
f89b792535 | ||
|
|
6d0ea2841c | ||
|
|
98678a8698 | ||
|
|
5326fa2970 | ||
|
|
90547670a2 | ||
|
|
4753206c52 | ||
|
|
613aa3b1c3 | ||
|
|
a6b704d4b4 | ||
|
|
227d06c736 | ||
|
|
8508763831 | ||
|
|
136d3153fa | ||
|
|
49bdf77040 | ||
|
|
f4dcd89835 | ||
|
|
139e915711 | ||
|
|
22eda58074 | ||
|
|
fb91cf4df2 | ||
|
|
e0332571da | ||
|
|
2d4bc47746 | ||
|
|
38e766484e | ||
|
|
b5ee4a6408 | ||
|
|
7892df21ec | ||
|
|
188fe407b6 | ||
|
|
600afdcd92 | ||
|
|
994fa4bd43 | ||
|
|
51098f2829 | ||
|
|
795b9e8418 | ||
|
|
9ca2b9dd56 | ||
|
|
d77b6d78b7 | ||
|
|
427e7a36d5 | ||
|
|
c90306cc9b | ||
|
|
5fe0660c64 | ||
|
|
2abb5bf122 | ||
|
|
bb65527469 | ||
|
|
d9a6db3359 | ||
|
|
58cafdb713 | ||
|
|
0594e278b6 | ||
|
|
807425f12a | ||
|
|
aa4b1ccc25 | ||
|
|
58255ec28b | ||
|
|
d62b84693d | ||
|
|
df75c7e68d | ||
|
|
c5c7fdf54f | ||
|
|
49e0deeff3 | ||
|
|
0c20701bef | ||
|
|
faa26651dd | ||
|
|
2eae8a7729 | ||
|
|
dde2b2a960 | ||
|
|
4a9089d3dd | ||
|
|
3244a5f1a1 | ||
|
|
449c1e9d10 | ||
|
|
d0aa916683 | ||
|
|
13433f8cd2 | ||
|
|
8d336320c0 | ||
|
|
d945c58d51 | ||
|
|
acaf122346 | ||
|
|
713759b411 | ||
|
|
c5175bb870 | ||
|
|
e63ef8d031 | ||
|
|
e043537241 | ||
|
|
46126f9950 | ||
|
|
f4eb916914 | ||
|
|
49b9b7a5ea | ||
|
|
9b1a9ee071 | ||
|
|
0b8f137a1b | ||
|
|
6148a12301 | ||
|
|
fadbf21b4f | ||
|
|
c38a06937d | ||
|
|
1a34403b0e | ||
|
|
e4d58d0f60 | ||
|
|
4e4ea85cc3 | ||
|
|
f7a856349a | ||
|
|
15edd7a42c | ||
|
|
46243a236d | ||
|
|
6f382e587a | ||
|
|
bf3d706bf4 | ||
|
|
cdf21e813c | ||
|
|
10f5588e4a | ||
|
|
0ecbdf6f39 | ||
|
|
61101a7ad0 | ||
|
|
6d9be814a5 | ||
|
|
52bf93e430 | ||
|
|
00fade756c | ||
|
|
3c0feb23ba | ||
|
|
3627840fe9 | ||
|
|
bbdc1bba87 | ||
|
|
21a1bc1a01 | ||
|
|
0968698804 | ||
|
|
a5b2e9b0bf | ||
|
|
5a6ff444b9 | ||
|
|
3bb240d3c1 | ||
|
|
ee0d241c75 | ||
|
|
321ff72953 | ||
|
|
412f1e62a1 | ||
|
|
8901b32a55 | ||
|
|
8ab6cc72ad | ||
|
|
52e671638b | ||
|
|
a3070f8d82 | ||
|
|
3fde474583 | ||
|
|
1454991d6d | ||
|
|
4398851bb9 | ||
|
|
5173aa6c20 | ||
|
|
3d98572a62 | ||
|
|
c48095d9c6 | ||
|
|
1e4d1b8f15 | ||
|
|
8c037465ba | ||
|
|
055c1ca0d4 | ||
|
|
27370df93a | ||
|
|
60d23aa238 | ||
|
|
5e441d9c4f | ||
|
|
eb76468280 | ||
|
|
01bbaa31a8 | ||
|
|
bddf023dc4 | ||
|
|
8e69a247ed | ||
|
|
97141b01e1 | ||
|
|
acf610ddff | ||
|
|
a9a6f66035 | ||
|
|
0040863a03 | ||
|
|
4ab86b4ae2 | ||
|
|
b32b4b4042 | ||
|
|
4e552dcf3e | ||
|
|
8f4c02efdc | ||
|
|
b77c596f3a | ||
|
|
181f0b5626 | ||
|
|
480e5d966f | ||
|
|
e8636b949d | ||
|
|
8ea369db47 | ||
|
|
ec9b37eb53 | ||
|
|
b0847f6b87 | ||
|
|
84d10b1f3b | ||
|
|
4fdc97d062 | ||
|
|
5fe5e7ea54 | ||
|
|
7be1a2bd65 | ||
|
|
87842385c6 | ||
|
|
1dc189eb39 | ||
|
|
6120922204 | ||
|
|
ddb30dbb17 | ||
|
|
1e8bd88e28 | ||
|
|
c3a66ecf28 | ||
|
|
1f60160e8b | ||
|
|
7d560bf07a | ||
|
|
47da9949d9 | ||
|
|
68c0a5ba71 | ||
|
|
1aa81c803b | ||
|
|
8f5e134d3e | ||
|
|
ef03a2a917 | ||
|
|
e275968553 | ||
|
|
76d3aa2b5b | ||
|
|
c9a65c7347 | ||
|
|
f542ade628 | ||
|
|
d2c2bfbe6a | ||
|
|
2b6910bd55 | ||
|
|
b1dd733493 | ||
|
|
5dcf0a1e48 | ||
|
|
cf357b57fc | ||
|
|
4e1773833f | ||
|
|
8cf762ffd3 | ||
|
|
d997eaa429 | ||
|
|
8e51f0f19f | ||
|
|
f0e246b4ac | ||
|
|
a232997a79 | ||
|
|
08a449db99 | ||
|
|
0c023c9888 | ||
|
|
0ad92d00b3 | ||
|
|
a726cbea1e | ||
|
|
c53fa8692b | ||
|
|
3118f3b43c | ||
|
|
9199950b74 | ||
|
|
4c7e31687b | ||
|
|
75e207b520 | ||
|
|
631289b75e | ||
|
|
1b958d0a5d | ||
|
|
35fdf9020d | ||
|
|
45926b1dca | ||
|
|
686ba5024d | ||
|
|
cf375c7c86 | ||
|
|
5e53d76f44 | ||
|
|
7757f72859 | ||
|
|
c8cc584049 | ||
|
|
2cdd269bba | ||
|
|
d2d97ae5bb | ||
|
|
d08d77c555 | ||
|
|
92f8d2139a | ||
|
|
50f2c2dfe6 | ||
|
|
3539c453d3 | ||
|
|
1631122f95 | ||
|
|
8fcb979544 | ||
|
|
8a5af0b7f3 | ||
|
|
cb1f08d556 | ||
|
|
1150267765 | ||
|
|
5c1252548d | ||
|
|
3c7cdf5db8 | ||
|
|
9ac4203b1c | ||
|
|
d0800510db | ||
|
|
f8ba551cc4 | ||
|
|
413444500e | ||
|
|
e21d5835ec | ||
|
|
f2f354e478 | ||
|
|
b195d4569c | ||
|
|
3b77fed72d | ||
|
|
fc64e97f92 | ||
|
|
1da0434454 | ||
|
|
cf2fe40612 | ||
|
|
8f46433ff7 | ||
|
|
f3be3ae269 | ||
|
|
cfec5447d3 | ||
|
|
2d36b461cf | ||
|
|
5e23e4b13d | ||
|
|
badae2e8b3 | ||
|
|
9e64531de6 | ||
|
|
fdec8d283c | ||
|
|
9abedbf7cb | ||
|
|
66004c1cdc | ||
|
|
5b564cd8a3 | ||
|
|
2e79970e6e | ||
|
|
67c82ba6ea | ||
|
|
98425f37b8 | ||
|
|
9d22dd3465 | ||
|
|
837138db49 | ||
|
|
d43d992362 | ||
|
|
16b611cb7e | ||
|
|
8dde2d5e0d | ||
|
|
22b0b2bd24 | ||
|
|
056f727bfd | ||
|
|
0aa6c53c1f | ||
|
|
d9b0660611 | ||
|
|
d01666f4e2 | ||
|
|
51bee87cd0 | ||
|
|
3041b443e5 | ||
|
|
d95e6c939b | ||
|
|
fd38c63b35 | ||
|
|
b69c24ae14 | ||
|
|
65a0c00e33 | ||
|
|
b12a5ef133 | ||
|
|
9e1b92c26e | ||
|
|
3922aec36e | ||
|
|
41cca8e56d | ||
|
|
2d37a7341a | ||
|
|
40e3c6134c | ||
|
|
edddd47a1e | ||
|
|
4ea6f38645 | ||
|
|
40d998a026 | ||
|
|
3af8f151ac | ||
|
|
e066fa6873 | ||
|
|
6bd94269d4 | ||
|
|
c90edec18a | ||
|
|
cbb302614c | ||
|
|
c54611a11b | ||
|
|
88f249649a | ||
|
|
fe9fbdb93c | ||
|
|
28bc966b76 | ||
|
|
77bbf85b52 | ||
|
|
3b1990e97a | ||
|
|
375b5a49f3 | ||
|
|
392c157cb5 | ||
|
|
6f5bf4b582 | ||
|
|
2e3f48ebb7 | ||
|
|
e4a2c518bb | ||
|
|
f19fb68b4c | ||
|
|
9121c12a2c | ||
|
|
d0fe28cfe2 | ||
|
|
656e3e43be | ||
|
|
c2c1772371 | ||
|
|
88d5caf642 | ||
|
|
1684978693 | ||
|
|
8e4927600f | ||
|
|
4d72dc57e7 | ||
|
|
e7316b3389 | ||
|
|
e17b374606 | ||
|
|
141f83065f | ||
|
|
6381dbafc1 | ||
|
|
fc9db4510f | ||
|
|
66abf736c9 | ||
|
|
af713470c1 | ||
|
|
93a51d2bcb | ||
|
|
3f3e06de8a | ||
|
|
7315aac9d8 | ||
|
|
d933308a6f | ||
|
|
3baf93dcc5 | ||
|
|
6ba14bd8fe | ||
|
|
7499570766 | ||
|
|
003ee55a75 | ||
|
|
b0cc42ef1f | ||
|
|
23679ec3f5 | ||
|
|
da52e5b9dd | ||
|
|
c4e357793f | ||
|
|
6c3424029c | ||
|
|
dd9e6a5b69 | ||
|
|
095320ef72 | ||
|
|
35f7674bcd | ||
|
|
26b36c123d | ||
|
|
c85e694c1d | ||
|
|
ec05282db6 | ||
|
|
3d6f9b226f | ||
|
|
eda6df4a5d | ||
|
|
d504f89f6a | ||
|
|
14c468f2a2 | ||
|
|
2a99b0e46f | ||
|
|
ae8914f5c8 | ||
|
|
0c9f8971ce | ||
|
|
d7a75ea4e5 | ||
|
|
3ad8d8b17c | ||
|
|
39225dc204 | ||
|
|
4fb69f7d89 | ||
|
|
0890c6ad24 | ||
|
|
dd81809589 | ||
|
|
f0672beb46 | ||
|
|
cc5301e710 | ||
|
|
9d5ec43c4e | ||
|
|
6d41211b07 | ||
|
|
d58b61eed5 | ||
|
|
4b53d98bfc | ||
|
|
f51f354e48 | ||
|
|
59d027181d | ||
|
|
0d0988c090 | ||
|
|
dc2de50924 | ||
|
|
12c88835f2 | ||
|
|
6f4453aaf3 | ||
|
|
4b4b8fe3c1 | ||
|
|
49e7c2e9f5 | ||
|
|
4653c273e3 | ||
|
|
ae145de2f2 | ||
|
|
dde7cf71c6 | ||
|
|
219cd242db | ||
|
|
e5b712c082 | ||
|
|
4d2c60d59b | ||
|
|
1d2c1b114b | ||
|
|
2bde936d05 | ||
|
|
cd3e32bf4b | ||
|
|
454536d631 | ||
|
|
656f1755fd | ||
|
|
8aa76ce5c1 | ||
|
|
49fa37f00d | ||
|
|
9f83548cf3 | ||
|
|
6054d95e85 | ||
|
|
8c9bb35824 | ||
|
|
3eacf9558a | ||
|
|
fee37172b4 | ||
|
|
e128c80eb1 | ||
|
|
5cc735ed57 | ||
|
|
43fcce6361 | ||
|
|
49b7126278 | ||
|
|
679cfb5c69 | ||
|
|
50616bc680 | ||
|
|
aaad270822 | ||
|
|
bd10280736 | ||
|
|
d477050239 | ||
|
|
85f79cd8d1 | ||
|
|
613cd81152 | ||
|
|
e0aba6c49a | ||
|
|
d78bcf2494 | ||
|
|
f7cffd2eba | ||
|
|
0d0b91aa80 | ||
|
|
42872e6d2d | ||
|
|
b91f06405d | ||
|
|
dac4c688d6 | ||
|
|
097a68ad18 | ||
|
|
4a98710db0 | ||
|
|
d033a374dd | ||
|
|
6aa23fe36a | ||
|
|
3220cfb79c | ||
|
|
b92e7aa446 | ||
|
|
c3b9c73541 | ||
|
|
81c6672880 | ||
|
|
08baf884d3 | ||
|
|
1c4096f3d5 | ||
|
|
66a3f3f59a | ||
|
|
624df1328b | ||
|
|
c063854b51 | ||
|
|
8cf99dd928 | ||
|
|
c07e885725 | ||
|
|
21772feadd | ||
|
|
2d00cfdd31 | ||
|
|
49e03d658b | ||
|
|
fec85bcc08 | ||
|
|
0e93a6bcb0 | ||
|
|
7e20f738fb | ||
|
|
24090e6077 | ||
|
|
1022b07f64 | ||
|
|
4faf912c6f | ||
|
|
56e4b24b07 | ||
|
|
12295d2fdc | ||
|
|
6261f7d18d | ||
|
|
9e1a2e3bb7 | ||
|
|
40cbb2155c | ||
|
|
a8d7070832 | ||
|
|
ab7266f3a4 | ||
|
|
3053b13fcb | ||
|
|
f3544b3471 | ||
|
|
1610048974 | ||
|
|
fc6f1bf95b | ||
|
|
67b274c1b2 | ||
|
|
fb0d6b5641 | ||
|
|
d30fbeb286 | ||
|
|
46e430ebbb | ||
|
|
bc4cd45fcb | ||
|
|
bdc86ddf15 | ||
|
|
ded17c1479 | ||
|
|
933e2fc01d | ||
|
|
1cddeee264 | ||
|
|
183c000080 | ||
|
|
adf7b6d4b2 | ||
|
|
0566d50346 | ||
|
|
4275dc3003 | ||
|
|
30956aeefc | ||
|
|
64e1dd3dd6 | ||
|
|
0dc4b6f728 | ||
|
|
86074c87d7 | ||
|
|
6f9245df01 | ||
|
|
4540e47055 | ||
|
|
4bb8981e78 | ||
|
|
c49be91aa0 | ||
|
|
2b847039d4 | ||
|
|
1147725fd7 | ||
|
|
26891e12a4 | ||
|
|
2f7e44a76f | ||
|
|
9366d3d2d0 | ||
|
|
6b606a5cc8 | ||
|
|
e5339c178a | ||
|
|
1a76f74482 | ||
|
|
13f13eb095 | ||
|
|
125fdecd61 | ||
|
|
d05076d258 | ||
|
|
00b77581fc | ||
|
|
897787d17c | ||
|
|
d5a280cf2b | ||
|
|
a0c2d9b5ad | ||
|
|
e713bd1ca2 | ||
|
|
a3c28c1003 | ||
|
|
f4b7c9a138 | ||
|
|
6b860b5f29 | ||
|
|
37dfcd6abd | ||
|
|
bc2fca3a4f | ||
|
|
f8ef159656 | ||
|
|
b2b8a9d37e | ||
|
|
15ae4031b7 | ||
|
|
688976ce3b | ||
|
|
a548af01dc | ||
|
|
0dd52eceb3 | ||
|
|
b8c6cf4ac1 | ||
|
|
beb8ff1dd1 | ||
|
|
6a8f0867d9 | ||
|
|
51ad1c9a33 | ||
|
|
34872eb612 | ||
|
|
8b4e3128ff | ||
|
|
c66cbc800b | ||
|
|
21941521a0 | ||
|
|
0d33884052 | ||
|
|
415df49377 | ||
|
|
f5f45002c7 | ||
|
|
1edf7126bb | ||
|
|
a1a55a1002 | ||
|
|
45f5cb46bd | ||
|
|
1b5e608a27 | ||
|
|
a7df8ae15c | ||
|
|
47ce0d0fe2 | ||
|
|
b220e288d0 | ||
|
|
1fc8b45b68 | ||
|
|
62f06302f0 | ||
|
|
3e5cb223f3 | ||
|
|
4ee5b7481c | ||
|
|
e104b78c01 | ||
|
|
ba1ac58721 | ||
|
|
a4fbeb6295 | ||
|
|
68f8871403 | ||
|
|
6fd74952b7 | ||
|
|
1ea468cfc4 | ||
|
|
14721c265f | ||
|
|
821827a375 | ||
|
|
9ba3e2c204 | ||
|
|
d287883671 | ||
|
|
ead34818db | ||
|
|
a060010b96 | ||
|
|
76a92ac847 | ||
|
|
74bc490383 | ||
|
|
510d476323 | ||
|
|
1e7257fd53 | ||
|
|
4ff1f51b1c | ||
|
|
74507cef05 |
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
patreon: PixelPawsAI
|
|
||||||
ko_fi: pixelpawsai
|
ko_fi: pixelpawsai
|
||||||
custom: ['paypal.me/pixelpawsai']
|
patreon: PixelPawsAI
|
||||||
|
custom: ['paypal.me/pixelpawsai', 'https://afdian.com/a/pixelpawsai']
|
||||||
|
|||||||
69
.github/workflows/backend-tests.yml
vendored
Normal file
69
.github/workflows/backend-tests.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
name: Backend Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'py/**'
|
||||||
|
- 'standalone.py'
|
||||||
|
- 'tests/**'
|
||||||
|
- 'requirements.txt'
|
||||||
|
- 'requirements-dev.txt'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'pytest.ini'
|
||||||
|
- '.github/workflows/backend-tests.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'py/**'
|
||||||
|
- 'standalone.py'
|
||||||
|
- 'tests/**'
|
||||||
|
- 'requirements.txt'
|
||||||
|
- 'requirements-dev.txt'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'pytest.ini'
|
||||||
|
- '.github/workflows/backend-tests.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pytest:
|
||||||
|
name: Run pytest with coverage
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
cache: 'pip'
|
||||||
|
cache-dependency-path: |
|
||||||
|
requirements.txt
|
||||||
|
requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Run pytest with coverage
|
||||||
|
env:
|
||||||
|
COVERAGE_FILE: coverage/backend/.coverage
|
||||||
|
run: |
|
||||||
|
mkdir -p coverage/backend
|
||||||
|
python -m pytest \
|
||||||
|
--cov=py \
|
||||||
|
--cov=standalone \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
--cov-report=xml:coverage/backend/coverage.xml \
|
||||||
|
--cov-report=html:coverage/backend/html \
|
||||||
|
--cov-report=json:coverage/backend/coverage.json
|
||||||
|
|
||||||
|
- name: Upload coverage artifact
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: backend-coverage
|
||||||
|
path: coverage/backend
|
||||||
|
if-no-files-found: warn
|
||||||
52
.github/workflows/frontend-tests.yml
vendored
Normal file
52
.github/workflows/frontend-tests.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
name: Frontend Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'vitest.config.js'
|
||||||
|
- 'tests/frontend/**'
|
||||||
|
- 'static/js/**'
|
||||||
|
- 'scripts/run_frontend_coverage.js'
|
||||||
|
- '.github/workflows/frontend-tests.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'vitest.config.js'
|
||||||
|
- 'tests/frontend/**'
|
||||||
|
- 'static/js/**'
|
||||||
|
- 'scripts/run_frontend_coverage.js'
|
||||||
|
- '.github/workflows/frontend-tests.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
vitest:
|
||||||
|
name: Run Vitest with coverage
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run frontend tests with coverage
|
||||||
|
run: npm run test:coverage
|
||||||
|
|
||||||
|
- name: Upload coverage artifact
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-coverage
|
||||||
|
path: coverage/frontend
|
||||||
|
if-no-files-found: warn
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -5,3 +5,8 @@ output/*
|
|||||||
py/run_test.py
|
py/run_test.py
|
||||||
.vscode/
|
.vscode/
|
||||||
cache/
|
cache/
|
||||||
|
civitai/
|
||||||
|
node_modules/
|
||||||
|
coverage/
|
||||||
|
.coverage
|
||||||
|
model_cache/
|
||||||
|
|||||||
22
AGENTS.md
Normal file
22
AGENTS.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
ComfyUI LoRA Manager pairs a Python backend with browser-side widgets. Backend modules live in <code>py/</code> with HTTP entry points in <code>py/routes/</code>, feature logic in <code>py/services/</code>, shared helpers in <code>py/utils/</code>, and custom nodes in <code>py/nodes/</code>. UI scripts extend ComfyUI from <code>web/comfyui/</code>, while deploy-ready assets remain in <code>static/</code> and <code>templates/</code>. Localization files live in <code>locales/</code>, example workflows in <code>example_workflows/</code>, and interim tests such as <code>test_i18n.py</code> sit beside their source until a dedicated <code>tests/</code> tree lands.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- <code>pip install -r requirements.txt</code> installs backend dependencies.
|
||||||
|
- <code>python standalone.py --port 8188</code> launches the standalone server for iterative development.
|
||||||
|
- <code>python -m pytest test_i18n.py</code> runs the current regression suite; target new files explicitly, e.g. <code>python -m pytest tests/test_recipes.py</code>.
|
||||||
|
- <code>python scripts/sync_translation_keys.py</code> synchronizes locale keys after UI string updates.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
Follow PEP 8 with four-space indentation and descriptive snake_case file and function names such as <code>settings_manager.py</code>. Classes stay PascalCase, constants in UPPER_SNAKE_CASE, and loggers retrieved via <code>logging.getLogger(__name__)</code>. Prefer explicit type hints and docstrings on public APIs. JavaScript under <code>web/comfyui/</code> uses ES modules with camelCase helpers and the <code>_widget.js</code> suffix for UI components.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
Pytest powers backend tests. Name modules <code>test_<feature>.py</code> and keep them near the code or in a future <code>tests/</code> package. Mock ComfyUI dependencies through helpers in <code>standalone.py</code>, keep filesystem fixtures deterministic, and ensure translations are covered. Run <code>python -m pytest</code> before submitting changes.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
Commits follow the conventional format, e.g. <code>feat(settings): add default model path</code>, and should stay focused on a single concern. Pull requests must outline the problem, summarize the solution, list manual verification steps (server run, targeted pytest), and link related issues. Include screenshots or GIFs for UI or locale updates and call out migration steps such as <code>settings.json</code> adjustments.
|
||||||
|
|
||||||
|
## Configuration & Localization Tips
|
||||||
|
Copy <code>settings.json.example</code> to <code>settings.json</code> and adapt model directories before running the standalone server. Store reference assets in <code>civitai/</code> or <code>docs/</code> to keep runtime directories deploy-ready. Whenever UI text changes, update every <code>locales/<lang>.json</code> file and rerun the translation sync script so ComfyUI surfaces localized strings.
|
||||||
103
IFLOW.md
Normal file
103
IFLOW.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# ComfyUI LoRA Manager - iFlow 上下文
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
ComfyUI LoRA Manager 是一个全面的工具集,用于简化 ComfyUI 中 LoRA 模型的组织、下载和应用。它提供了强大的功能,如配方管理、检查点组织和一键工作流集成,使模型操作更快、更流畅、更简单。
|
||||||
|
|
||||||
|
该项目是一个 Python 后端与 JavaScript 前端结合的 Web 应用程序,既可以作为 ComfyUI 的自定义节点运行,也可以作为独立应用程序运行。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
D:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\
|
||||||
|
├── py/ # Python 后端代码
|
||||||
|
│ ├── config.py # 全局配置
|
||||||
|
│ ├── lora_manager.py # 主入口点
|
||||||
|
│ ├── controllers/ # 控制器
|
||||||
|
│ ├── metadata_collector/ # 元数据收集器
|
||||||
|
│ ├── middleware/ # 中间件
|
||||||
|
│ ├── nodes/ # ComfyUI 节点
|
||||||
|
│ ├── recipes/ # 配方相关
|
||||||
|
│ ├── routes/ # API 路由
|
||||||
|
│ ├── services/ # 业务逻辑服务
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ └── validators/ # 验证器
|
||||||
|
├── static/ # 静态资源 (CSS, JS, 图片)
|
||||||
|
├── templates/ # HTML 模板
|
||||||
|
├── locales/ # 国际化文件
|
||||||
|
├── tests/ # 测试代码
|
||||||
|
├── standalone.py # 独立模式入口
|
||||||
|
├── requirements.txt # Python 依赖
|
||||||
|
├── package.json # Node.js 依赖和脚本
|
||||||
|
└── README.md # 项目说明
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
|
||||||
|
### 后端 (Python)
|
||||||
|
|
||||||
|
- **主入口**: `py/lora_manager.py` 和 `standalone.py`
|
||||||
|
- **配置**: `py/config.py` 管理全局配置和路径
|
||||||
|
- **路由**: `py/routes/` 目录下包含各种 API 路由
|
||||||
|
- **服务**: `py/services/` 目录下包含业务逻辑,如模型扫描、下载管理等
|
||||||
|
- **模型管理**: 使用 `ModelServiceFactory` 来管理不同类型的模型 (LoRA, Checkpoint, Embedding)
|
||||||
|
|
||||||
|
### 前端 (JavaScript)
|
||||||
|
|
||||||
|
- **构建工具**: 使用 Node.js 和 npm 进行依赖管理和测试
|
||||||
|
- **测试**: 使用 Vitest 进行前端测试
|
||||||
|
|
||||||
|
## 构建和运行
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python 依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Node.js 依赖 (用于测试)
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行 (ComfyUI 模式)
|
||||||
|
|
||||||
|
作为 ComfyUI 的自定义节点安装后,在 ComfyUI 中启动即可。
|
||||||
|
|
||||||
|
### 运行 (独立模式)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用默认配置运行
|
||||||
|
python standalone.py
|
||||||
|
|
||||||
|
# 指定主机和端口
|
||||||
|
python standalone.py --host 127.0.0.1 --port 9000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
#### 后端测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装开发依赖
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
# 运行测试
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 前端测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行测试
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# 运行测试并生成覆盖率报告
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发约定
|
||||||
|
|
||||||
|
- **代码风格**: Python 代码应遵循 PEP 8 规范
|
||||||
|
- **测试**: 新功能应包含相应的单元测试
|
||||||
|
- **配置**: 使用 `settings.json` 文件进行用户配置
|
||||||
|
- **日志**: 使用 Python 标准库 `logging` 模块进行日志记录
|
||||||
102
README.md
102
README.md
@@ -34,6 +34,37 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
|||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
### v0.9.10
|
||||||
|
* **Smarter Update Matching** - Users can now choose to check and group updates by matching base model only or with no base-model constraint; version lists also support toggling between same-base versions or all versions.
|
||||||
|
* **Flexible Tag Filtering** - The filter panel now supports tag exclusion: click a tag to include, click again to exclude, and click a third time to clear, enabling stronger and more flexible tag filters.
|
||||||
|
* **License Visibility & Controls** - Model detail headers and ComfyUI preview popups now show Civitai license icons. The filter panel gains license include/exclude options, and a new global context menu action, "Refresh license metadata," fetches missing license data.
|
||||||
|
* **Recipe Improvements** - Recipes now allow importing with zero LoRAs, and recipe detail pages show the related checkpoint for easier reference.
|
||||||
|
* **Better ZIP Downloads** - When downloading models packaged in ZIPs, model files are extracted into the target model folder; ZIPs containing multiple model files (e.g., WanVideo high/low LoRA pairs) are added as separate models.
|
||||||
|
* **Template Workflow Update** - Refreshed the "Illustrious Pony Example" template workflow with usage guidance for each LoRA Manager node.
|
||||||
|
* **Bug Fixes & Stability** - General fixes and stability improvements.
|
||||||
|
|
||||||
|
### v0.9.9
|
||||||
|
* **Check for Updates Feature** - Users can now check for updates for all models or selected models in bulk mode. Models with available updates will display an "update available" badge on their model card, and users can filter to show only models with updates.
|
||||||
|
* **Model Versions Management** - Added a new Versions tab in the model modal that centralizes all versions of a model, providing download, delete, and ignore update functions.
|
||||||
|
* **Send Checkpoint to ComfyUI** - Users can now click the send button on a checkpoint card to send the checkpoint directly to the current workflow's checkpoint or diffusion model loader node in ComfyUI.
|
||||||
|
* **Customizable Model Card Display** - Added a new setting that allows users to choose whether to display the model name or filename on model cards.
|
||||||
|
* **New Path Template Placeholders** - Added new path template placeholders: `{model_name}` and `{version_name}` for more flexible organization.
|
||||||
|
* **ComfyUI Auto Path Correction Setting** - Added a new setting within ComfyUI to enable or disable the auto path correction feature.
|
||||||
|
|
||||||
|
### v0.9.8
|
||||||
|
* **Full CivArchive API Support** - Added complete support for the CivArchive API as a fallback metadata source beyond Civitai API. Models deleted from Civitai can now still retrieve metadata through the CivArchive API.
|
||||||
|
* **Download Models from CivArchive** - Added support for downloading models directly from CivArchive, similar to downloading from Civitai. Simply click the Download button and paste the model URL to download the corresponding model.
|
||||||
|
* **Custom Priority Tags** - Introduced Custom Priority Tags feature, allowing users to define custom priority tags. These tags will appear as suggestions when editing tags or during auto organization/download using default paths, providing more precise and controlled folder organization. [Guide](https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide)
|
||||||
|
* **Drag and Drop Tag Reordering** - Added drag and drop functionality to reorder tags in the tags edit mode for improved usability.
|
||||||
|
* **Download Control in Example Images Panel** - Added stop control in the Download Example Images Panel for better download management.
|
||||||
|
* **Prompt (LoraManager) Node with Autocomplete** - Added new Prompt (LoraManager) node with autocomplete feature for adding embeddings.
|
||||||
|
* **Lora Manager Nodes in Subgraphs** - Lora Manager nodes now support being placed within subgraphs for more flexible workflow organization.
|
||||||
|
|
||||||
|
### v0.9.6
|
||||||
|
* **Metadata Archive Database Support** - Added the ability to download and utilize a metadata archive database, enabling access to metadata for models that have been deleted from CivitAI.
|
||||||
|
* **App-Level Proxy Settings** - Introduced support for configuring a global proxy within the application, making it easier to use the manager behind network restrictions.
|
||||||
|
* **Bug Fixes** - Various bug fixes for improved stability and reliability.
|
||||||
|
|
||||||
### v0.9.2
|
### v0.9.2
|
||||||
* **Bulk Auto-Organization Action** - Added a new bulk auto-organization feature. You can now select multiple models and automatically organize them according to your current path template settings for streamlined management.
|
* **Bulk Auto-Organization Action** - Added a new bulk auto-organization feature. You can now select multiple models and automatically organize them according to your current path template settings for streamlined management.
|
||||||
* **Bug Fixes** - Addressed several bugs to improve stability and reliability.
|
* **Bug Fixes** - Addressed several bugs to improve stability and reliability.
|
||||||
@@ -49,34 +80,6 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
|||||||
* **Automatic Filename Conflict Resolution** - Implemented automatic file renaming (`original name + short hash`) to prevent conflicts when downloading or moving models.
|
* **Automatic Filename Conflict Resolution** - Implemented automatic file renaming (`original name + short hash`) to prevent conflicts when downloading or moving models.
|
||||||
* **Performance Optimizations & Bug Fixes** - Various performance improvements and bug fixes for a more stable and responsive experience.
|
* **Performance Optimizations & Bug Fixes** - Various performance improvements and bug fixes for a more stable and responsive experience.
|
||||||
|
|
||||||
### v0.8.30
|
|
||||||
* **Automatic Model Path Correction** - Added auto-correction for model paths in built-in nodes such as Load Checkpoint, Load Diffusion Model, Load LoRA, and other custom nodes with similar functionality. Workflows containing outdated or incorrect model paths will now be automatically updated to reflect the current location of your models.
|
|
||||||
* **Node UI Enhancements** - Improved node interface for a smoother and more intuitive user experience.
|
|
||||||
* **Bug Fixes** - Addressed various bugs to enhance stability and reliability.
|
|
||||||
|
|
||||||
### v0.8.29
|
|
||||||
* **Enhanced Recipe Imports** - Improved recipe importing with new target folder selection, featuring path input autocomplete and interactive folder tree navigation. Added a "Use Default Path" option when downloading missing LoRAs.
|
|
||||||
* **WanVideo Lora Select Node Update** - Updated the WanVideo Lora Select node with a 'merge_loras' option to match the counterpart node in the WanVideoWrapper node package.
|
|
||||||
* **Autocomplete Conflict Resolution** - Resolved an autocomplete feature conflict in LoRA nodes with pysssss autocomplete.
|
|
||||||
* **Improved Download Functionality** - Enhanced download functionality with resumable downloads and improved error handling.
|
|
||||||
* **Bug Fixes** - Addressed several bugs for improved stability and performance.
|
|
||||||
|
|
||||||
### v0.8.28
|
|
||||||
* **Autocomplete for Node Inputs** - Instantly find and add LoRAs by filename directly in Lora Loader, Lora Stacker, and WanVideo Lora Select nodes. Autocomplete suggestions include preview tooltips and preset weights, allowing you to quickly select LoRAs without opening the LoRA Manager UI.
|
|
||||||
* **Duplicate Notification Control** - Added a switch to duplicates mode, enabling users to turn off duplicate model notifications for a more streamlined experience.
|
|
||||||
* **Download Example Images from Context Menu** - Introduced a new context menu option to download example images for individual models.
|
|
||||||
|
|
||||||
### v0.8.27
|
|
||||||
* **User Experience Enhancements** - Improved the model download target folder selection with path input autocomplete and interactive folder tree navigation, making it easier and faster to choose where models are saved.
|
|
||||||
* **Default Path Option for Downloads** - Added a "Use Default Path" option when downloading models. When enabled, models are automatically organized and stored according to your configured path template settings.
|
|
||||||
* **Advanced Download Path Templates** - Expanded path template settings, allowing users to set individual templates for LoRA, checkpoint, and embedding models for greater flexibility. Introduced the `{author}` placeholder, enabling automatic organization of model files by creator name.
|
|
||||||
* **Bug Fixes & Stability Improvements** - Addressed various bugs and improved overall stability for a smoother experience.
|
|
||||||
|
|
||||||
### v0.8.26
|
|
||||||
* **Creator Search Option** - Added ability to search models by creator name, making it easier to find models from specific authors.
|
|
||||||
* **Enhanced Node Usability** - Improved user experience for Lora Loader, Lora Stacker, and WanVideo Lora Select nodes by fixing the maximum height of the text input area. Users can now freely and conveniently adjust the LoRA region within these nodes.
|
|
||||||
* **Compatibility Fixes** - Resolved compatibility issues with ComfyUI and certain custom nodes, including ComfyUI-Custom-Scripts, ensuring smoother integration and operation.
|
|
||||||
|
|
||||||
[View Update History](./update_logs.md)
|
[View Update History](./update_logs.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -134,9 +137,10 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
|||||||
|
|
||||||
### Option 2: **Portable Standalone Edition** (No ComfyUI required)
|
### Option 2: **Portable Standalone Edition** (No ComfyUI required)
|
||||||
|
|
||||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.8.26/lora_manager_portable.7z)
|
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.8/lora_manager_portable.7z)
|
||||||
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder
|
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder.
|
||||||
3. Edit `settings.json` to include your correct model folder paths and CivitAI API key
|
3. Edit the new `settings.json` to include your correct model folder paths and CivitAI API key
|
||||||
|
- Set `"use_portable_settings": true` if you want the configuration to remain inside the repository folder instead of your user settings directory.
|
||||||
4. Run run.bat
|
4. Run run.bat
|
||||||
- To change the startup port, edit `run.bat` and modify the parameter (e.g. `--port 9001`)
|
- To change the startup port, edit `run.bat` and modify the parameter (e.g. `--port 9001`)
|
||||||
|
|
||||||
@@ -204,7 +208,7 @@ You can combine multiple patterns to create detailed, organized filenames for yo
|
|||||||
You can now run LoRA Manager independently from ComfyUI:
|
You can now run LoRA Manager independently from ComfyUI:
|
||||||
|
|
||||||
1. **For ComfyUI users**:
|
1. **For ComfyUI users**:
|
||||||
- Launch ComfyUI with LoRA Manager at least once to initialize the necessary path information in the `settings.json` file.
|
- Launch ComfyUI with LoRA Manager at least once to initialize the necessary path information in the `settings.json` file located in your user settings folder (see paths above).
|
||||||
- Make sure dependencies are installed: `pip install -r requirements.txt`
|
- Make sure dependencies are installed: `pip install -r requirements.txt`
|
||||||
- From your ComfyUI root directory, run:
|
- From your ComfyUI root directory, run:
|
||||||
```bash
|
```bash
|
||||||
@@ -217,8 +221,9 @@ You can now run LoRA Manager independently from ComfyUI:
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **For non-ComfyUI users**:
|
2. **For non-ComfyUI users**:
|
||||||
- Copy the provided `settings.json.example` file to create a new file named `settings.json`
|
- Copy the provided `settings.json.example` file to create a new file named `settings.json`. Update the API key, optional language, and folder paths only—the library registry is created automatically when LoRA Manager starts.
|
||||||
- Edit `settings.json` to include your correct model folder paths and CivitAI API key
|
- Edit `settings.json` to include your correct model folder paths and CivitAI API key (you can leave the defaults until ready to configure them)
|
||||||
|
- Enable portable mode by setting `"use_portable_settings": true` if you prefer LoRA Manager to read and write the `settings.json` located in the project directory.
|
||||||
- Install required dependencies: `pip install -r requirements.txt`
|
- Install required dependencies: `pip install -r requirements.txt`
|
||||||
- Run standalone mode:
|
- Run standalone mode:
|
||||||
```bash
|
```bash
|
||||||
@@ -226,8 +231,37 @@ You can now run LoRA Manager independently from ComfyUI:
|
|||||||
```
|
```
|
||||||
- Access the interface through your browser at: `http://localhost:8188/loras`
|
- Access the interface through your browser at: `http://localhost:8188/loras`
|
||||||
|
|
||||||
|
> **Note:** Existing installations automatically migrate the legacy `settings.json` from the plugin folder to the user settings directory the first time you launch this version.
|
||||||
|
|
||||||
This standalone mode provides a lightweight option for managing your model and recipe collection without needing to run the full ComfyUI environment, making it useful even for users who primarily use other stable diffusion interfaces.
|
This standalone mode provides a lightweight option for managing your model and recipe collection without needing to run the full ComfyUI environment, making it useful even for users who primarily use other stable diffusion interfaces.
|
||||||
|
|
||||||
|
## Testing & Coverage
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
Install the development dependencies and run pytest with coverage reports:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
COVERAGE_FILE=coverage/backend/.coverage pytest \
|
||||||
|
--cov=py \
|
||||||
|
--cov=standalone \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
--cov-report=html:coverage/backend/html \
|
||||||
|
--cov-report=xml:coverage/backend/coverage.xml \
|
||||||
|
--cov-report=json:coverage/backend/coverage.json
|
||||||
|
```
|
||||||
|
|
||||||
|
HTML, XML, and JSON artifacts are stored under `coverage/backend/` so you can inspect hot spots locally or from CI artifacts.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Run the Vitest coverage suite to analyze widget hot spots:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
44
__init__.py
44
__init__.py
@@ -1,21 +1,45 @@
|
|||||||
from .py.lora_manager import LoraManager
|
try: # pragma: no cover - import fallback for pytest collection
|
||||||
from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader
|
from .py.lora_manager import LoraManager
|
||||||
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader
|
||||||
from .py.nodes.lora_stacker import LoraStacker
|
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
||||||
from .py.nodes.save_image import SaveImage
|
from .py.nodes.prompt import PromptLoraManager
|
||||||
from .py.nodes.debug_metadata import DebugMetadata
|
from .py.nodes.lora_stacker import LoraStacker
|
||||||
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelect
|
from .py.nodes.save_image import SaveImage
|
||||||
# Import metadata collector to install hooks on startup
|
from .py.nodes.debug_metadata import DebugMetadata
|
||||||
from .py.metadata_collector import init as init_metadata_collector
|
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelect
|
||||||
|
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText
|
||||||
|
from .py.metadata_collector import init as init_metadata_collector
|
||||||
|
except ImportError: # pragma: no cover - allows running under pytest without package install
|
||||||
|
import importlib
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
package_root = pathlib.Path(__file__).resolve().parent
|
||||||
|
if str(package_root) not in sys.path:
|
||||||
|
sys.path.append(str(package_root))
|
||||||
|
|
||||||
|
PromptLoraManager = importlib.import_module("py.nodes.prompt").PromptLoraManager
|
||||||
|
LoraManager = importlib.import_module("py.lora_manager").LoraManager
|
||||||
|
LoraManagerLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerLoader
|
||||||
|
LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader
|
||||||
|
TriggerWordToggle = importlib.import_module("py.nodes.trigger_word_toggle").TriggerWordToggle
|
||||||
|
LoraStacker = importlib.import_module("py.nodes.lora_stacker").LoraStacker
|
||||||
|
SaveImage = importlib.import_module("py.nodes.save_image").SaveImage
|
||||||
|
DebugMetadata = importlib.import_module("py.nodes.debug_metadata").DebugMetadata
|
||||||
|
WanVideoLoraSelect = importlib.import_module("py.nodes.wanvideo_lora_select").WanVideoLoraSelect
|
||||||
|
WanVideoLoraSelectFromText = importlib.import_module("py.nodes.wanvideo_lora_select_from_text").WanVideoLoraSelectFromText
|
||||||
|
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
PromptLoraManager.NAME: PromptLoraManager,
|
||||||
LoraManagerLoader.NAME: LoraManagerLoader,
|
LoraManagerLoader.NAME: LoraManagerLoader,
|
||||||
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
|
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
|
||||||
TriggerWordToggle.NAME: TriggerWordToggle,
|
TriggerWordToggle.NAME: TriggerWordToggle,
|
||||||
LoraStacker.NAME: LoraStacker,
|
LoraStacker.NAME: LoraStacker,
|
||||||
SaveImage.NAME: SaveImage,
|
SaveImage.NAME: SaveImage,
|
||||||
DebugMetadata.NAME: DebugMetadata,
|
DebugMetadata.NAME: DebugMetadata,
|
||||||
WanVideoLoraSelect.NAME: WanVideoLoraSelect
|
WanVideoLoraSelect.NAME: WanVideoLoraSelect,
|
||||||
|
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web/comfyui"
|
WEB_DIRECTORY = "./web/comfyui"
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
# Event Management Implementation Summary
|
|
||||||
|
|
||||||
## What Has Been Implemented
|
|
||||||
|
|
||||||
### 1. Enhanced EventManager Class
|
|
||||||
- **Location**: `static/js/utils/EventManager.js`
|
|
||||||
- **Features**:
|
|
||||||
- Priority-based event handling
|
|
||||||
- Conditional execution based on application state
|
|
||||||
- Element filtering (target/exclude selectors)
|
|
||||||
- Mouse button filtering
|
|
||||||
- Automatic cleanup with cleanup functions
|
|
||||||
- State tracking for app modes
|
|
||||||
- Error handling for event handlers
|
|
||||||
|
|
||||||
### 2. BulkManager Integration
|
|
||||||
- **Location**: `static/js/managers/BulkManager.js`
|
|
||||||
- **Migrated Events**:
|
|
||||||
- Global keyboard shortcuts (Ctrl+A, Escape, B key)
|
|
||||||
- Marquee selection events (mousedown, mousemove, mouseup, contextmenu)
|
|
||||||
- State synchronization with EventManager
|
|
||||||
- **Benefits**:
|
|
||||||
- Centralized priority handling
|
|
||||||
- Conditional execution based on modal state
|
|
||||||
- Better coordination with other components
|
|
||||||
|
|
||||||
### 3. UIHelpers Integration
|
|
||||||
- **Location**: `static/js/utils/uiHelpers.js`
|
|
||||||
- **Migrated Events**:
|
|
||||||
- Mouse position tracking for node selector positioning
|
|
||||||
- Node selector click events (outside clicks and selection)
|
|
||||||
- State management for node selector
|
|
||||||
- **Benefits**:
|
|
||||||
- Reduced direct DOM listeners
|
|
||||||
- Coordinated state tracking
|
|
||||||
- Better cleanup
|
|
||||||
|
|
||||||
### 4. ModelCard Integration
|
|
||||||
- **Location**: `static/js/components/shared/ModelCard.js`
|
|
||||||
- **Migrated Events**:
|
|
||||||
- Model card click delegation
|
|
||||||
- Action button handling (star, globe, copy, etc.)
|
|
||||||
- Better return value handling for event propagation
|
|
||||||
- **Benefits**:
|
|
||||||
- Single event listener for all model cards
|
|
||||||
- Priority-based execution
|
|
||||||
- Better event flow control
|
|
||||||
|
|
||||||
### 5. Documentation and Initialization
|
|
||||||
- **EventManagerDocs.md**: Comprehensive documentation
|
|
||||||
- **eventManagementInit.js**: Initialization and global handlers
|
|
||||||
- **Features**:
|
|
||||||
- Global escape key handling
|
|
||||||
- Modal state synchronization
|
|
||||||
- Error handling
|
|
||||||
- Analytics integration points
|
|
||||||
- Cleanup on page unload
|
|
||||||
|
|
||||||
## Application States Tracked
|
|
||||||
|
|
||||||
1. **bulkMode**: When bulk selection mode is active
|
|
||||||
2. **marqueeActive**: When marquee selection is in progress
|
|
||||||
3. **modalOpen**: When any modal dialog is open
|
|
||||||
4. **nodeSelectorActive**: When node selector popup is visible
|
|
||||||
|
|
||||||
## Priority Levels Used
|
|
||||||
|
|
||||||
- **250+**: Critical system events (escape keys)
|
|
||||||
- **200+**: High priority system events (modal close)
|
|
||||||
- **100-199**: Application-level shortcuts (bulk operations)
|
|
||||||
- **80-99**: UI interactions (marquee selection)
|
|
||||||
- **60-79**: Component interactions (model cards)
|
|
||||||
- **10-49**: Tracking and monitoring
|
|
||||||
- **1-9**: Analytics and low-priority tasks
|
|
||||||
|
|
||||||
## Event Flow Examples
|
|
||||||
|
|
||||||
### Bulk Mode Toggle (B key)
|
|
||||||
1. **Priority 100**: BulkManager keyboard handler catches 'b' key
|
|
||||||
2. Toggles bulk mode state
|
|
||||||
3. Updates EventManager state
|
|
||||||
4. Updates UI accordingly
|
|
||||||
5. Stops propagation (returns true)
|
|
||||||
|
|
||||||
### Marquee Selection
|
|
||||||
1. **Priority 80**: BulkManager mousedown handler (only in .models-container, excluding cards/buttons)
|
|
||||||
2. Starts marquee selection
|
|
||||||
3. **Priority 90**: BulkManager mousemove handler (only when marquee active)
|
|
||||||
4. Updates selection rectangle
|
|
||||||
5. **Priority 90**: BulkManager mouseup handler ends selection
|
|
||||||
|
|
||||||
### Model Card Click
|
|
||||||
1. **Priority 60**: ModelCard delegation handler checks for specific elements
|
|
||||||
2. If action button: handles action and stops propagation
|
|
||||||
3. If general card click: continues to other handlers
|
|
||||||
4. Bulk selection may also handle the event if in bulk mode
|
|
||||||
|
|
||||||
## Remaining Event Listeners (Not Yet Migrated)
|
|
||||||
|
|
||||||
### High Priority for Migration
|
|
||||||
1. **SearchManager keyboard events** - Global search shortcuts
|
|
||||||
2. **ModalManager escape handling** - Already integrated with initialization
|
|
||||||
3. **Scroll-based events** - Back to top, virtual scrolling
|
|
||||||
4. **Resize events** - Panel positioning, responsive layouts
|
|
||||||
|
|
||||||
### Medium Priority
|
|
||||||
1. **Form input events** - Tag inputs, settings forms
|
|
||||||
2. **Component-specific events** - Recipe modal, showcase view
|
|
||||||
3. **Sidebar events** - Resize handling, toggle events
|
|
||||||
|
|
||||||
### Low Priority (Can Remain As-Is)
|
|
||||||
1. **VirtualScroller events** - Performance-critical, specialized
|
|
||||||
2. **Component lifecycle events** - Modal open/close callbacks
|
|
||||||
3. **One-time setup events** - Theme initialization, etc.
|
|
||||||
|
|
||||||
## Benefits Achieved
|
|
||||||
|
|
||||||
### Performance Improvements
|
|
||||||
- **Reduced DOM listeners**: From ~15+ individual listeners to ~5 coordinated handlers
|
|
||||||
- **Conditional execution**: Handlers only run when conditions are met
|
|
||||||
- **Priority ordering**: Important events handled first
|
|
||||||
- **Better memory management**: Automatic cleanup prevents leaks
|
|
||||||
|
|
||||||
### Coordination Improvements
|
|
||||||
- **State synchronization**: All components aware of app state
|
|
||||||
- **Event flow control**: Proper propagation stopping
|
|
||||||
- **Conflict resolution**: Priority system prevents conflicts
|
|
||||||
- **Debugging**: Centralized event handling for easier debugging
|
|
||||||
|
|
||||||
### Code Quality Improvements
|
|
||||||
- **Consistent patterns**: All event handling follows same patterns
|
|
||||||
- **Better separation of concerns**: Event logic separated from business logic
|
|
||||||
- **Error handling**: Centralized error catching and reporting
|
|
||||||
- **Documentation**: Clear patterns for future development
|
|
||||||
|
|
||||||
## Next Steps (Recommendations)
|
|
||||||
|
|
||||||
### 1. Migrate Search Events
|
|
||||||
```javascript
|
|
||||||
// In SearchManager.js
|
|
||||||
eventManager.addHandler('keydown', 'search-shortcuts', (e) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
||||||
this.focusSearchInput();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}, { priority: 120 });
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Integrate Resize Events
|
|
||||||
```javascript
|
|
||||||
// Create ResizeManager
|
|
||||||
eventManager.addHandler('resize', 'layout-resize', debounce((e) => {
|
|
||||||
this.updateLayoutDimensions();
|
|
||||||
}, 250), { priority: 50 });
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Add Debug Mode
|
|
||||||
```javascript
|
|
||||||
// In EventManager.js
|
|
||||||
if (window.DEBUG_EVENTS) {
|
|
||||||
console.log(`Event ${eventType} handled by ${source} (priority: ${priority})`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Create Event Analytics
|
|
||||||
```javascript
|
|
||||||
// Track event patterns for optimization
|
|
||||||
eventManager.addHandler('*', 'analytics', (e) => {
|
|
||||||
this.trackEventUsage(e.type, performance.now());
|
|
||||||
}, { priority: 1 });
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Recommendations
|
|
||||||
|
|
||||||
1. **Verify bulk mode interactions** work correctly
|
|
||||||
2. **Test marquee selection** in various scenarios
|
|
||||||
3. **Check modal state synchronization**
|
|
||||||
4. **Verify node selector** positioning and cleanup
|
|
||||||
5. **Test keyboard shortcuts** don't conflict
|
|
||||||
6. **Verify proper cleanup** when components are destroyed
|
|
||||||
|
|
||||||
The centralized event management system provides a solid foundation for coordinated, efficient event handling across the application while maintaining good performance and code organization.
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
# Centralized Event Management System
|
|
||||||
|
|
||||||
This document describes the centralized event management system that coordinates event handling across the ComfyUI LoRA Manager application.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The `EventManager` class provides a centralized way to handle DOM events with priority-based execution, conditional execution based on application state, and proper cleanup mechanisms.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Priority-based execution**: Handlers with higher priority run first
|
|
||||||
- **Conditional execution**: Handlers can be executed based on application state
|
|
||||||
- **Element filtering**: Handlers can target specific elements or exclude others
|
|
||||||
- **Automatic cleanup**: Cleanup functions are called when handlers are removed
|
|
||||||
- **State tracking**: Tracks application states like bulk mode, modal open, etc.
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
### Importing
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { eventManager } from './EventManager.js';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding Event Handlers
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
eventManager.addHandler('click', 'myComponent', (event) => {
|
|
||||||
console.log('Button clicked!');
|
|
||||||
return true; // Stop propagation to other handlers
|
|
||||||
}, {
|
|
||||||
priority: 100,
|
|
||||||
targetSelector: '.my-button',
|
|
||||||
skipWhenModalOpen: true
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Removing Event Handlers
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Remove specific handler
|
|
||||||
eventManager.removeHandler('click', 'myComponent');
|
|
||||||
|
|
||||||
// Remove all handlers for a component
|
|
||||||
eventManager.removeAllHandlersForSource('myComponent');
|
|
||||||
```
|
|
||||||
|
|
||||||
### Updating Application State
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Set state
|
|
||||||
eventManager.setState('bulkMode', true);
|
|
||||||
eventManager.setState('modalOpen', true);
|
|
||||||
|
|
||||||
// Get state
|
|
||||||
const isBulkMode = eventManager.getState('bulkMode');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available States
|
|
||||||
|
|
||||||
- `bulkMode`: Whether bulk selection mode is active
|
|
||||||
- `marqueeActive`: Whether marquee selection is in progress
|
|
||||||
- `modalOpen`: Whether any modal is currently open
|
|
||||||
- `nodeSelectorActive`: Whether the node selector popup is active
|
|
||||||
|
|
||||||
## Handler Options
|
|
||||||
|
|
||||||
### Priority
|
|
||||||
Higher numbers = higher priority. Handlers run in descending priority order.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
priority: 100 // High priority
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Conditional Execution
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
onlyInBulkMode: true, // Only run when bulk mode is active
|
|
||||||
onlyWhenMarqueeActive: true, // Only run when marquee selection is active
|
|
||||||
skipWhenModalOpen: true, // Skip when any modal is open
|
|
||||||
skipWhenNodeSelectorActive: true, // Skip when node selector is active
|
|
||||||
onlyWhenNodeSelectorActive: true // Only run when node selector is active
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Element Filtering
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
targetSelector: '.model-card', // Only handle events on matching elements
|
|
||||||
excludeSelector: 'button, input', // Exclude events from these elements
|
|
||||||
button: 0 // Only handle specific mouse button (0=left, 1=middle, 2=right)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cleanup Functions
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
cleanup: () => {
|
|
||||||
// Custom cleanup logic
|
|
||||||
console.log('Handler cleaned up');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration Examples
|
|
||||||
|
|
||||||
### BulkManager Integration
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
class BulkManager {
|
|
||||||
registerEventHandlers() {
|
|
||||||
// High priority keyboard shortcuts
|
|
||||||
eventManager.addHandler('keydown', 'bulkManager-keyboard', (e) => {
|
|
||||||
return this.handleGlobalKeyboard(e);
|
|
||||||
}, {
|
|
||||||
priority: 100,
|
|
||||||
skipWhenModalOpen: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Marquee selection
|
|
||||||
eventManager.addHandler('mousedown', 'bulkManager-marquee-start', (e) => {
|
|
||||||
return this.handleMarqueeStart(e);
|
|
||||||
}, {
|
|
||||||
priority: 80,
|
|
||||||
skipWhenModalOpen: true,
|
|
||||||
targetSelector: '.models-container',
|
|
||||||
excludeSelector: '.model-card, button, input',
|
|
||||||
button: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
eventManager.removeAllHandlersForSource('bulkManager-keyboard');
|
|
||||||
eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modal Integration
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
class ModalManager {
|
|
||||||
showModal(modalId) {
|
|
||||||
// Update state when modal opens
|
|
||||||
eventManager.setState('modalOpen', true);
|
|
||||||
this.displayModal(modalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeModal(modalId) {
|
|
||||||
// Update state when modal closes
|
|
||||||
eventManager.setState('modalOpen', false);
|
|
||||||
this.hideModal(modalId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Event Delegation
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export function setupComponentEvents() {
|
|
||||||
eventManager.addHandler('click', 'myComponent-actions', (event) => {
|
|
||||||
const button = event.target.closest('.action-button');
|
|
||||||
if (!button) return false;
|
|
||||||
|
|
||||||
this.handleAction(button.dataset.action);
|
|
||||||
return true; // Stop propagation
|
|
||||||
}, {
|
|
||||||
priority: 60,
|
|
||||||
targetSelector: '.component-container'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### 1. Use Descriptive Source Names
|
|
||||||
Use the format `componentName-purposeDescription`:
|
|
||||||
```javascript
|
|
||||||
// Good
|
|
||||||
'bulkManager-marqueeSelection'
|
|
||||||
'nodeSelector-clickOutside'
|
|
||||||
'modelCard-delegation'
|
|
||||||
|
|
||||||
// Avoid
|
|
||||||
'bulk'
|
|
||||||
'click'
|
|
||||||
'handler1'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Set Appropriate Priorities
|
|
||||||
- 200+: Critical system events (escape keys, critical modals)
|
|
||||||
- 100-199: High priority application events (keyboard shortcuts)
|
|
||||||
- 50-99: Normal UI interactions (buttons, cards)
|
|
||||||
- 1-49: Low priority events (tracking, analytics)
|
|
||||||
|
|
||||||
### 3. Use Conditional Execution
|
|
||||||
Instead of checking state inside handlers, use options:
|
|
||||||
```javascript
|
|
||||||
// Good
|
|
||||||
eventManager.addHandler('click', 'bulk-action', handler, {
|
|
||||||
onlyInBulkMode: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Avoid
|
|
||||||
eventManager.addHandler('click', 'bulk-action', (e) => {
|
|
||||||
if (!state.bulkMode) return;
|
|
||||||
// handler logic
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Clean Up Properly
|
|
||||||
Always clean up handlers when components are destroyed:
|
|
||||||
```javascript
|
|
||||||
class MyComponent {
|
|
||||||
constructor() {
|
|
||||||
this.registerEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
eventManager.removeAllHandlersForSource('myComponent');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Return Values Matter
|
|
||||||
- Return `true` to stop event propagation to other handlers
|
|
||||||
- Return `false` or `undefined` to continue with other handlers
|
|
||||||
|
|
||||||
## Migration Guide
|
|
||||||
|
|
||||||
### From Direct Event Listeners
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (e.target.closest('.my-button')) {
|
|
||||||
this.handleClick(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
eventManager.addHandler('click', 'myComponent-button', (e) => {
|
|
||||||
this.handleClick(e);
|
|
||||||
}, {
|
|
||||||
targetSelector: '.my-button'
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### From Event Delegation
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```javascript
|
|
||||||
container.addEventListener('click', (e) => {
|
|
||||||
const card = e.target.closest('.model-card');
|
|
||||||
if (!card) return;
|
|
||||||
|
|
||||||
if (e.target.closest('.action-btn')) {
|
|
||||||
this.handleAction(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```javascript
|
|
||||||
eventManager.addHandler('click', 'container-actions', (e) => {
|
|
||||||
const card = e.target.closest('.model-card');
|
|
||||||
if (!card) return false;
|
|
||||||
|
|
||||||
if (e.target.closest('.action-btn')) {
|
|
||||||
this.handleAction(e);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
targetSelector: '.container'
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Benefits
|
|
||||||
|
|
||||||
1. **Reduced DOM listeners**: Single listener per event type instead of multiple
|
|
||||||
2. **Conditional execution**: Handlers only run when conditions are met
|
|
||||||
3. **Priority ordering**: Important handlers run first, avoiding unnecessary work
|
|
||||||
4. **Automatic cleanup**: Prevents memory leaks from orphaned listeners
|
|
||||||
5. **Centralized debugging**: All event handling flows through one system
|
|
||||||
|
|
||||||
## Debugging
|
|
||||||
|
|
||||||
Enable debug logging to trace event handling:
|
|
||||||
```javascript
|
|
||||||
// Add to EventManager.js for debugging
|
|
||||||
console.log(`Handling ${eventType} event with ${handlers.length} handlers`);
|
|
||||||
```
|
|
||||||
|
|
||||||
The event manager provides a foundation for coordinated, efficient event handling across the entire application.
|
|
||||||
180
docs/LM-Extension-Wiki.md
Normal file
180
docs/LM-Extension-Wiki.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
## Overview
|
||||||
|
|
||||||
|
The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com).
|
||||||
|
It also supports browsing on [CivArchive](https://civarchive.com/) (formerly CivitaiArchive).
|
||||||
|
|
||||||
|
With this extension, you can:
|
||||||
|
|
||||||
|
✅ Instantly see which models are already present in your local library
|
||||||
|
✅ Download new models with a single click
|
||||||
|
✅ Manage downloads efficiently with queue and parallel download support
|
||||||
|
✅ Keep your downloaded models automatically organized according to your custom settings
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why Are All Features for Supporters Only?
|
||||||
|
|
||||||
|
I love building tools for the Stable Diffusion and ComfyUI communities, and LoRA Manager is a passion project that I've poured countless hours into. When I created this companion extension, my hope was to offer its core features for free, as a thank-you to all of you.
|
||||||
|
|
||||||
|
Unfortunately, I've reached a point where I need to be realistic. The level of support from the free model has been far lower than what's needed to justify the continuous development and maintenance for both projects. It was a difficult decision, but I've chosen to make the extension's features exclusive to supporters.
|
||||||
|
|
||||||
|
This change is crucial for me to be able to continue dedicating my time to improving the free and open-source LoRA Manager, which I'm committed to keeping available for everyone.
|
||||||
|
|
||||||
|
Your support does more than just unlock a few features—it allows me to keep innovating and ensures the core LoRA Manager project thrives. I'm incredibly grateful for your understanding and any support you can offer. ❤️
|
||||||
|
|
||||||
|
(_For those who previously supported me on Ko-fi with a one-time donation, I'll be sending out license keys individually as a thank-you._)
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Supported Browsers & Installation Methods
|
||||||
|
|
||||||
|
| Browser | Installation Method |
|
||||||
|
|--------------------|-------------------------------------------------------------------------------------|
|
||||||
|
| **Google Chrome** | [Chrome Web Store link](https://chromewebstore.google.com/detail/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) |
|
||||||
|
| **Microsoft Edge** | Install via Chrome Web Store (compatible) |
|
||||||
|
| **Brave Browser** | Install via Chrome Web Store (compatible) |
|
||||||
|
| **Opera** | Install via Chrome Web Store (compatible) |
|
||||||
|
| **Firefox** | <div id="firefox-install" class="install-ok"><a href="https://github.com/willmiao/lm-civitai-extension-firefox/releases/latest/download/extension.xpi">📦 Install Firefox Extension (reviewed and verified by Mozilla)</a></div> |
|
||||||
|
|
||||||
|
For non-Chrome browsers (e.g., Microsoft Edge), you can typically install extensions from the Chrome Web Store by following these steps: open the extension’s Chrome Web Store page, click 'Get extension', then click 'Allow' when prompted to enable installations from other stores, and finally click 'Add extension' to complete the installation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy & Security
|
||||||
|
|
||||||
|
I understand concerns around browser extensions and privacy, and I want to be fully transparent about how the **LM Civitai Extension** works:
|
||||||
|
|
||||||
|
- **Reviewed and Verified**
|
||||||
|
This extension has been **manually reviewed and approved by the Chrome Web Store**. The Firefox version uses the **exact same code** (only the packaging format differs) and has passed **Mozilla’s Add-on review**.
|
||||||
|
|
||||||
|
- **Minimal Network Access**
|
||||||
|
The only external server this extension connects to is:
|
||||||
|
**`https://willmiao.shop`** — used solely for **license validation**.
|
||||||
|
|
||||||
|
It does **not collect, transmit, or store any personal or usage data**.
|
||||||
|
No browsing history, no user IDs, no analytics, no hidden trackers.
|
||||||
|
|
||||||
|
- **Local-Only Model Detection**
|
||||||
|
Model detection and LoRA Manager communication all happen **locally** within your browser, directly interacting with your local LoRA Manager backend.
|
||||||
|
|
||||||
|
I value your trust and are committed to keeping your local setup private and secure. If you have any questions, feel free to reach out!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
After installing the extension, you'll automatically receive a **7-day trial** to explore all features.
|
||||||
|
|
||||||
|
When the extension is correctly installed and your license is valid:
|
||||||
|
|
||||||
|
- Open **Civitai**, and you'll see visual indicators added by the extension on model cards, showing:
|
||||||
|
- ✅ Models already present in your local library
|
||||||
|
- ⬇️ A download button for models not in your library
|
||||||
|
|
||||||
|
Clicking the download button adds the corresponding model version to the download queue, waiting to be downloaded. You can set up to **5 models to download simultaneously**.
|
||||||
|
|
||||||
|
### Visual Indicators Appear On:
|
||||||
|
|
||||||
|
- **Home Page** — Featured models
|
||||||
|
- **Models Page**
|
||||||
|
- **Creator Profiles** — If the creator has set their models to be visible
|
||||||
|
- **Recommended Resources** — On individual model pages
|
||||||
|
|
||||||
|
### Version Buttons on Model Pages
|
||||||
|
|
||||||
|
On a specific model page, visual indicators also appear on version buttons, showing which versions are already in your local library.
|
||||||
|
|
||||||
|
When switching to a specific version by clicking a version button:
|
||||||
|
|
||||||
|
- Clicking the download button will open a dropdown:
|
||||||
|
- Download via **LoRA Manager**
|
||||||
|
- Download via **Original Download** (browser download)
|
||||||
|
|
||||||
|
You can check **Remember my choice** to set your preferred default. You can change this setting anytime in the extension's settings.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Resources on Image Pages (2025-08-05) — now shows in-library indicators for image resources. ‘Import image as recipe’ coming soon!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model Download Location & LoRA Manager Settings
|
||||||
|
|
||||||
|
To use the **one-click download function**, you must first set:
|
||||||
|
|
||||||
|
- Your **Default LoRAs Root**
|
||||||
|
- Your **Default Checkpoints Root**
|
||||||
|
|
||||||
|
These are set within LoRA Manager's settings.
|
||||||
|
|
||||||
|
When everything is configured, downloaded model files will be placed in:
|
||||||
|
|
||||||
|
`<Default_Models_Root>/<Base_Model_of_the_Model>/<First_Tag_of_the_Model>`
|
||||||
|
|
||||||
|
|
||||||
|
### Update: Default Path Customization (2025-07-21)
|
||||||
|
|
||||||
|
A new setting to customize the default download path has been added in the nightly version. You can now personalize where models are saved when downloading via the LM Civitai Extension.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The previous YAML path mapping file will be deprecated—settings will now be unified in settings.json to simplify configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Port Configuration
|
||||||
|
|
||||||
|
If your **ComfyUI** or **LoRA Manager** backend is running on a port **other than the default 8188**, you must configure the backend port in the extension's settings.
|
||||||
|
|
||||||
|
After correctly setting and saving the port, you'll see in the extension's header area:
|
||||||
|
- A **Healthy** status with the tooltip: `Connected to LoRA Manager on port xxxx`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Connecting to a Remote LoRA Manager
|
||||||
|
|
||||||
|
If your LoRA Manager is running on another computer, you can still connect from your browser using port forwarding.
|
||||||
|
|
||||||
|
> **Why can't you set a remote IP directly?**
|
||||||
|
>
|
||||||
|
> For privacy and security, the extension only requests access to `http://127.0.0.1/*`. Supporting remote IPs would require much broader permissions, which may be rejected by browser stores and could raise user concerns.
|
||||||
|
|
||||||
|
**Solution: Port Forwarding with `socat`**
|
||||||
|
|
||||||
|
On your browser computer, run:
|
||||||
|
|
||||||
|
`socat TCP-LISTEN:8188,bind=127.0.0.1,fork TCP:REMOTE.IP.ADDRESS.HERE:8188`
|
||||||
|
|
||||||
|
- Replace `REMOTE.IP.ADDRESS.HERE` with the IP of the machine running LoRA Manager.
|
||||||
|
- Adjust the port if needed.
|
||||||
|
|
||||||
|
This lets the extension connect to `127.0.0.1:8188` as usual, with traffic forwarded to your remote server.
|
||||||
|
|
||||||
|
_Thanks to user **Temikus** for sharing this solution!_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
The extension will evolve alongside **LoRA Manager** improvements. Planned features include:
|
||||||
|
|
||||||
|
- [x] Support for **additional model types** (e.g., embeddings)
|
||||||
|
- [ ] One-click **Recipe Import**
|
||||||
|
- [x] Display of in-library status for all resources in the **Resources Used** section of the image page
|
||||||
|
- [x] One-click **Auto-organize Models**
|
||||||
|
|
||||||
|
**Stay tuned — and thank you for your support!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
93
docs/architecture/example_images_routes.md
Normal file
93
docs/architecture/example_images_routes.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Example image route architecture
|
||||||
|
|
||||||
|
The example image routing stack mirrors the layered model route stack described in
|
||||||
|
[`docs/architecture/model_routes.md`](model_routes.md). HTTP wiring, controller setup,
|
||||||
|
handler orchestration, and long-running workflows now live in clearly separated modules so
|
||||||
|
we can extend download/import behaviour without touching the entire feature surface.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph HTTP
|
||||||
|
A[ExampleImagesRouteRegistrar] -->|binds| B[ExampleImagesRoutes controller]
|
||||||
|
end
|
||||||
|
subgraph Application
|
||||||
|
B --> C[ExampleImagesHandlerSet]
|
||||||
|
C --> D1[Handlers]
|
||||||
|
D1 --> E1[Use cases]
|
||||||
|
E1 --> F1[Download manager / processor / file manager]
|
||||||
|
end
|
||||||
|
subgraph Side Effects
|
||||||
|
F1 --> G1[Filesystem]
|
||||||
|
F1 --> G2[Model metadata]
|
||||||
|
F1 --> G3[WebSocket progress]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layer responsibilities
|
||||||
|
|
||||||
|
| Layer | Module(s) | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Registrar | `py/routes/example_images_route_registrar.py` | Declarative catalogue of every example image endpoint plus helpers that bind them to an `aiohttp` router. Keeps HTTP concerns symmetrical with the model registrar. |
|
||||||
|
| Controller | `py/routes/example_images_routes.py` | Lazily constructs `ExampleImagesHandlerSet`, injects defaults for the download manager, processor, and file manager, and exposes the registrar-ready mapping just like `BaseModelRoutes`. |
|
||||||
|
| Handler set | `py/routes/handlers/example_images_handlers.py` | Groups HTTP adapters by concern (downloads, imports/deletes, filesystem access). Each handler translates domain errors into HTTP responses and defers to a use case or utility service. |
|
||||||
|
| Use cases | `py/services/use_cases/example_images/*.py` | Encapsulate orchestration for downloads and imports. They validate input, translate concurrency/configuration errors, and keep handler logic declarative. |
|
||||||
|
| Supporting services | `py/utils/example_images_download_manager.py`, `py/utils/example_images_processor.py`, `py/utils/example_images_file_manager.py` | Execute long-running work: pull assets from Civitai, persist uploads, clean metadata, expose filesystem actions with guardrails, and broadcast progress snapshots. |
|
||||||
|
|
||||||
|
## Handler responsibilities & invariants
|
||||||
|
|
||||||
|
`ExampleImagesHandlerSet` flattens the handler objects into the `{"handler_name": coroutine}`
|
||||||
|
mapping consumed by the registrar. The table below outlines how each handler collaborates
|
||||||
|
with the use cases and utilities.
|
||||||
|
|
||||||
|
| Handler | Key endpoints | Collaborators | Contracts |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `ExampleImagesDownloadHandler` | `/api/lm/download-example-images`, `/api/lm/example-images-status`, `/api/lm/pause-example-images`, `/api/lm/resume-example-images`, `/api/lm/force-download-example-images` | `DownloadExampleImagesUseCase`, `DownloadManager` | Delegates payload validation and concurrency checks to the use case; progress/status endpoints expose the same snapshot used for WebSocket broadcasts; pause/resume surface `DownloadNotRunningError` as HTTP 400 instead of 500. |
|
||||||
|
| `ExampleImagesManagementHandler` | `/api/lm/import-example-images`, `/api/lm/delete-example-image` | `ImportExampleImagesUseCase`, `ExampleImagesProcessor` | Multipart uploads are streamed to disk via the use case; validation failures return HTTP 400 with no filesystem side effects; deletion funnels through the processor to prune metadata and cached images consistently. |
|
||||||
|
| `ExampleImagesFileHandler` | `/api/lm/open-example-images-folder`, `/api/lm/example-image-files`, `/api/lm/has-example-images` | `ExampleImagesFileManager` | Centralises filesystem access, enforcing settings-based root paths and returning HTTP 400/404 for missing configuration or folders; responses always include `success`/`has_images` booleans for UI consumption. |
|
||||||
|
|
||||||
|
## Use case boundaries
|
||||||
|
|
||||||
|
| Use case | Entry point | Dependencies | Guarantees |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `DownloadExampleImagesUseCase` | `execute(payload)` | `DownloadManager.start_download`, download configuration errors | Raises `DownloadExampleImagesInProgressError` when the manager reports an active job, rewraps configuration errors into `DownloadExampleImagesConfigurationError`, and lets `ExampleImagesDownloadError` bubble as 500s so handlers do not duplicate logging. |
|
||||||
|
| `ImportExampleImagesUseCase` | `execute(request)` | `ExampleImagesProcessor.import_images`, temporary file helpers | Supports multipart or JSON payloads, normalises file paths into a single list, cleans up temp files even on failure, and maps validation issues to `ImportExampleImagesValidationError` for HTTP 400 responses. |
|
||||||
|
|
||||||
|
## Maintaining critical invariants
|
||||||
|
|
||||||
|
* **Shared progress snapshots** - The download handler returns the same snapshot built by
|
||||||
|
`DownloadManager`, guaranteeing parity between HTTP polling endpoints and WebSocket
|
||||||
|
progress events.
|
||||||
|
* **Safe filesystem access** - All folder/file actions flow through
|
||||||
|
`ExampleImagesFileManager`, which validates the configured example image root and ensures
|
||||||
|
responses never leak absolute paths outside the allowed directory.
|
||||||
|
* **Metadata hygiene** - Import/delete operations run through `ExampleImagesProcessor`,
|
||||||
|
which updates model metadata via `MetadataManager` and notifies the relevant scanners so
|
||||||
|
cache state stays in sync.
|
||||||
|
|
||||||
|
## Migration notes
|
||||||
|
|
||||||
|
The refactor brings the example image stack in line with the model/recipe stacks:
|
||||||
|
|
||||||
|
1. `ExampleImagesRouteRegistrar` now owns the declarative route list. Downstream projects
|
||||||
|
should rely on `ExampleImagesRoutes.to_route_mapping()` instead of manually wiring
|
||||||
|
handler callables.
|
||||||
|
2. `ExampleImagesRoutes` caches its `ExampleImagesHandlerSet` just like
|
||||||
|
`BaseModelRoutes`. If you previously instantiated handlers directly, inject custom
|
||||||
|
collaborators via the controller constructor (`download_manager`, `processor`,
|
||||||
|
`file_manager`) to keep test seams predictable.
|
||||||
|
3. Tests that mocked `ExampleImagesRoutes.setup_routes` should switch to patching
|
||||||
|
`DownloadExampleImagesUseCase`/`ImportExampleImagesUseCase` at import time. The handlers
|
||||||
|
expect those abstractions to surface validation/concurrency errors, and bypassing them
|
||||||
|
will skip the HTTP-friendly error mapping.
|
||||||
|
|
||||||
|
## Extending the stack
|
||||||
|
|
||||||
|
1. Add the endpoint to `ROUTE_DEFINITIONS` with a unique `handler_name`.
|
||||||
|
2. Expose the coroutine on an existing handler class (or create a new handler and extend
|
||||||
|
`ExampleImagesHandlerSet`).
|
||||||
|
3. Wire additional services or factories inside `_build_handler_set` on
|
||||||
|
`ExampleImagesRoutes`, mirroring how the model stack introduces new use cases.
|
||||||
|
|
||||||
|
`tests/routes/test_example_images_routes.py` exercises registrar binding, download pause
|
||||||
|
flows, and import validations. Use it as a template when introducing new handler
|
||||||
|
collaborators or error mappings.
|
||||||
100
docs/architecture/model_routes.md
Normal file
100
docs/architecture/model_routes.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Base model route architecture
|
||||||
|
|
||||||
|
The model routing stack now splits HTTP wiring, orchestration logic, and
|
||||||
|
business rules into discrete layers. The goal is to make it obvious where a
|
||||||
|
new collaborator should live and which contract it must honour. The diagram
|
||||||
|
below captures the end-to-end flow for a typical request:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph HTTP
|
||||||
|
A[ModelRouteRegistrar] -->|binds| B[BaseModelRoutes handler proxy]
|
||||||
|
end
|
||||||
|
subgraph Application
|
||||||
|
B --> C[ModelHandlerSet]
|
||||||
|
C --> D1[Handlers]
|
||||||
|
D1 --> E1[Use cases]
|
||||||
|
E1 --> F1[Services / scanners]
|
||||||
|
end
|
||||||
|
subgraph Side Effects
|
||||||
|
F1 --> G1[Cache & metadata]
|
||||||
|
F1 --> G2[Filesystem]
|
||||||
|
F1 --> G3[WebSocket state]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Every box maps to a concrete module:
|
||||||
|
|
||||||
|
| Layer | Module(s) | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Registrar | `py/routes/model_route_registrar.py` | Declarative list of routes shared by every model type and helper methods for binding them to an `aiohttp` application. |
|
||||||
|
| Route controller | `py/routes/base_model_routes.py` | Constructs the handler graph, injects shared services, exposes proxies that surface `503 Service not ready` when the model service has not been attached. |
|
||||||
|
| Handler set | `py/routes/handlers/model_handlers.py` | Thin HTTP adapters grouped by concern (page rendering, listings, mutations, queries, downloads, CivitAI integration, move operations, auto-organize). |
|
||||||
|
| Use cases | `py/services/use_cases/*.py` | Encapsulate long-running flows (`DownloadModelUseCase`, `BulkMetadataRefreshUseCase`, `AutoOrganizeUseCase`). They normalise validation errors and concurrency constraints before returning control to the handlers. |
|
||||||
|
| Services | `py/services/*.py` | Existing services and scanners that mutate caches, write metadata, move files, and broadcast WebSocket updates. |
|
||||||
|
|
||||||
|
## Handler responsibilities & contracts
|
||||||
|
|
||||||
|
`ModelHandlerSet` flattens the handler objects into the exact callables used by
|
||||||
|
the registrar. The table below highlights the separation of concerns within
|
||||||
|
the set and the invariants that must hold after each handler returns.
|
||||||
|
|
||||||
|
| Handler | Key endpoints | Collaborators | Contracts |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `ModelPageView` | `/{prefix}` | `SettingsManager`, `server_i18n`, Jinja environment, `service.scanner` | Template is rendered with `is_initializing` flag when caches are cold; i18n filter is registered exactly once per environment instance. |
|
||||||
|
| `ModelListingHandler` | `/api/lm/{prefix}/list` | `service.get_paginated_data`, `service.format_response` | Listings respect pagination query parameters and cap `page_size` at 100; every item is formatted before response. |
|
||||||
|
| `ModelManagementHandler` | Mutations (delete, exclude, metadata, preview, tags, rename, bulk delete, duplicate verification) | `ModelLifecycleService`, `MetadataSyncService`, `PreviewAssetService`, `TagUpdateService`, scanner cache/index | Cache state mirrors filesystem changes: deletes prune cache & hash index, preview replacements synchronise metadata and cache NSFW levels, metadata saves trigger cache resort when names change. |
|
||||||
|
| `ModelQueryHandler` | Read-only queries (top tags, folders, duplicates, metadata, URLs) | Service query helpers & scanner cache | Outputs always wrapped in `{"success": True}` when no error; duplicate/filename grouping omits empty entries; invalid parameters (e.g. missing `model_root`) return HTTP 400. |
|
||||||
|
| `ModelDownloadHandler` | `/api/lm/download-model`, `/download-model-get`, `/download-progress/{id}`, `/cancel-download-get` | `DownloadModelUseCase`, `DownloadCoordinator`, `WebSocketManager` | Payload validation errors become HTTP 400 without mutating download progress cache; early-access failures surface as HTTP 401; successful downloads cache progress snapshots that back both WebSocket broadcasts and polling endpoints. |
|
||||||
|
| `ModelCivitaiHandler` | CivitAI metadata routes | `MetadataSyncService`, metadata provider factory, `BulkMetadataRefreshUseCase` | `fetch_all_civitai` streams progress via `WebSocketBroadcastCallback`; version lookups validate model type before returning; local availability fields derive from hash lookups without mutating cache state. |
|
||||||
|
| `ModelMoveHandler` | `move_model`, `move_models_bulk` | `ModelMoveService` | Moves execute atomically per request; bulk operations aggregate success/failure per file set. |
|
||||||
|
| `ModelAutoOrganizeHandler` | `/api/lm/{prefix}/auto-organize` (GET/POST), `/auto-organize-progress` | `AutoOrganizeUseCase`, `WebSocketProgressCallback`, `WebSocketManager` | Enforces single-flight execution using the shared lock; progress broadcasts remain available to polling clients until explicitly cleared; conflicts return HTTP 409 with a descriptive error. |
|
||||||
|
|
||||||
|
## Use case boundaries
|
||||||
|
|
||||||
|
Each use case exposes a narrow asynchronous API that hides the underlying
|
||||||
|
services. Their error mapping is essential for predictable HTTP responses.
|
||||||
|
|
||||||
|
| Use case | Entry point | Dependencies | Guarantees |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `DownloadModelUseCase` | `execute(payload)` | `DownloadCoordinator.schedule_download` | Translates `ValueError` into `DownloadModelValidationError` for HTTP 400, recognises early-access errors (`"401"` in message) and surfaces them as `DownloadModelEarlyAccessError`, forwards success dictionaries untouched. |
|
||||||
|
| `AutoOrganizeUseCase` | `execute(file_paths, progress_callback)` | `ModelFileService.auto_organize_models`, `WebSocketManager` lock | Guarded by `ws_manager` lock + status checks; raises `AutoOrganizeInProgressError` before invoking the file service when another run is already active. |
|
||||||
|
| `BulkMetadataRefreshUseCase` | `execute_with_error_handling(progress_callback)` | `MetadataSyncService`, `SettingsManager`, `WebSocketBroadcastCallback` | Iterates through cached models, applies metadata sync, emits progress snapshots that handlers broadcast unchanged. |
|
||||||
|
|
||||||
|
## Maintaining legacy contracts
|
||||||
|
|
||||||
|
The refactor preserves the invariants called out in the previous architecture
|
||||||
|
notes. The most critical ones are reiterated here to emphasise the
|
||||||
|
collaboration points:
|
||||||
|
|
||||||
|
1. **Cache mutations** – Delete, exclude, rename, and bulk delete operations are
|
||||||
|
channelled through `ModelManagementHandler`. The handler delegates to
|
||||||
|
`ModelLifecycleService` or `MetadataSyncService`, and the scanner cache is
|
||||||
|
mutated in-place before the handler returns. The accompanying tests assert
|
||||||
|
that `scanner._cache.raw_data` and `scanner._hash_index` stay in sync after
|
||||||
|
each mutation.
|
||||||
|
2. **Preview updates** – `PreviewAssetService.replace_preview` writes the new
|
||||||
|
asset, `MetadataSyncService` persists the JSON metadata, and
|
||||||
|
`scanner.update_preview_in_cache` mirrors the change. The handler returns
|
||||||
|
the static URL produced by `config.get_preview_static_url`, keeping browser
|
||||||
|
clients in lockstep with disk state.
|
||||||
|
3. **Download progress** – `DownloadCoordinator.schedule_download` generates the
|
||||||
|
download identifier, registers a WebSocket progress callback, and caches the
|
||||||
|
latest numeric progress via `WebSocketManager`. Both `download_model`
|
||||||
|
responses and `/download-progress/{id}` polling read from the same cache to
|
||||||
|
guarantee consistent progress reporting across transports.
|
||||||
|
|
||||||
|
## Extending the stack
|
||||||
|
|
||||||
|
To add a new shared route:
|
||||||
|
|
||||||
|
1. Declare it in `COMMON_ROUTE_DEFINITIONS` using a unique handler name.
|
||||||
|
2. Implement the corresponding coroutine on one of the handlers inside
|
||||||
|
`ModelHandlerSet` (or introduce a new handler class when the concern does not
|
||||||
|
fit existing ones).
|
||||||
|
3. Inject additional dependencies in `BaseModelRoutes._create_handler_set` by
|
||||||
|
wiring services or use cases through the constructor parameters.
|
||||||
|
|
||||||
|
Model-specific routes should continue to be registered inside the subclass
|
||||||
|
implementation of `setup_specific_routes`, reusing the shared registrar where
|
||||||
|
possible.
|
||||||
34
docs/architecture/multi_library_design.md
Normal file
34
docs/architecture/multi_library_design.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Multi-Library Management for Standalone Mode
|
||||||
|
|
||||||
|
## Requirements Summary
|
||||||
|
- **Independent libraries**: In standalone mode, users can maintain multiple libraries, where each library represents a distinct set of model folders (LoRAs, checkpoints, embeddings, etc.). Only one library is active at any given time, but users need a fast way to switch between them.
|
||||||
|
- **Library-specific settings**: The fields that vary per library are `folder_paths`, `default_lora_root`, `default_checkpoint_root`, and `default_embedding_root` inside `settings.json`.
|
||||||
|
- **Persistent caches**: Every library must have its own SQLite persistent model cache so that metadata generated for one library does not leak into another.
|
||||||
|
- **Backward compatibility**: Existing single-library setups should continue to work. When no multi-library configuration is provided, the application should behave exactly as before.
|
||||||
|
|
||||||
|
## Proposed Design
|
||||||
|
1. **Library registry**
|
||||||
|
- Extend the standalone configuration to hold a list of libraries, each identified by a unique name.
|
||||||
|
- Each entry stores the folder path configuration plus any library-scoped metadata (e.g. creation time, display name).
|
||||||
|
- The active library key is stored separately to allow quick switching without rewriting the full config.
|
||||||
|
2. **Settings management**
|
||||||
|
- Update `settings_manager` to load and persist the library registry. When a library is activated, hydrate the in-memory settings object with that library's folder configuration.
|
||||||
|
- Provide helper methods for creating, renaming, and deleting libraries, ensuring validation for duplicate names and path collisions.
|
||||||
|
- Continue writing the active library settings to `settings.json` for compatibility, while storing the registry in a new section such as `libraries`.
|
||||||
|
3. **Persistent model cache**
|
||||||
|
- Derive the SQLite file path from the active library, e.g. `model_cache_<library>.sqlite` or a nested directory structure like `model_cache/<library>/models.sqlite`.
|
||||||
|
- Update `PersistentModelCache` so it resolves the database path dynamically whenever the active library changes. Ensure connections are closed before switching to avoid locking issues.
|
||||||
|
- Migrate existing single cache files by treating them as the default library's cache.
|
||||||
|
4. **Model scanning workflow**
|
||||||
|
- Modify `ModelScanner` and related services to react to library switches by clearing in-memory caches, re-reading folder paths, and rehydrating metadata from the library-specific SQLite cache.
|
||||||
|
- Provide API endpoints in standalone mode to list libraries, activate one, and trigger a rescan.
|
||||||
|
5. **UI/UX considerations**
|
||||||
|
- In the standalone UI, introduce a library selector component that surfaces available libraries and offers quick switching.
|
||||||
|
- Offer feedback when switching libraries (e.g. spinner while rescanning) and guard destructive actions with confirmation prompts.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
- **Data migration**: On startup, detect if the old `settings.json` structure is present. If so, create a default library entry using the current folder paths and point the active library to it.
|
||||||
|
- **Thread safety**: Ensure that any long-running scans are cancelled or awaited before switching libraries to prevent race conditions in cache writes.
|
||||||
|
- **Testing**: Add unit tests for the settings manager to cover library CRUD operations and cache path resolution. Include integration tests that simulate switching libraries and verifying that the correct models are loaded.
|
||||||
|
- **Documentation**: Update user guides to explain how to define libraries, switch between them, and where the new cache files are stored.
|
||||||
|
- **Extensibility**: Keep the design open to future per-library settings (e.g. auto-refresh intervals, metadata overrides) by storing library data as objects instead of flat maps.
|
||||||
89
docs/architecture/recipe_routes.md
Normal file
89
docs/architecture/recipe_routes.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Recipe route architecture
|
||||||
|
|
||||||
|
The recipe routing stack now mirrors the modular model route design. HTTP
|
||||||
|
bindings, controller wiring, handler orchestration, and business rules live in
|
||||||
|
separate layers so new behaviours can be added without re-threading the entire
|
||||||
|
feature. The diagram below outlines the flow for a typical request:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph HTTP
|
||||||
|
A[RecipeRouteRegistrar] -->|binds| B[RecipeRoutes controller]
|
||||||
|
end
|
||||||
|
subgraph Application
|
||||||
|
B --> C[RecipeHandlerSet]
|
||||||
|
C --> D1[Handlers]
|
||||||
|
D1 --> E1[Use cases]
|
||||||
|
E1 --> F1[Services / scanners]
|
||||||
|
end
|
||||||
|
subgraph Side Effects
|
||||||
|
F1 --> G1[Cache & fingerprint index]
|
||||||
|
F1 --> G2[Metadata files]
|
||||||
|
F1 --> G3[Temporary shares]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layer responsibilities
|
||||||
|
|
||||||
|
| Layer | Module(s) | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Registrar | `py/routes/recipe_route_registrar.py` | Declarative list of every recipe endpoint and helper methods that bind them to an `aiohttp` application. |
|
||||||
|
| Controller | `py/routes/base_recipe_routes.py`, `py/routes/recipe_routes.py` | Lazily resolves scanners/clients from the service registry, wires shared templates/i18n, instantiates `RecipeHandlerSet`, and exposes a `{handler_name: coroutine}` mapping for the registrar. |
|
||||||
|
| Handler set | `py/routes/handlers/recipe_handlers.py` | Thin HTTP adapters grouped by concern (page view, listings, queries, mutations, sharing). They normalise responses and translate service exceptions into HTTP status codes. |
|
||||||
|
| Services & scanners | `py/services/recipes/*.py`, `py/services/recipe_scanner.py`, `py/services/service_registry.py` | Concrete business logic: metadata parsing, persistence, sharing, fingerprint/index maintenance, and cache refresh. |
|
||||||
|
|
||||||
|
## Handler responsibilities & invariants
|
||||||
|
|
||||||
|
`RecipeHandlerSet` flattens purpose-built handler objects into the callables the
|
||||||
|
registrar binds. Each handler is responsible for a narrow concern and enforces a
|
||||||
|
set of invariants before returning:
|
||||||
|
|
||||||
|
| Handler | Key endpoints | Collaborators | Contracts |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `RecipePageView` | `/loras/recipes` | `SettingsManager`, `server_i18n`, Jinja environment, recipe scanner getter | Template rendered with `is_initializing` flag when caches are still warming; i18n filter registered exactly once per environment instance. |
|
||||||
|
| `RecipeListingHandler` | `/api/lm/recipes`, `/api/lm/recipe/{id}` | `recipe_scanner.get_paginated_data`, `recipe_scanner.get_recipe_by_id` | Listings respect pagination and search filters; every item receives a `file_url` fallback even when metadata is incomplete; missing recipes become HTTP 404. |
|
||||||
|
| `RecipeQueryHandler` | Tag/base-model stats, syntax, LoRA lookups | Recipe scanner cache, `format_recipe_file_url` helper | Cache snapshots are reused without forcing refresh; duplicate lookups collapse groups by fingerprint; syntax lookups return helpful errors when LoRAs are absent. |
|
||||||
|
| `RecipeManagementHandler` | Save, update, reconnect, bulk delete, widget ingest | `RecipePersistenceService`, `RecipeAnalysisService`, recipe scanner | Persistence results propagate HTTP status codes; fingerprint/index updates flow through the scanner before returning; validation errors surface as HTTP 400 without touching disk. |
|
||||||
|
| `RecipeAnalysisHandler` | Uploaded/local/remote analysis | `RecipeAnalysisService`, `civitai_client`, recipe scanner | Unsupported content types map to HTTP 400; download errors (`RecipeDownloadError`) are not retried; every response includes a `loras` array for client compatibility. |
|
||||||
|
| `RecipeSharingHandler` | Share + download | `RecipeSharingService`, recipe scanner | Share responses provide a stable download URL and filename; expired shares surface as HTTP 404; downloads stream via `web.FileResponse` with attachment headers. |
|
||||||
|
|
||||||
|
## Use case boundaries
|
||||||
|
|
||||||
|
The dedicated services encapsulate long-running work so handlers stay thin.
|
||||||
|
|
||||||
|
| Use case | Entry point | Dependencies | Guarantees |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `RecipeAnalysisService` | `analyze_uploaded_image`, `analyze_remote_image`, `analyze_local_image`, `analyze_widget_metadata` | `ExifUtils`, `RecipeParserFactory`, downloader factory, optional metadata collector/processor | Normalises missing/invalid payloads into `RecipeValidationError`; generates consistent fingerprint data to keep duplicate detection stable; temporary files are cleaned up after every analysis path. |
|
||||||
|
| `RecipePersistenceService` | `save_recipe`, `delete_recipe`, `update_recipe`, `reconnect_lora`, `bulk_delete`, `save_recipe_from_widget` | `ExifUtils`, recipe scanner, card preview sizing constants | Writes images/JSON metadata atomically; updates scanner caches and hash indices before returning; recalculates fingerprints whenever LoRA assignments change. |
|
||||||
|
| `RecipeSharingService` | `share_recipe`, `prepare_download` | `tempfile`, recipe scanner | Copies originals to TTL-managed temp files; metadata lookups re-use the scanner; expired shares trigger cleanup and `RecipeNotFoundError`. |
|
||||||
|
|
||||||
|
## Maintaining critical invariants
|
||||||
|
|
||||||
|
* **Cache updates** – Mutations (`save`, `delete`, `bulk_delete`, `update`) call
|
||||||
|
back into the recipe scanner to mutate the in-memory cache and fingerprint
|
||||||
|
index before returning a response. Tests assert that these methods are invoked
|
||||||
|
even when stubbing persistence.
|
||||||
|
* **Fingerprint management** – `RecipePersistenceService` recomputes
|
||||||
|
fingerprints whenever LoRA metadata changes and duplicate lookups use those
|
||||||
|
fingerprints to group recipes. Handlers bubble the resulting IDs so clients
|
||||||
|
can merge duplicates without an extra fetch.
|
||||||
|
* **Metadata synchronisation** – Saving or reconnecting a recipe updates the
|
||||||
|
JSON sidecar, refreshes embedded metadata via `ExifUtils`, and instructs the
|
||||||
|
scanner to resort its cache. Sharing relies on this metadata to generate
|
||||||
|
filenames and ensure downloads stay in sync with on-disk state.
|
||||||
|
|
||||||
|
## Extending the stack
|
||||||
|
|
||||||
|
1. Declare the new endpoint in `ROUTE_DEFINITIONS` with a unique handler name.
|
||||||
|
2. Implement the coroutine on an existing handler or introduce a new handler
|
||||||
|
class inside `py/routes/handlers/recipe_handlers.py` when the concern does
|
||||||
|
not fit existing ones.
|
||||||
|
3. Wire additional collaborators inside
|
||||||
|
`BaseRecipeRoutes._create_handler_set` (inject new services or factories) and
|
||||||
|
expose helper getters on the handler owner if the handler needs to share
|
||||||
|
utilities.
|
||||||
|
|
||||||
|
Integration tests in `tests/routes/test_recipe_routes.py` exercise the listing,
|
||||||
|
mutation, analysis-error, and sharing paths end-to-end, ensuring the controller
|
||||||
|
and handler wiring remains valid as new capabilities are added.
|
||||||
|
|
||||||
46
docs/custom_priority_tags_format.md
Normal file
46
docs/custom_priority_tags_format.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Custom Priority Tag Format Proposal
|
||||||
|
|
||||||
|
To support user-defined priority tags with flexible aliasing across different model types, the configuration will be stored as editable strings. The format balances readability with enough structure for parsing on both the backend and frontend.
|
||||||
|
|
||||||
|
## Format Overview
|
||||||
|
|
||||||
|
- Each model type is declared on its own line: `model_type: entries`.
|
||||||
|
- Entries are comma-separated and ordered by priority from highest to lowest.
|
||||||
|
- An entry may be a single canonical tag (e.g., `realistic`) or a canonical tag with aliases.
|
||||||
|
- Canonical tags define the final folder name that should be used when matching that entry.
|
||||||
|
- Aliases are enclosed in parentheses and separated by `|` (vertical bar).
|
||||||
|
- All matching is case-insensitive; stored canonical names preserve the user-specified casing for folder creation and UI suggestions.
|
||||||
|
|
||||||
|
### Grammar
|
||||||
|
|
||||||
|
```
|
||||||
|
priority-config := model-config { "\n" model-config }
|
||||||
|
model-config := model-type ":" entry-list
|
||||||
|
model-type := <identifier without spaces>
|
||||||
|
entry-list := entry { "," entry }
|
||||||
|
entry := canonical [ "(" alias { "|" alias } ")" ]
|
||||||
|
canonical := <tag text without parentheses or commas>
|
||||||
|
alias := <tag text without parentheses, commas, or pipes>
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
lora: celebrity(celeb|celebrity), stylized, character(char)
|
||||||
|
checkpoint: realistic(realism|realistic), anime(anime-style|toon)
|
||||||
|
embedding: face, celeb(celebrity|celeb)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parsing Notes
|
||||||
|
|
||||||
|
- Whitespace around separators is ignored to make manual editing more forgiving.
|
||||||
|
- Duplicate canonical tags within the same model type collapse to a single entry; the first definition wins.
|
||||||
|
- Aliases map to their canonical tag. When generating folder names, the canonical form is used.
|
||||||
|
- Tags that do not match any alias or canonical entry fall back to the first tag in the model's tag list, preserving current behavior.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
- **Backend:** Convert each model type's string into an ordered list of canonical tags with alias sets. During path generation, iterate by priority order and match tags against both canonical names and their aliases.
|
||||||
|
- **Frontend:** Surface canonical tags as suggestions, optionally displaying aliases in tooltips or secondary text. Input validation should warn about duplicate aliases within the same model type.
|
||||||
|
|
||||||
|
This format allows users to customize priority tag handling per model type while keeping editing simple and avoiding proliferation of folder names through alias normalization.
|
||||||
51
docs/frontend-dom-fixtures.md
Normal file
51
docs/frontend-dom-fixtures.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Frontend DOM Fixture Strategy
|
||||||
|
|
||||||
|
This guide outlines how to reproduce the markup emitted by the Django templates while running Vitest in jsdom. The aim is to make it straightforward to write integration-style unit tests for managers and UI helpers without having to duplicate template fragments inline.
|
||||||
|
|
||||||
|
## Loading Template Markup
|
||||||
|
|
||||||
|
Vitest executes inside Node, so we can read the same HTML templates that ship with the extension:
|
||||||
|
|
||||||
|
1. Use the helper utilities from `tests/frontend/utils/domFixtures.js` to read files under the `templates/` directory.
|
||||||
|
2. Mount the returned markup into `document.body` (or any custom container) before importing the module under test so its query selectors resolve correctly.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { renderTemplate } from '../utils/domFixtures.js'; // adjust the relative path to your spec
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
renderTemplate('loras.html', {
|
||||||
|
dataset: { page: 'loras' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper ensures the dataset is applied to the container, which mirrors how Django sets `data-page` in production.
|
||||||
|
|
||||||
|
## Working with Partial Components
|
||||||
|
|
||||||
|
Many features are implemented as template partials located under `templates/components/`. When a test only needs a fragment (for example, the progress panel or context menu markup), load the component file directly:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const container = renderTemplate('components/progress_panel.html');
|
||||||
|
|
||||||
|
const progressPanel = container.querySelector('#progress-panel');
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern avoids hand-written fixture strings and keeps the tests aligned with the actual markup.
|
||||||
|
|
||||||
|
## Resetting Between Tests
|
||||||
|
|
||||||
|
The shared Vitest setup clears `document.body` and storage APIs before each test. If a suite adds additional DOM nodes outside of the body or needs to reset custom attributes mid-test, use `resetDom()` exported from `domFixtures.js`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { resetDom } from '../utils/domFixtures.js';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetDom();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Provide typed helpers for injecting mock script tags (e.g., replicating ComfyUI globals).
|
||||||
|
- Compose higher-level fixtures that mimic specific pages (loras, checkpoints, recipes) once those managers receive dedicated suites.
|
||||||
44
docs/frontend-filtering-test-matrix.md
Normal file
44
docs/frontend-filtering-test-matrix.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# LoRA & Checkpoints Filtering/Sorting Test Matrix
|
||||||
|
|
||||||
|
This matrix captures the scenarios that Phase 3 frontend tests should cover for the LoRA and Checkpoint managers. It focuses on how search, filter, sort, and duplicate badge toggles interact so future specs can share fixtures and expectations.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- **Components**: `PageControls`, `FilterManager`, `SearchManager`, and `ModelDuplicatesManager` wiring invoked through `CheckpointsPageManager` and `LorasPageManager`.
|
||||||
|
- **Templates**: `templates/loras.html` and `templates/checkpoints.html` along with shared filter panel and toolbar partials.
|
||||||
|
- **APIs**: Requests issued through `baseModelApi.fetchModels` (via `resetAndReload`/`refreshModels`) and duplicates badge updates.
|
||||||
|
|
||||||
|
## Shared Setup Considerations
|
||||||
|
|
||||||
|
1. Render full page templates using `renderLorasPage` / `renderCheckpointsPage` helpers before importing modules so DOM queries resolve.
|
||||||
|
2. Stub storage helpers (`getStorageItem`, `setStorageItem`, `getSessionItem`, `setSessionItem`) to observe persistence behavior without mutating real storage.
|
||||||
|
3. Mock `sidebarManager` to capture refresh calls triggered after sort/filter actions.
|
||||||
|
4. Provide fake API implementations exposing `resetAndReload`, `refreshModels`, `fetchFromCivitai`, `toggleBulkMode`, and `clearCustomFilter` so control events remain asynchronous but deterministic.
|
||||||
|
5. Supply a minimal `ModelDuplicatesManager` mock exposing `toggleDuplicateMode`, `checkDuplicatesCount`, and `updateDuplicatesBadgeAfterRefresh` to validate duplicate badge wiring.
|
||||||
|
|
||||||
|
## Scenario Matrix
|
||||||
|
|
||||||
|
| ID | Feature | Scenario | LoRAs Expectations | Checkpoints Expectations | Notes |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| F-01 | Search filter | Typing a query updates `pageState.filters.search`, persists to session, and triggers `resetAndReload` on submit | Validate `SearchManager` writes query and reloads via API stub; confirm LoRA cards pass query downstream | Same as LoRAs | Cover `enter` press and clicking search icon |
|
||||||
|
| F-02 | Tag filter | Selecting a tag chip cycles include ➜ exclude ➜ clear, updates storage, and reloads results | Tag state stored under `filters.tags[tagName] = 'include'|'exclude'`; `FilterManager.applyFilters` persists and triggers `resetAndReload(true)` | Same; ensure base model tag set is scoped to checkpoints dataset | Include removal path |
|
||||||
|
| F-03 | Base model filter | Toggling base model checkboxes updates `filters.baseModel`, persists, and reloads | Ensure only LoRA-supported models show; toggle multi-select | Ensure SDXL/Flux base models appear as expected | Capture UI state restored from storage on next init |
|
||||||
|
| F-04 | Favorites-only | Clicking favorites toggle updates session flag and calls `resetAndReload(true)` | Button gains `.active` class and API called | Same | Verify duplicates badge refresh when active |
|
||||||
|
| F-05 | Sort selection | Changing sort select saves preference (legacy + new format) and reloads | Confirm `PageControls.saveSortPreference` invoked with option and API called | Same with checkpoints-specific defaults | Cover `convertLegacySortFormat` branch |
|
||||||
|
| F-06 | Filter persistence | Re-initializing manager loads stored filters/sort and updates DOM | Filters pre-populate chips/checkboxes; favorites state restored | Same | Requires simulating repeated construction |
|
||||||
|
| F-07 | Combined filters | Applying search + tag + base model yields aggregated query params for fetch | Assert API receives merged filter payload | Same | Validate toast messaging for active filters |
|
||||||
|
| F-08 | Clearing filters | Using "Clear filters" resets state, storage, and reloads list | `FilterManager.clearFilters` empties `filters`, removes active class, shows toast | Same | Ensure favorites-only toggle unaffected |
|
||||||
|
| F-09 | Duplicate badge toggle | Pressing "Find duplicates" toggles duplicate mode and updates badge counts post-refresh | `ModelDuplicatesManager.toggleDuplicateMode` invoked and badge refresh called after API rebuild | Same plus checkpoint-specific duplicate badge dataset | Connects to future duplicate-specific specs |
|
||||||
|
| F-10 | Bulk actions menu | Opening bulk dropdown keeps filters intact and closes on outside click | Validate dropdown class toggling and no unintended reload | Same | Guard against regression when dropdown interacts with filters |
|
||||||
|
|
||||||
|
## Automation Coverage Status
|
||||||
|
|
||||||
|
- ✅ F-01 Search filter, F-02 Tag filter, F-03 Base model filter, F-04 Favorites-only toggle, F-05 Sort selection, and F-09 Duplicate badge toggle are covered by `tests/frontend/components/pageControls.filtering.test.js` for both LoRA and checkpoint pages.
|
||||||
|
- ⏳ F-06 Filter persistence, F-07 Combined filters, F-08 Clearing filters, and F-10 Bulk actions remain to be automated alongside upcoming bulk mode refinements.
|
||||||
|
|
||||||
|
## Coverage Gaps & Follow-Ups
|
||||||
|
|
||||||
|
- Write Vitest suites that exercise the matrix for both managers, sharing fixtures through page helpers to avoid duplication.
|
||||||
|
- Capture API parameter assertions by inspecting `baseModelApi.fetchModels` mocks rather than relying solely on state mutations.
|
||||||
|
- Add regression cases for legacy storage migrations (old filter keys) once fixtures exist for older payloads.
|
||||||
|
- Extend duplicate badge coverage with scenarios where `checkDuplicatesCount` signals zero duplicates versus pending calculations.
|
||||||
33
docs/frontend-testing-roadmap.md
Normal file
33
docs/frontend-testing-roadmap.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Frontend Automation Testing Roadmap
|
||||||
|
|
||||||
|
This roadmap tracks the planned rollout of automated testing for the ComfyUI LoRA Manager frontend. Each phase builds on the infrastructure introduced in this change set and records progress so future contributors can quickly identify the next tasks.
|
||||||
|
|
||||||
|
## Phase Overview
|
||||||
|
|
||||||
|
| Phase | Goal | Primary Focus | Status | Notes |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Phase 0 | Establish baseline tooling | Add Node test runner, jsdom environment, and seed smoke tests | ✅ Complete | Vitest + jsdom configured, example state tests committed |
|
||||||
|
| Phase 1 | Cover state management logic | Unit test selectors, derived data helpers, and storage utilities under `static/js/state` and `static/js/utils` | ✅ Complete | Storage helpers and state selectors now exercised via deterministic suites |
|
||||||
|
| Phase 2 | Test AppCore orchestration | Simulate page bootstrapping, infinite scroll hooks, and manager registration using JSDOM DOM fixtures | ✅ Complete | AppCore initialization + page feature suites now validate manager wiring, infinite scroll hooks, and onboarding gating |
|
||||||
|
| Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | ✅ Complete | LoRA/checkpoint suites expanded; embeddings + recipes managers now covered with initialization, filtering, and duplicate workflows |
|
||||||
|
| Phase 4 | Interaction-level regression tests | Exercise template fragments, modals, and menus to ensure UI wiring remains intact | ✅ Complete | Vitest DOM suites cover NSFW selector, recipe modal editing, and global context menus |
|
||||||
|
| Phase 5 | Continuous integration & coverage | Integrate frontend tests into CI workflow and track coverage metrics | ✅ Complete | CI workflow runs Vitest and aggregates V8 coverage into `coverage/frontend` via a dedicated script |
|
||||||
|
|
||||||
|
## Next Steps Checklist
|
||||||
|
|
||||||
|
- [x] Expand unit tests for `storageHelpers` covering migrations and namespace behavior.
|
||||||
|
- [x] Document DOM fixture strategy for reproducing template structures in tests.
|
||||||
|
- [x] Prototype AppCore initialization test that verifies manager bootstrapping with stubbed dependencies.
|
||||||
|
- [x] Add AppCore page feature suite exercising context menu creation and infinite scroll registration via DOM fixtures.
|
||||||
|
- [x] Extend AppCore orchestration tests to cover manager wiring, bulk menu setup, and onboarding gating scenarios.
|
||||||
|
- [x] Add interaction regression suites for context menus and recipe modals to complete Phase 4.
|
||||||
|
- [x] Evaluate integrating coverage reporting once test surface grows (> 20 specs).
|
||||||
|
- [x] Create shared fixtures for the loras and checkpoints pages once dedicated manager suites are added.
|
||||||
|
- [x] Draft focused test matrix for loras/checkpoints manager filtering and sorting paths ahead of Phase 3.
|
||||||
|
- [x] Implement LoRAs manager filtering/sorting specs for scenarios F-01–F-05 & F-09; queue remaining edge cases after duplicate/bulk flows stabilize.
|
||||||
|
- [x] Implement checkpoints manager filtering/sorting specs for scenarios F-01–F-05 & F-09; cover remaining paths alongside bulk action work.
|
||||||
|
- [x] Implement checkpoints page manager smoke tests covering initialization and duplicate badge wiring.
|
||||||
|
- [x] Outline focused checkpoints scenarios (filtering, sorting, duplicate badge toggles) to feed into the shared test matrix.
|
||||||
|
- [ ] Add duplicate badge regression coverage for zero/pending states after API refreshes.
|
||||||
|
|
||||||
|
Maintaining this roadmap alongside code changes will make it easier to append new automated test tasks and update their progress.
|
||||||
28
docs/library-switching.md
Normal file
28
docs/library-switching.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Library Switching and Preview Routes
|
||||||
|
|
||||||
|
Library switching no longer requires restarting the backend. The preview
|
||||||
|
thumbnails shown in the UI are now served through a dynamic endpoint that
|
||||||
|
resolves files against the folders registered for the active library at request
|
||||||
|
time. This allows the multi-library flow to update model roots without touching
|
||||||
|
the aiohttp router, so previews remain available immediately after a switch.
|
||||||
|
|
||||||
|
## How the dynamic preview endpoint works
|
||||||
|
|
||||||
|
* `config.get_preview_static_url()` now returns `/api/lm/previews?path=<encoded>`
|
||||||
|
for any preview path. The raw filesystem location is URL encoded so that it
|
||||||
|
can be passed through the query string without leaking directory structure in
|
||||||
|
the route itself.【F:py/config.py†L398-L404】
|
||||||
|
* `PreviewRoutes` exposes the `/api/lm/previews` handler which validates the
|
||||||
|
decoded path against the directories registered for the current library. The
|
||||||
|
request is rejected if it falls outside those roots or if the file does not
|
||||||
|
exist.【F:py/routes/preview_routes.py†L5-L21】【F:py/routes/handlers/preview_handlers.py†L9-L48】
|
||||||
|
* `Config` keeps an up-to-date cache of allowed preview roots. Every time a
|
||||||
|
library is applied the cache is rebuilt using the declared LoRA, checkpoint
|
||||||
|
and embedding directories (including symlink targets). The validation logic
|
||||||
|
checks preview requests against this cache.【F:py/config.py†L51-L68】【F:py/config.py†L180-L248】【F:py/config.py†L332-L346】
|
||||||
|
|
||||||
|
Both the ComfyUI runtime (`LoraManager.add_routes`) and the standalone launcher
|
||||||
|
(`StandaloneLoraManager.add_routes`) register the new preview routes instead of
|
||||||
|
mounting a static directory per root. Switching libraries therefore works
|
||||||
|
without restarting the application, and preview URLs generated before or after a
|
||||||
|
switch continue to resolve correctly.【F:py/lora_manager.py†L21-L82】【F:standalone.py†L302-L315】
|
||||||
71
docs/priority_tags_help.md
Normal file
71
docs/priority_tags_help.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Priority Tags Configuration Guide
|
||||||
|
|
||||||
|
This guide explains how to tailor the tag priority order that powers folder naming and tag suggestions in the LoRA Manager. You only need to edit the comma-separated list of entries shown in the **Priority Tags** field for each model type.
|
||||||
|
|
||||||
|
## 1. Pick the Model Type
|
||||||
|
|
||||||
|
In the **Priority Tags** dialog you will find one tab per model type (LoRA, Checkpoint, Embedding). Select the tab you want to update; changes on one tab do not affect the others.
|
||||||
|
|
||||||
|
## 2. Edit the Entry List
|
||||||
|
|
||||||
|
Inside the textarea you will see a line similar to:
|
||||||
|
|
||||||
|
```
|
||||||
|
character, concept, style(toon|toon_style)
|
||||||
|
```
|
||||||
|
|
||||||
|
This entire line is the **entry list**. Replace it with your own ordered list.
|
||||||
|
|
||||||
|
### Entry Rules
|
||||||
|
|
||||||
|
Each entry is separated by a comma, in order from highest to lowest priority:
|
||||||
|
|
||||||
|
- **Canonical tag only:** `realistic`
|
||||||
|
- **Canonical tag with aliases:** `character(char|chars)`
|
||||||
|
|
||||||
|
Aliases live inside `()` and are separated with `|`. The canonical name is what appears in folder names and UI suggestions when any of the aliases are detected. Matching is case-insensitive.
|
||||||
|
|
||||||
|
## Use `{first_tag}` in Path Templates
|
||||||
|
|
||||||
|
When your path template contains `{first_tag}`, the app picks a folder name based on your priority list and the model’s own tags:
|
||||||
|
|
||||||
|
- It checks the priority list from top to bottom. If a canonical tag or any of its aliases appear in the model tags, that canonical name becomes the folder name.
|
||||||
|
- If no priority tags are found but the model has tags, the very first model tag is used.
|
||||||
|
- If the model has no tags at all, the folder falls back to `no tags`.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
With a template like `/{model_type}/{first_tag}` and the priority entry list `character(char|chars), style(anime|toon)`:
|
||||||
|
|
||||||
|
| Model Tags | Folder Name | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `["chars", "female"]` | `character` | `chars` matches the `character` alias, so the canonical wins. |
|
||||||
|
| `["anime", "portrait"]` | `style` | `anime` hits the `style` entry, so its canonical label is used. |
|
||||||
|
| `["portrait", "bw"]` | `portrait` | No priority match, so the first model tag is used. |
|
||||||
|
| `[]` | `no tags` | Nothing to match, so the fallback is applied. |
|
||||||
|
|
||||||
|
## 3. Save the Settings
|
||||||
|
|
||||||
|
After editing the entry list, press **Enter** to save. Use **Shift+Enter** whenever you need a new line. Clicking outside the field also saves automatically. A success toast confirms the update.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
| Goal | Entry List |
|
||||||
|
| --- | --- |
|
||||||
|
| Prefer people over styles | `character, portraits, style(anime\|toon)` |
|
||||||
|
| Group sci-fi variants | `sci-fi(scifi\|science_fiction), cyberpunk(cyber\|punk)` |
|
||||||
|
| Alias shorthand tags | `realistic(real\|realisim), photorealistic(photo_real)` |
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Keep canonical names short and meaningful—they become folder names.
|
||||||
|
- Place the most important categories first; the first match wins.
|
||||||
|
- Avoid duplicate canonical names within the same list; only the first instance is used.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Unexpected folder name?** Check that the canonical name you want is placed before other matches.
|
||||||
|
- **Alias not working?** Ensure the alias is inside parentheses and separated with `|`, e.g. `character(char|chars)`.
|
||||||
|
- **Validation error?** Look for missing parentheses or stray commas. Each entry must follow the `canonical(alias|alias)` pattern or just `canonical`.
|
||||||
|
|
||||||
|
With these basics you can quickly adapt Priority Tags to match your library’s organization style.
|
||||||
26
docs/testing/coverage_analysis.md
Normal file
26
docs/testing/coverage_analysis.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Backend Test Coverage Notes
|
||||||
|
|
||||||
|
## Pytest Execution
|
||||||
|
- Command: `python -m pytest`
|
||||||
|
- Result: All 283 collected tests passed in the current environment.
|
||||||
|
- Coverage tooling (``pytest-cov``/``coverage``) is unavailable in the offline sandbox, so line-level metrics could not be generated. The earlier attempt to install ``pytest-cov`` failed because the package index cannot be reached from the container.
|
||||||
|
|
||||||
|
## High-Priority Gaps to Address
|
||||||
|
|
||||||
|
### 1. Standalone server bootstrapping
|
||||||
|
* **Source:** [`standalone.py`](../../standalone.py)
|
||||||
|
* **Why it matters:** The standalone entry point wires together the aiohttp application, static asset routes, model-route registration, and configuration validation. None of these behaviours are covered by automated tests, leaving regressions in bootstrapping logic undetected.
|
||||||
|
* **Suggested coverage:** Add integration-style tests that instantiate `StandaloneServer`/`StandaloneLoraManager` with temporary settings and assert that routes (HTTP + websocket) are registered, configuration warnings fire for missing paths, and the mock ComfyUI shims behave as expected.
|
||||||
|
|
||||||
|
### 2. Model service registration factory
|
||||||
|
* **Source:** [`py/services/model_service_factory.py`](../../py/services/model_service_factory.py)
|
||||||
|
* **Why it matters:** The factory coordinates which model services and routes the API exposes, including error handling when unknown model types are requested. No current tests verify registration, memoization of route instances, or the logging path on failures.
|
||||||
|
* **Suggested coverage:** Unit tests that exercise `register_model_type`, `get_route_instance`, error branches in `get_service_class`/`get_route_class`, and `setup_all_routes` when a route setup raises. Use lightweight fakes to confirm the logger is called and state is cleared via `clear_registrations`.
|
||||||
|
|
||||||
|
### 3. Server-side i18n helper
|
||||||
|
* **Source:** [`py/services/server_i18n.py`](../../py/services/server_i18n.py)
|
||||||
|
* **Why it matters:** Template rendering relies on the `ServerI18nManager` to load locale JSON, perform key lookups, and format parameters. The fallback logic (dot-notation lookup, English fallbacks, placeholder substitution) is untested, so malformed locale files or regressions in placeholder handling would slip through.
|
||||||
|
* **Suggested coverage:** Tests that load fixture locale dictionaries, assert `set_locale` fallbacks, verify nested key resolution and placeholder substitution, and ensure missing keys return the original identifier.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
Prioritize creating focused unit tests around these modules, then re-run pytest once coverage tooling is available to confirm the new tests close the identified gaps.
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 669 KiB |
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 669 KiB After Width: | Height: | Size: 668 KiB |
File diff suppressed because one or more lines are too long
344
locales/de.json
344
locales/de.json
@@ -16,7 +16,9 @@
|
|||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
"unknown": "Unbekannt",
|
"unknown": "Unbekannt",
|
||||||
"date": "Datum",
|
"date": "Datum",
|
||||||
"version": "Version"
|
"version": "Version",
|
||||||
|
"enabled": "Aktiviert",
|
||||||
|
"disabled": "Deaktiviert"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"select": "Sprache",
|
"select": "Sprache",
|
||||||
@@ -29,7 +31,8 @@
|
|||||||
"japanese": "日本語",
|
"japanese": "日本語",
|
||||||
"korean": "한국어",
|
"korean": "한국어",
|
||||||
"french": "Français",
|
"french": "Français",
|
||||||
"spanish": "Español"
|
"spanish": "Español",
|
||||||
|
"Hebrew": "עברית"
|
||||||
},
|
},
|
||||||
"fileSize": {
|
"fileSize": {
|
||||||
"zero": "0 Bytes",
|
"zero": "0 Bytes",
|
||||||
@@ -98,7 +101,12 @@
|
|||||||
"checkpointNameCopied": "Checkpoint-Name kopiert",
|
"checkpointNameCopied": "Checkpoint-Name kopiert",
|
||||||
"toggleBlur": "Unschärfe umschalten",
|
"toggleBlur": "Unschärfe umschalten",
|
||||||
"show": "Anzeigen",
|
"show": "Anzeigen",
|
||||||
"openExampleImages": "Beispielbilder-Ordner öffnen"
|
"openExampleImages": "Beispielbilder-Ordner öffnen",
|
||||||
|
"replacePreview": "Vorschau ersetzen",
|
||||||
|
"copyCheckpointName": "Checkpoint-Name kopieren",
|
||||||
|
"copyEmbeddingName": "Embedding-Name kopieren",
|
||||||
|
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
||||||
|
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
||||||
},
|
},
|
||||||
"nsfw": {
|
"nsfw": {
|
||||||
"matureContent": "Nicht jugendfreie Inhalte",
|
"matureContent": "Nicht jugendfreie Inhalte",
|
||||||
@@ -112,12 +120,45 @@
|
|||||||
"updateFailed": "Fehler beim Aktualisieren des Favoriten-Status"
|
"updateFailed": "Fehler beim Aktualisieren des Favoriten-Status"
|
||||||
},
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"checkpointNotImplemented": "Checkpoint an Workflow senden - Funktion wird implementiert"
|
"checkpointNotImplemented": "Checkpoint an Workflow senden - Funktion wird implementiert",
|
||||||
|
"missingPath": "Modellpfad für diese Karte konnte nicht ermittelt werden"
|
||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"checkError": "Fehler beim Überprüfen der Beispielbilder",
|
"checkError": "Fehler beim Überprüfen der Beispielbilder",
|
||||||
"missingHash": "Fehlende Modell-Hash-Informationen.",
|
"missingHash": "Fehlende Modell-Hash-Informationen.",
|
||||||
"noRemoteImagesAvailable": "Keine Remote-Beispielbilder für dieses Modell auf Civitai verfügbar"
|
"noRemoteImagesAvailable": "Keine Remote-Beispielbilder für dieses Modell auf Civitai verfügbar"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"update": "Update",
|
||||||
|
"updateAvailable": "Update verfügbar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"globalContextMenu": {
|
||||||
|
"downloadExampleImages": {
|
||||||
|
"label": "Beispielbilder herunterladen",
|
||||||
|
"missingPath": "Bitte legen Sie einen Speicherort fest, bevor Sie Beispielbilder herunterladen.",
|
||||||
|
"unavailable": "Beispielbild-Downloads sind noch nicht verfügbar. Versuchen Sie es erneut, nachdem die Seite vollständig geladen ist."
|
||||||
|
},
|
||||||
|
"checkModelUpdates": {
|
||||||
|
"label": "Auf Updates prüfen",
|
||||||
|
"loading": "Prüfe auf {type}-Updates...",
|
||||||
|
"success": "{count} Update(s) für {type} gefunden",
|
||||||
|
"none": "Alle {type} sind auf dem neuesten Stand",
|
||||||
|
"error": "Fehler beim Prüfen auf {type}-Updates: {message}"
|
||||||
|
},
|
||||||
|
"cleanupExampleImages": {
|
||||||
|
"label": "Beispielbild-Ordner bereinigen",
|
||||||
|
"success": "{count} Ordner wurden in den Papierkorb verschoben",
|
||||||
|
"none": "Keine Beispielbild-Ordner mussten bereinigt werden",
|
||||||
|
"partial": "Bereinigung abgeschlossen, {failures} Ordner übersprungen",
|
||||||
|
"error": "Fehler beim Bereinigen der Beispielbild-Ordner: {message}"
|
||||||
|
},
|
||||||
|
"fetchMissingLicenses": {
|
||||||
|
"label": "Refresh license metadata",
|
||||||
|
"loading": "Refreshing license metadata for {typePlural}...",
|
||||||
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
|
"none": "All {typePlural} already have license metadata",
|
||||||
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -154,6 +195,10 @@
|
|||||||
"title": "Modelle filtern",
|
"title": "Modelle filtern",
|
||||||
"baseModel": "Basis-Modell",
|
"baseModel": "Basis-Modell",
|
||||||
"modelTags": "Tags (Top 20)",
|
"modelTags": "Tags (Top 20)",
|
||||||
|
"modelTypes": "Model Types",
|
||||||
|
"license": "Lizenz",
|
||||||
|
"noCreditRequired": "Kein Credit erforderlich",
|
||||||
|
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||||
"clearAll": "Alle Filter löschen"
|
"clearAll": "Alle Filter löschen"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -164,6 +209,7 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"checkUpdates": "Updates prüfen",
|
"checkUpdates": "Updates prüfen",
|
||||||
|
"notifications": "Benachrichtigungen",
|
||||||
"support": "Unterstützung"
|
"support": "Unterstützung"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -171,14 +217,30 @@
|
|||||||
"civitaiApiKey": "Civitai API Key",
|
"civitaiApiKey": "Civitai API Key",
|
||||||
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
||||||
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
||||||
|
"openSettingsFileLocation": {
|
||||||
|
"label": "Einstellungsordner öffnen",
|
||||||
|
"tooltip": "Den Ordner mit der settings.json öffnen",
|
||||||
|
"success": "Einstellungsordner geöffnet",
|
||||||
|
"failed": "Einstellungsordner konnte nicht geöffnet werden"
|
||||||
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "Inhaltsfilterung",
|
"contentFiltering": "Inhaltsfilterung",
|
||||||
"videoSettings": "Video-Einstellungen",
|
"videoSettings": "Video-Einstellungen",
|
||||||
"layoutSettings": "Layout-Einstellungen",
|
"layoutSettings": "Layout-Einstellungen",
|
||||||
"folderSettings": "Ordner-Einstellungen",
|
"folderSettings": "Ordner-Einstellungen",
|
||||||
|
"priorityTags": "Prioritäts-Tags",
|
||||||
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
||||||
"exampleImages": "Beispielbilder",
|
"exampleImages": "Beispielbilder",
|
||||||
"misc": "Verschiedenes"
|
"updateFlags": "Update-Markierungen",
|
||||||
|
"autoOrganize": "Auto-organize",
|
||||||
|
"misc": "Verschiedenes",
|
||||||
|
"metadataArchive": "Metadaten-Archiv-Datenbank",
|
||||||
|
"storageLocation": "Einstellungsort",
|
||||||
|
"proxySettings": "Proxy-Einstellungen"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"locationLabel": "Portabler Modus",
|
||||||
|
"locationHelp": "Aktiviere, um settings.json im Repository zu belassen; deaktiviere, um es im Benutzerkonfigurationsordner zu speichern."
|
||||||
},
|
},
|
||||||
"contentFiltering": {
|
"contentFiltering": {
|
||||||
"blurNsfwContent": "NSFW-Inhalte unscharf stellen",
|
"blurNsfwContent": "NSFW-Inhalte unscharf stellen",
|
||||||
@@ -190,6 +252,15 @@
|
|||||||
"autoplayOnHover": "Videos bei Hover automatisch abspielen",
|
"autoplayOnHover": "Videos bei Hover automatisch abspielen",
|
||||||
"autoplayOnHoverHelp": "Video-Vorschauen nur beim Darüberfahren mit der Maus abspielen"
|
"autoplayOnHoverHelp": "Video-Vorschauen nur beim Darüberfahren mit der Maus abspielen"
|
||||||
},
|
},
|
||||||
|
"autoOrganizeExclusions": {
|
||||||
|
"label": "Auto-Organisierungs-Ausnahmen",
|
||||||
|
"placeholder": "Beispiel: curated/*, */backups/*; *_temp.safetensors",
|
||||||
|
"help": "Dateien überspringen, die mit diesen Wildcard-Mustern übereinstimmen. Mehrere Muster mit Kommas oder Semikolons trennen.",
|
||||||
|
"validation": {
|
||||||
|
"noPatterns": "Geben Sie mindestens ein Muster ein, getrennt durch Kommas oder Semikolons.",
|
||||||
|
"saveFailed": "Fehler beim Speichern der Ausschlüsse: {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "Anzeige-Dichte",
|
"displayDensity": "Anzeige-Dichte",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -199,23 +270,37 @@
|
|||||||
},
|
},
|
||||||
"displayDensityHelp": "Wählen Sie, wie viele Karten pro Zeile angezeigt werden sollen:",
|
"displayDensityHelp": "Wählen Sie, wie viele Karten pro Zeile angezeigt werden sollen:",
|
||||||
"displayDensityDetails": {
|
"displayDensityDetails": {
|
||||||
"default": "Standard: 5 (1080p), 6 (2K), 8 (4K)",
|
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||||
"medium": "Mittel: 6 (1080p), 7 (2K), 9 (4K)",
|
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||||
"compact": "Kompakt: 7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "Warnung: Höhere Dichten können bei Systemen mit begrenzten Ressourcen zu Performance-Problemen führen.",
|
"displayDensityWarning": "Warnung: Höhere Dichten können bei Systemen mit begrenzten Ressourcen zu Performance-Problemen führen.",
|
||||||
|
"showFolderSidebar": "Ordner-Seitenleiste anzeigen",
|
||||||
|
"showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.",
|
||||||
"cardInfoDisplay": "Karten-Info-Anzeige",
|
"cardInfoDisplay": "Karten-Info-Anzeige",
|
||||||
"cardInfoDisplayOptions": {
|
"cardInfoDisplayOptions": {
|
||||||
"always": "Immer sichtbar",
|
"always": "Immer sichtbar",
|
||||||
"hover": "Bei Hover anzeigen"
|
"hover": "Bei Hover anzeigen"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen:",
|
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
|
||||||
"cardInfoDisplayDetails": {
|
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
|
||||||
"always": "Immer sichtbar: Kopf- und Fußzeilen sind immer sichtbar",
|
"modelCardFooterActionOptions": {
|
||||||
"hover": "Bei Hover anzeigen: Kopf- und Fußzeilen erscheinen nur beim Darüberfahren mit der Maus"
|
"exampleImages": "Beispielbilder öffnen",
|
||||||
}
|
"replacePreview": "Vorschau ersetzen"
|
||||||
|
},
|
||||||
|
"modelCardFooterActionHelp": "Wähle aus, was die Schaltfläche unten rechts auf der Karte ausführt",
|
||||||
|
"modelNameDisplay": "Anzeige des Modellnamens",
|
||||||
|
"modelNameDisplayOptions": {
|
||||||
|
"modelName": "Modellname",
|
||||||
|
"fileName": "Dateiname"
|
||||||
|
},
|
||||||
|
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll"
|
||||||
},
|
},
|
||||||
"folderSettings": {
|
"folderSettings": {
|
||||||
|
"activeLibrary": "Aktive Bibliothek",
|
||||||
|
"activeLibraryHelp": "Zwischen den konfigurierten Bibliotheken wechseln, um die Standardordner zu aktualisieren. Eine Änderung der Auswahl lädt die Seite neu.",
|
||||||
|
"loadingLibraries": "Bibliotheken werden geladen...",
|
||||||
|
"noLibraries": "Keine Bibliotheken konfiguriert",
|
||||||
"defaultLoraRoot": "Standard-LoRA-Stammordner",
|
"defaultLoraRoot": "Standard-LoRA-Stammordner",
|
||||||
"defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
"defaultCheckpointRoot": "Standard-Checkpoint-Stammordner",
|
"defaultCheckpointRoot": "Standard-Checkpoint-Stammordner",
|
||||||
@@ -224,6 +309,26 @@
|
|||||||
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
"noDefault": "Kein Standard"
|
"noDefault": "Kein Standard"
|
||||||
},
|
},
|
||||||
|
"priorityTags": {
|
||||||
|
"title": "Prioritäts-Tags",
|
||||||
|
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
|
||||||
|
"placeholder": "character, concept, style(toon|toon_style)",
|
||||||
|
"helpLinkLabel": "Prioritäts-Tags-Hilfe öffnen",
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"embedding": "Embedding"
|
||||||
|
},
|
||||||
|
"saveSuccess": "Prioritäts-Tags aktualisiert.",
|
||||||
|
"saveError": "Prioritäts-Tags konnten nicht aktualisiert werden.",
|
||||||
|
"loadingSuggestions": "Lade Vorschläge...",
|
||||||
|
"validation": {
|
||||||
|
"missingClosingParen": "Eintrag {index} fehlt eine schließende Klammer.",
|
||||||
|
"missingCanonical": "Eintrag {index} muss einen kanonischen Tag-Namen enthalten.",
|
||||||
|
"duplicateCanonical": "Der kanonische Tag \"{tag}\" kommt mehrfach vor.",
|
||||||
|
"unknown": "Ungültige Prioritäts-Tag-Konfiguration."
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadPathTemplates": {
|
"downloadPathTemplates": {
|
||||||
"title": "Download-Pfad-Vorlagen",
|
"title": "Download-Pfad-Vorlagen",
|
||||||
"help": "Konfigurieren Sie Ordnerstrukturen für verschiedene Modelltypen beim Herunterladen von Civitai.",
|
"help": "Konfigurieren Sie Ordnerstrukturen für verschiedene Modelltypen beim Herunterladen von Civitai.",
|
||||||
@@ -236,6 +341,7 @@
|
|||||||
"baseModelFirstTag": "Basis-Modell + Erster Tag",
|
"baseModelFirstTag": "Basis-Modell + Erster Tag",
|
||||||
"baseModelAuthor": "Basis-Modell + Autor",
|
"baseModelAuthor": "Basis-Modell + Autor",
|
||||||
"authorFirstTag": "Autor + Erster Tag",
|
"authorFirstTag": "Autor + Erster Tag",
|
||||||
|
"baseModelAuthorFirstTag": "Basis-Modell + Autor + Erster Tag",
|
||||||
"customTemplate": "Benutzerdefinierte Vorlage"
|
"customTemplate": "Benutzerdefinierte Vorlage"
|
||||||
},
|
},
|
||||||
"customTemplatePlaceholder": "Benutzerdefinierte Vorlage eingeben (z.B. {base_model}/{author}/{first_tag})",
|
"customTemplatePlaceholder": "Benutzerdefinierte Vorlage eingeben (z.B. {base_model}/{author}/{first_tag})",
|
||||||
@@ -270,9 +376,59 @@
|
|||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"restartRequired": "Neustart erforderlich"
|
"restartRequired": "Neustart erforderlich"
|
||||||
},
|
},
|
||||||
|
"updateFlagStrategy": {
|
||||||
|
"label": "Strategie für Update-Markierungen",
|
||||||
|
"help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.",
|
||||||
|
"options": {
|
||||||
|
"sameBase": "Updates nach Basismodell abgleichen",
|
||||||
|
"any": "Jede verfügbare Aktualisierung markieren"
|
||||||
|
}
|
||||||
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
|
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
|
||||||
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen"
|
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen"
|
||||||
|
},
|
||||||
|
"metadataArchive": {
|
||||||
|
"enableArchiveDb": "Metadaten-Archiv-Datenbank aktivieren",
|
||||||
|
"enableArchiveDbHelp": "Verwenden Sie eine lokale Datenbank, um auf Metadaten von Modellen zuzugreifen, die von Civitai gelöscht wurden.",
|
||||||
|
"status": "Status",
|
||||||
|
"statusAvailable": "Verfügbar",
|
||||||
|
"statusUnavailable": "Nicht verfügbar",
|
||||||
|
"enabled": "Aktiviert",
|
||||||
|
"management": "Datenbankverwaltung",
|
||||||
|
"managementHelp": "Laden Sie die Metadaten-Archiv-Datenbank herunter oder entfernen Sie sie",
|
||||||
|
"downloadButton": "Datenbank herunterladen",
|
||||||
|
"downloadingButton": "Wird heruntergeladen...",
|
||||||
|
"downloadedButton": "Heruntergeladen",
|
||||||
|
"removeButton": "Datenbank entfernen",
|
||||||
|
"removingButton": "Wird entfernt...",
|
||||||
|
"downloadSuccess": "Metadaten-Archiv-Datenbank erfolgreich heruntergeladen",
|
||||||
|
"downloadError": "Fehler beim Herunterladen der Metadaten-Archiv-Datenbank",
|
||||||
|
"removeSuccess": "Metadaten-Archiv-Datenbank erfolgreich entfernt",
|
||||||
|
"removeError": "Fehler beim Entfernen der Metadaten-Archiv-Datenbank",
|
||||||
|
"removeConfirm": "Sind Sie sicher, dass Sie die Metadaten-Archiv-Datenbank entfernen möchten? Dadurch wird die lokale Datenbankdatei gelöscht und Sie müssen sie erneut herunterladen, um diese Funktion zu nutzen.",
|
||||||
|
"preparing": "Download wird vorbereitet...",
|
||||||
|
"connecting": "Verbindung zum Download-Server wird hergestellt...",
|
||||||
|
"completed": "Abgeschlossen",
|
||||||
|
"downloadComplete": "Download erfolgreich abgeschlossen"
|
||||||
|
},
|
||||||
|
"proxySettings": {
|
||||||
|
"enableProxy": "App-Proxy aktivieren",
|
||||||
|
"enableProxyHelp": "Aktivieren Sie benutzerdefinierte Proxy-Einstellungen für diese Anwendung. Überschreibt die System-Proxy-Einstellungen.",
|
||||||
|
"proxyType": "Proxy-Typ",
|
||||||
|
"proxyTypeHelp": "Wählen Sie den Typ des Proxy-Servers (HTTP, HTTPS, SOCKS4, SOCKS5)",
|
||||||
|
"proxyHost": "Proxy-Host",
|
||||||
|
"proxyHostPlaceholder": "proxy.beispiel.de",
|
||||||
|
"proxyHostHelp": "Der Hostname oder die IP-Adresse Ihres Proxy-Servers",
|
||||||
|
"proxyPort": "Proxy-Port",
|
||||||
|
"proxyPortPlaceholder": "8080",
|
||||||
|
"proxyPortHelp": "Die Portnummer Ihres Proxy-Servers",
|
||||||
|
"proxyUsername": "Benutzername (optional)",
|
||||||
|
"proxyUsernamePlaceholder": "benutzername",
|
||||||
|
"proxyUsernameHelp": "Benutzername für die Proxy-Authentifizierung (falls erforderlich)",
|
||||||
|
"proxyPassword": "Passwort (optional)",
|
||||||
|
"proxyPasswordPlaceholder": "passwort",
|
||||||
|
"proxyPasswordHelp": "Passwort für die Proxy-Authentifizierung (falls erforderlich)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"loras": {
|
"loras": {
|
||||||
@@ -291,8 +447,10 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Modelliste aktualisieren",
|
"title": "Modelliste aktualisieren",
|
||||||
"quick": "Schnelle Aktualisierung (inkrementell)",
|
"quick": "Änderungen synchronisieren",
|
||||||
"full": "Vollständiger Neuaufbau (komplett)"
|
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
|
||||||
|
"full": "Cache neu aufbauen",
|
||||||
|
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
|
||||||
},
|
},
|
||||||
"fetch": {
|
"fetch": {
|
||||||
"title": "Metadaten von Civitai abrufen",
|
"title": "Metadaten von Civitai abrufen",
|
||||||
@@ -313,6 +471,13 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "Nur Favoriten anzeigen",
|
"title": "Nur Favoriten anzeigen",
|
||||||
"action": "Favoriten"
|
"action": "Favoriten"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"title": "Nur Modelle mit verfügbaren Updates anzeigen",
|
||||||
|
"action": "Updates",
|
||||||
|
"menuLabel": "Weitere Update-Optionen anzeigen",
|
||||||
|
"check": "Updates prüfen",
|
||||||
|
"checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bulkOperations": {
|
"bulkOperations": {
|
||||||
@@ -321,8 +486,10 @@
|
|||||||
"viewSelected": "Auswahl anzeigen",
|
"viewSelected": "Auswahl anzeigen",
|
||||||
"addTags": "Allen Tags hinzufügen",
|
"addTags": "Allen Tags hinzufügen",
|
||||||
"setBaseModel": "Basis-Modell für alle festlegen",
|
"setBaseModel": "Basis-Modell für alle festlegen",
|
||||||
|
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
||||||
"copyAll": "Alle Syntax kopieren",
|
"copyAll": "Alle Syntax kopieren",
|
||||||
"refreshAll": "Alle Metadaten aktualisieren",
|
"refreshAll": "Alle Metadaten aktualisieren",
|
||||||
|
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||||
"moveAll": "Alle in Ordner verschieben",
|
"moveAll": "Alle in Ordner verschieben",
|
||||||
"autoOrganize": "Automatisch organisieren",
|
"autoOrganize": "Automatisch organisieren",
|
||||||
"deleteAll": "Alle Modelle löschen",
|
"deleteAll": "Alle Modelle löschen",
|
||||||
@@ -339,6 +506,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "Civitai-Daten aktualisieren",
|
"refreshMetadata": "Civitai-Daten aktualisieren",
|
||||||
|
"checkUpdates": "Updates prüfen",
|
||||||
"relinkCivitai": "Mit Civitai neu verknüpfen",
|
"relinkCivitai": "Mit Civitai neu verknüpfen",
|
||||||
"copySyntax": "LoRA-Syntax kopieren",
|
"copySyntax": "LoRA-Syntax kopieren",
|
||||||
"copyFilename": "Modell-Dateiname kopieren",
|
"copyFilename": "Modell-Dateiname kopieren",
|
||||||
@@ -360,6 +528,9 @@
|
|||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRA-Rezepte",
|
"title": "LoRA-Rezepte",
|
||||||
|
"actions": {
|
||||||
|
"sendCheckpoint": "Send to ComfyUI"
|
||||||
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "Importieren",
|
"action": "Importieren",
|
||||||
@@ -456,13 +627,19 @@
|
|||||||
"title": "Embedding-Modelle"
|
"title": "Embedding-Modelle"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Modell-Stammverzeichnis",
|
"modelRoot": "Stammverzeichnis",
|
||||||
"collapseAll": "Alle Ordner einklappen",
|
"collapseAll": "Alle Ordner einklappen",
|
||||||
"pinSidebar": "Sidebar anheften",
|
"pinSidebar": "Sidebar anheften",
|
||||||
"unpinSidebar": "Sidebar lösen",
|
"unpinSidebar": "Sidebar lösen",
|
||||||
"switchToListView": "Zur Listenansicht wechseln",
|
"switchToListView": "Zur Listenansicht wechseln",
|
||||||
"switchToTreeView": "Zur Baumansicht wechseln",
|
"switchToTreeView": "Zur Baumansicht wechseln",
|
||||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar"
|
"recursiveOn": "Unterordner durchsuchen",
|
||||||
|
"recursiveOff": "Nur aktuellen Ordner durchsuchen",
|
||||||
|
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
||||||
|
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
||||||
|
"dragDrop": {
|
||||||
|
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"title": "Statistiken",
|
"title": "Statistiken",
|
||||||
@@ -537,6 +714,14 @@
|
|||||||
"downloadedPreview": "Vorschaubild heruntergeladen",
|
"downloadedPreview": "Vorschaubild heruntergeladen",
|
||||||
"downloadingFile": "{type}-Datei wird heruntergeladen",
|
"downloadingFile": "{type}-Datei wird heruntergeladen",
|
||||||
"finalizing": "Download wird abgeschlossen..."
|
"finalizing": "Download wird abgeschlossen..."
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"currentFile": "Aktuelle Datei:",
|
||||||
|
"downloading": "Wird heruntergeladen: {name}",
|
||||||
|
"transferred": "Heruntergeladen: {downloaded} / {total}",
|
||||||
|
"transferredSimple": "Heruntergeladen: {downloaded}",
|
||||||
|
"transferredUnknown": "Heruntergeladen: --",
|
||||||
|
"speed": "Geschwindigkeit: {speed}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
@@ -545,6 +730,7 @@
|
|||||||
"contentRating": {
|
"contentRating": {
|
||||||
"title": "Inhaltsbewertung festlegen",
|
"title": "Inhaltsbewertung festlegen",
|
||||||
"current": "Aktuell",
|
"current": "Aktuell",
|
||||||
|
"multiple": "Mehrere Werte",
|
||||||
"levels": {
|
"levels": {
|
||||||
"pg": "PG",
|
"pg": "PG",
|
||||||
"pg13": "PG13",
|
"pg13": "PG13",
|
||||||
@@ -583,6 +769,12 @@
|
|||||||
"countMessage": "Modelle werden dauerhaft gelöscht.",
|
"countMessage": "Modelle werden dauerhaft gelöscht.",
|
||||||
"action": "Alle löschen"
|
"action": "Alle löschen"
|
||||||
},
|
},
|
||||||
|
"checkUpdates": {
|
||||||
|
"title": "Alle {typePlural} auf Updates prüfen?",
|
||||||
|
"message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.",
|
||||||
|
"tip": "Du möchtest in Etappen prüfen? Wechsle in den Sammelmodus, wähle die benötigten Modelle aus und nutze anschließend \"Auswahl auf Updates prüfen\".",
|
||||||
|
"action": "Alles prüfen"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "Tags zu mehreren Modellen hinzufügen",
|
"title": "Tags zu mehreren Modellen hinzufügen",
|
||||||
"description": "Tags hinzufügen zu",
|
"description": "Tags hinzufügen zu",
|
||||||
@@ -651,7 +843,12 @@
|
|||||||
"editBaseModel": "Basis-Modell bearbeiten",
|
"editBaseModel": "Basis-Modell bearbeiten",
|
||||||
"viewOnCivitai": "Auf Civitai anzeigen",
|
"viewOnCivitai": "Auf Civitai anzeigen",
|
||||||
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
||||||
"viewCreatorProfile": "Ersteller-Profil anzeigen"
|
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
||||||
|
"openFileLocation": "Dateispeicherort öffnen"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "Dateispeicherort erfolgreich geöffnet",
|
||||||
|
"failed": "Fehler beim Öffnen des Dateispeicherorts"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
@@ -675,6 +872,7 @@
|
|||||||
"strengthMin": "Stärke Min",
|
"strengthMin": "Stärke Min",
|
||||||
"strengthMax": "Stärke Max",
|
"strengthMax": "Stärke Max",
|
||||||
"strength": "Stärke",
|
"strength": "Stärke",
|
||||||
|
"clipStrength": "Clip-Stärke",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Wert",
|
"valuePlaceholder": "Wert",
|
||||||
"add": "Hinzufügen"
|
"add": "Hinzufügen"
|
||||||
@@ -713,13 +911,77 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"examples": "Beispiele",
|
"examples": "Beispiele",
|
||||||
"description": "Modellbeschreibung",
|
"description": "Modellbeschreibung",
|
||||||
"recipes": "Rezepte"
|
"recipes": "Rezepte",
|
||||||
|
"versions": "Versionen"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"noImageSell": "No selling generated content",
|
||||||
|
"noRentCivit": "No Civitai generation",
|
||||||
|
"noRent": "No generation services",
|
||||||
|
"noSell": "No selling models",
|
||||||
|
"creditRequired": "Ersteller-Angabe erforderlich",
|
||||||
|
"noDerivatives": "Keine gemeinsamen Zusammenführungen",
|
||||||
|
"noReLicense": "Gleiche Berechtigungen erforderlich",
|
||||||
|
"restrictionsLabel": "Lizenzbeschränkungen"
|
||||||
},
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"exampleImages": "Beispielbilder werden geladen...",
|
"exampleImages": "Beispielbilder werden geladen...",
|
||||||
"description": "Modellbeschreibung wird geladen...",
|
"description": "Modellbeschreibung wird geladen...",
|
||||||
"recipes": "Rezepte werden geladen...",
|
"recipes": "Rezepte werden geladen...",
|
||||||
"examples": "Beispiele werden geladen..."
|
"examples": "Beispiele werden geladen...",
|
||||||
|
"versions": "Versionen werden geladen..."
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"heading": "Modellversionen",
|
||||||
|
"copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.",
|
||||||
|
"media": {
|
||||||
|
"placeholder": "Keine Vorschau"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"unnamed": "Unbenannte Version",
|
||||||
|
"noDetails": "Keine zusätzlichen Details"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "Aktuelle Version",
|
||||||
|
"inLibrary": "In der Bibliothek",
|
||||||
|
"newer": "Neuere Version",
|
||||||
|
"ignored": "Ignoriert"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"download": "Herunterladen",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"ignore": "Ignorieren",
|
||||||
|
"unignore": "Ignorierung aufheben",
|
||||||
|
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
|
||||||
|
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
|
||||||
|
"viewLocalVersions": "Alle lokalen Versionen anzeigen",
|
||||||
|
"viewLocalTooltip": "Demnächst verfügbar"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"label": "Basisfilter",
|
||||||
|
"state": {
|
||||||
|
"showAll": "Alle Versionen",
|
||||||
|
"showSameBase": "Gleiches Basismodell"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"showAllVersions": "Wechseln, um alle Versionen anzuzeigen",
|
||||||
|
"showSameBaseVersions": "Wechseln, um nur Versionen mit demselben Basismodell anzuzeigen"
|
||||||
|
},
|
||||||
|
"empty": "Keine Versionen entsprechen dem Filter für das aktuelle Basismodell."
|
||||||
|
},
|
||||||
|
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
|
||||||
|
"error": "Versionen konnten nicht geladen werden.",
|
||||||
|
"missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.",
|
||||||
|
"confirm": {
|
||||||
|
"delete": "Diese Version aus Ihrer Bibliothek löschen?"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"modelIgnored": "Aktualisierungen für dieses Modell werden ignoriert",
|
||||||
|
"modelResumed": "Aktualisierungen für dieses Modell werden wieder geprüft",
|
||||||
|
"versionIgnored": "Aktualisierungen für diese Version werden ignoriert",
|
||||||
|
"versionUnignored": "Version wurde wieder aktiviert",
|
||||||
|
"versionDeleted": "Version gelöscht"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -826,7 +1088,9 @@
|
|||||||
"loraFailedToSend": "Fehler beim Senden der LoRA an den Workflow",
|
"loraFailedToSend": "Fehler beim Senden der LoRA an den Workflow",
|
||||||
"recipeAdded": "Rezept zum Workflow hinzugefügt",
|
"recipeAdded": "Rezept zum Workflow hinzugefügt",
|
||||||
"recipeReplaced": "Rezept im Workflow ersetzt",
|
"recipeReplaced": "Rezept im Workflow ersetzt",
|
||||||
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow"
|
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
|
||||||
|
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
||||||
|
"noTargetNodeSelected": "Kein Zielknoten ausgewählt"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Rezept",
|
"recipe": "Rezept",
|
||||||
@@ -871,6 +1135,11 @@
|
|||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "Nach Updates suchen",
|
"title": "Nach Updates suchen",
|
||||||
|
"notificationsTitle": "Benachrichtigungszentrum",
|
||||||
|
"tabs": {
|
||||||
|
"updates": "Aktualisierungen",
|
||||||
|
"messages": "Mitteilungen"
|
||||||
|
},
|
||||||
"updateAvailable": "Update verfügbar",
|
"updateAvailable": "Update verfügbar",
|
||||||
"noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.",
|
"noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.",
|
||||||
"currentVersion": "Aktuelle Version",
|
"currentVersion": "Aktuelle Version",
|
||||||
@@ -902,6 +1171,13 @@
|
|||||||
"nightly": {
|
"nightly": {
|
||||||
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
|
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
|
||||||
"enable": "Nightly Updates aktivieren"
|
"enable": "Nightly Updates aktivieren"
|
||||||
|
},
|
||||||
|
"banners": {
|
||||||
|
"recent": "Neueste Mitteilungen",
|
||||||
|
"empty": "Keine aktuellen Banner verfügbar.",
|
||||||
|
"shown": "{time} angezeigt",
|
||||||
|
"dismissed": "{time} geschlossen",
|
||||||
|
"active": "Aktiv"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
@@ -981,6 +1257,9 @@
|
|||||||
"cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID",
|
"cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID",
|
||||||
"sendFailed": "Fehler beim Senden des Rezepts an Workflow",
|
"sendFailed": "Fehler beim Senden des Rezepts an Workflow",
|
||||||
"sendError": "Fehler beim Senden des Rezepts an Workflow",
|
"sendError": "Fehler beim Senden des Rezepts an Workflow",
|
||||||
|
"missingCheckpointPath": "Checkpoint-Pfad nicht verfügbar",
|
||||||
|
"missingCheckpointInfo": "Checkpoint-Informationen fehlen",
|
||||||
|
"downloadCheckpointFailed": "Checkpoint-Download fehlgeschlagen: {message}",
|
||||||
"cannotDelete": "Kann Rezept nicht löschen: Fehlende Rezept-ID",
|
"cannotDelete": "Kann Rezept nicht löschen: Fehlende Rezept-ID",
|
||||||
"deleteConfirmationError": "Fehler beim Anzeigen der Löschbestätigung",
|
"deleteConfirmationError": "Fehler beim Anzeigen der Löschbestätigung",
|
||||||
"deletedSuccessfully": "Rezept erfolgreich gelöscht",
|
"deletedSuccessfully": "Rezept erfolgreich gelöscht",
|
||||||
@@ -1017,6 +1296,16 @@
|
|||||||
"bulkBaseModelUpdateSuccess": "Basis-Modell erfolgreich für {count} Modell(e) aktualisiert",
|
"bulkBaseModelUpdateSuccess": "Basis-Modell erfolgreich für {count} Modell(e) aktualisiert",
|
||||||
"bulkBaseModelUpdatePartial": "{success} Modelle aktualisiert, {failed} fehlgeschlagen",
|
"bulkBaseModelUpdatePartial": "{success} Modelle aktualisiert, {failed} fehlgeschlagen",
|
||||||
"bulkBaseModelUpdateFailed": "Aktualisierung des Basis-Modells für ausgewählte Modelle fehlgeschlagen",
|
"bulkBaseModelUpdateFailed": "Aktualisierung des Basis-Modells für ausgewählte Modelle fehlgeschlagen",
|
||||||
|
"bulkContentRatingUpdating": "Inhaltsbewertung wird für {count} Modell(e) aktualisiert...",
|
||||||
|
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
|
||||||
|
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
|
||||||
|
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
|
||||||
|
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
|
||||||
|
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
|
||||||
|
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
|
||||||
|
"bulkUpdatesMissing": "Ausgewählte {type}-Modelle sind nicht mit Civitai-Updates verknüpft",
|
||||||
|
"bulkUpdatesPartialMissing": "{missing} ausgewählte {type}-Modelle ohne Civitai-Verknüpfung übersprungen",
|
||||||
|
"bulkUpdatesFailed": "Updates für ausgewählte {type}-Modelle konnten nicht geprüft werden: {message}",
|
||||||
"invalidCharactersRemoved": "Ungültige Zeichen aus Dateiname entfernt",
|
"invalidCharactersRemoved": "Ungültige Zeichen aus Dateiname entfernt",
|
||||||
"filenameCannotBeEmpty": "Dateiname darf nicht leer sein",
|
"filenameCannotBeEmpty": "Dateiname darf nicht leer sein",
|
||||||
"renameFailed": "Fehler beim Umbenennen der Datei: {message}",
|
"renameFailed": "Fehler beim Umbenennen der Datei: {message}",
|
||||||
@@ -1051,6 +1340,8 @@
|
|||||||
"compactModeToggled": "Kompakt-Modus {state}",
|
"compactModeToggled": "Kompakt-Modus {state}",
|
||||||
"settingSaveFailed": "Fehler beim Speichern der Einstellung: {message}",
|
"settingSaveFailed": "Fehler beim Speichern der Einstellung: {message}",
|
||||||
"displayDensitySet": "Anzeige-Dichte auf {density} gesetzt",
|
"displayDensitySet": "Anzeige-Dichte auf {density} gesetzt",
|
||||||
|
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||||
|
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||||
"languageChangeFailed": "Fehler beim Ändern der Sprache: {message}",
|
"languageChangeFailed": "Fehler beim Ändern der Sprache: {message}",
|
||||||
"cacheCleared": "Cache-Dateien wurden erfolgreich gelöscht. Cache wird bei der nächsten Aktion neu aufgebaut.",
|
"cacheCleared": "Cache-Dateien wurden erfolgreich gelöscht. Cache wird bei der nächsten Aktion neu aufgebaut.",
|
||||||
"cacheClearFailed": "Fehler beim Löschen des Caches: {error}",
|
"cacheClearFailed": "Fehler beim Löschen des Caches: {error}",
|
||||||
@@ -1075,7 +1366,7 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "Konnte trainierte Wörter nicht laden",
|
"loadFailed": "Konnte trainierte Wörter nicht laden",
|
||||||
"tooLong": "Trigger Word sollte 30 Wörter nicht überschreiten",
|
"tooLong": "Trigger Word sollte 100 Wörter nicht überschreiten",
|
||||||
"tooMany": "Maximal 30 Trigger Words erlaubt",
|
"tooMany": "Maximal 30 Trigger Words erlaubt",
|
||||||
"alreadyExists": "Dieses Trigger Word existiert bereits",
|
"alreadyExists": "Dieses Trigger Word existiert bereits",
|
||||||
"updateSuccess": "Trigger Words erfolgreich aktualisiert",
|
"updateSuccess": "Trigger Words erfolgreich aktualisiert",
|
||||||
@@ -1115,6 +1406,7 @@
|
|||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"pathUpdated": "Beispielbilder-Pfad erfolgreich aktualisiert",
|
"pathUpdated": "Beispielbilder-Pfad erfolgreich aktualisiert",
|
||||||
|
"pathUpdateFailed": "Fehler beim Aktualisieren des Beispielbilder-Pfads: {message}",
|
||||||
"downloadInProgress": "Download bereits in Bearbeitung",
|
"downloadInProgress": "Download bereits in Bearbeitung",
|
||||||
"enterLocationFirst": "Bitte geben Sie zuerst einen Download-Speicherort ein",
|
"enterLocationFirst": "Bitte geben Sie zuerst einen Download-Speicherort ein",
|
||||||
"downloadStarted": "Beispielbilder-Download gestartet",
|
"downloadStarted": "Beispielbilder-Download gestartet",
|
||||||
@@ -1123,6 +1415,8 @@
|
|||||||
"pauseFailed": "Fehler beim Pausieren des Downloads: {error}",
|
"pauseFailed": "Fehler beim Pausieren des Downloads: {error}",
|
||||||
"downloadResumed": "Download fortgesetzt",
|
"downloadResumed": "Download fortgesetzt",
|
||||||
"resumeFailed": "Fehler beim Fortsetzen des Downloads: {error}",
|
"resumeFailed": "Fehler beim Fortsetzen des Downloads: {error}",
|
||||||
|
"downloadStopped": "Download abgebrochen",
|
||||||
|
"stopFailed": "Download konnte nicht abgebrochen werden: {error}",
|
||||||
"deleted": "Beispielbild gelöscht",
|
"deleted": "Beispielbild gelöscht",
|
||||||
"deleteFailed": "Fehler beim Löschen des Beispielbilds",
|
"deleteFailed": "Fehler beim Löschen des Beispielbilds",
|
||||||
"setPreviewFailed": "Fehler beim Setzen des Vorschaubilds"
|
"setPreviewFailed": "Fehler beim Setzen des Vorschaubilds"
|
||||||
@@ -1169,6 +1463,12 @@
|
|||||||
"refreshNow": "Jetzt aktualisieren",
|
"refreshNow": "Jetzt aktualisieren",
|
||||||
"refreshingIn": "Aktualisierung in",
|
"refreshingIn": "Aktualisierung in",
|
||||||
"seconds": "Sekunden"
|
"seconds": "Sekunden"
|
||||||
|
},
|
||||||
|
"communitySupport": {
|
||||||
|
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||||
|
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||||
|
"supportCta": "Support on Ko-fi",
|
||||||
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
358
locales/en.json
358
locales/en.json
@@ -16,7 +16,9 @@
|
|||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"version": "Version"
|
"version": "Version",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"select": "Language",
|
"select": "Language",
|
||||||
@@ -29,7 +31,8 @@
|
|||||||
"japanese": "日本語",
|
"japanese": "日本語",
|
||||||
"korean": "한국어",
|
"korean": "한국어",
|
||||||
"french": "Français",
|
"french": "Français",
|
||||||
"spanish": "Español"
|
"spanish": "Español",
|
||||||
|
"Hebrew": "עברית"
|
||||||
},
|
},
|
||||||
"fileSize": {
|
"fileSize": {
|
||||||
"zero": "0 Bytes",
|
"zero": "0 Bytes",
|
||||||
@@ -98,7 +101,12 @@
|
|||||||
"checkpointNameCopied": "Checkpoint name copied",
|
"checkpointNameCopied": "Checkpoint name copied",
|
||||||
"toggleBlur": "Toggle blur",
|
"toggleBlur": "Toggle blur",
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"openExampleImages": "Open Example Images Folder"
|
"openExampleImages": "Open Example Images Folder",
|
||||||
|
"replacePreview": "Replace Preview",
|
||||||
|
"copyCheckpointName": "Copy checkpoint name",
|
||||||
|
"copyEmbeddingName": "Copy embedding name",
|
||||||
|
"sendCheckpointToWorkflow": "Send to ComfyUI",
|
||||||
|
"sendEmbeddingToWorkflow": "Send to ComfyUI"
|
||||||
},
|
},
|
||||||
"nsfw": {
|
"nsfw": {
|
||||||
"matureContent": "Mature Content",
|
"matureContent": "Mature Content",
|
||||||
@@ -112,12 +120,45 @@
|
|||||||
"updateFailed": "Failed to update favorite status"
|
"updateFailed": "Failed to update favorite status"
|
||||||
},
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"checkpointNotImplemented": "Send checkpoint to workflow - feature to be implemented"
|
"checkpointNotImplemented": "Send checkpoint to workflow - feature to be implemented",
|
||||||
|
"missingPath": "Unable to determine model path for this card"
|
||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"checkError": "Error checking for example images",
|
"checkError": "Error checking for example images",
|
||||||
"missingHash": "Missing model hash information.",
|
"missingHash": "Missing model hash information.",
|
||||||
"noRemoteImagesAvailable": "No remote example images available for this model on Civitai"
|
"noRemoteImagesAvailable": "No remote example images available for this model on Civitai"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"update": "Update",
|
||||||
|
"updateAvailable": "Update available"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"globalContextMenu": {
|
||||||
|
"downloadExampleImages": {
|
||||||
|
"label": "Download example images",
|
||||||
|
"missingPath": "Set a download location before downloading example images.",
|
||||||
|
"unavailable": "Example image downloads aren't available yet. Try again after the page finishes loading."
|
||||||
|
},
|
||||||
|
"checkModelUpdates": {
|
||||||
|
"label": "Check for updates",
|
||||||
|
"loading": "Checking for {type} updates...",
|
||||||
|
"success": "Found {count} update(s) for {type}s",
|
||||||
|
"none": "All {type}s are up to date",
|
||||||
|
"error": "Failed to check for {type} updates: {message}"
|
||||||
|
},
|
||||||
|
"cleanupExampleImages": {
|
||||||
|
"label": "Clean up example image folders",
|
||||||
|
"success": "Moved {count} folder(s) to the deleted folder",
|
||||||
|
"none": "No example image folders needed cleanup",
|
||||||
|
"partial": "Cleanup completed with {failures} folder(s) skipped",
|
||||||
|
"error": "Failed to clean example image folders: {message}"
|
||||||
|
},
|
||||||
|
"fetchMissingLicenses": {
|
||||||
|
"label": "Refresh license metadata",
|
||||||
|
"loading": "Refreshing license metadata for {typePlural}...",
|
||||||
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
|
"none": "All {typePlural} already have license metadata",
|
||||||
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -154,6 +195,10 @@
|
|||||||
"title": "Filter Models",
|
"title": "Filter Models",
|
||||||
"baseModel": "Base Model",
|
"baseModel": "Base Model",
|
||||||
"modelTags": "Tags (Top 20)",
|
"modelTags": "Tags (Top 20)",
|
||||||
|
"modelTypes": "Model Types",
|
||||||
|
"license": "License",
|
||||||
|
"noCreditRequired": "No Credit Required",
|
||||||
|
"allowSellingGeneratedContent": "Allow Selling",
|
||||||
"clearAll": "Clear All Filters"
|
"clearAll": "Clear All Filters"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -164,6 +209,7 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"checkUpdates": "Check Updates",
|
"checkUpdates": "Check Updates",
|
||||||
|
"notifications": "Notifications",
|
||||||
"support": "Support"
|
"support": "Support"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -171,14 +217,30 @@
|
|||||||
"civitaiApiKey": "Civitai API Key",
|
"civitaiApiKey": "Civitai API Key",
|
||||||
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
||||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||||
|
"openSettingsFileLocation": {
|
||||||
|
"label": "Open settings folder",
|
||||||
|
"tooltip": "Open the folder containing settings.json",
|
||||||
|
"success": "Opened settings.json folder",
|
||||||
|
"failed": "Failed to open settings.json folder"
|
||||||
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "Content Filtering",
|
"contentFiltering": "Content Filtering",
|
||||||
"videoSettings": "Video Settings",
|
"videoSettings": "Video Settings",
|
||||||
"layoutSettings": "Layout Settings",
|
"layoutSettings": "Layout Settings",
|
||||||
"folderSettings": "Folder Settings",
|
"folderSettings": "Folder Settings",
|
||||||
|
"priorityTags": "Priority Tags",
|
||||||
"downloadPathTemplates": "Download Path Templates",
|
"downloadPathTemplates": "Download Path Templates",
|
||||||
"exampleImages": "Example Images",
|
"exampleImages": "Example Images",
|
||||||
"misc": "Misc."
|
"updateFlags": "Update Flags",
|
||||||
|
"autoOrganize": "Auto-organize",
|
||||||
|
"misc": "Misc.",
|
||||||
|
"metadataArchive": "Metadata Archive Database",
|
||||||
|
"storageLocation": "Settings Location",
|
||||||
|
"proxySettings": "Proxy Settings"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"locationLabel": "Portable mode",
|
||||||
|
"locationHelp": "Enable to keep settings.json inside the repository; disable to store it in your user config directory."
|
||||||
},
|
},
|
||||||
"contentFiltering": {
|
"contentFiltering": {
|
||||||
"blurNsfwContent": "Blur NSFW Content",
|
"blurNsfwContent": "Blur NSFW Content",
|
||||||
@@ -190,32 +252,55 @@
|
|||||||
"autoplayOnHover": "Autoplay Videos on Hover",
|
"autoplayOnHover": "Autoplay Videos on Hover",
|
||||||
"autoplayOnHoverHelp": "Only play video previews when hovering over them"
|
"autoplayOnHoverHelp": "Only play video previews when hovering over them"
|
||||||
},
|
},
|
||||||
|
"autoOrganizeExclusions": {
|
||||||
|
"label": "Auto-organize exclusions",
|
||||||
|
"placeholder": "Example: curated/*, */backups/*; *_temp.safetensors",
|
||||||
|
"help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.",
|
||||||
|
"validation": {
|
||||||
|
"noPatterns": "Enter at least one pattern separated by commas or semicolons.",
|
||||||
|
"saveFailed": "Unable to save exclusions: {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "Display Density",
|
"displayDensity": "Display Density",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
"medium": "Medium",
|
"medium": "Medium",
|
||||||
"compact": "Compact"
|
"compact": "Compact"
|
||||||
},
|
},
|
||||||
"displayDensityHelp": "Choose how many cards to display per row:",
|
"displayDensityHelp": "Choose how many cards to display per row:",
|
||||||
"displayDensityDetails": {
|
"displayDensityDetails": {
|
||||||
"default": "Default: 5 (1080p), 6 (2K), 8 (4K)",
|
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||||
"medium": "Medium: 6 (1080p), 7 (2K), 9 (4K)",
|
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||||
"compact": "Compact: 7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.",
|
"displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.",
|
||||||
|
"showFolderSidebar": "Show Folder Sidebar",
|
||||||
|
"showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.",
|
||||||
"cardInfoDisplay": "Card Info Display",
|
"cardInfoDisplay": "Card Info Display",
|
||||||
"cardInfoDisplayOptions": {
|
"cardInfoDisplayOptions": {
|
||||||
"always": "Always Visible",
|
"always": "Always Visible",
|
||||||
"hover": "Reveal on Hover"
|
"hover": "Reveal on Hover"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Choose when to display model information and action buttons:",
|
"cardInfoDisplayHelp": "Choose when to display model information and action buttons",
|
||||||
"cardInfoDisplayDetails": {
|
"modelCardFooterAction": "Model Card Button Action",
|
||||||
"always": "Always Visible: Headers and footers are always visible",
|
"modelCardFooterActionOptions": {
|
||||||
"hover": "Reveal on Hover: Headers and footers only appear when hovering over a card"
|
"exampleImages": "Open Example Images",
|
||||||
}
|
"replacePreview": "Replace Preview"
|
||||||
|
},
|
||||||
|
"modelCardFooterActionHelp": "Choose what the bottom-right card button does",
|
||||||
|
"modelNameDisplay": "Model Name Display",
|
||||||
|
"modelNameDisplayOptions": {
|
||||||
|
"modelName": "Model Name",
|
||||||
|
"fileName": "File Name"
|
||||||
|
},
|
||||||
|
"modelNameDisplayHelp": "Choose what to display in the model card footer"
|
||||||
},
|
},
|
||||||
"folderSettings": {
|
"folderSettings": {
|
||||||
|
"activeLibrary": "Active Library",
|
||||||
|
"activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.",
|
||||||
|
"loadingLibraries": "Loading libraries...",
|
||||||
|
"noLibraries": "No libraries configured",
|
||||||
"defaultLoraRoot": "Default LoRA Root",
|
"defaultLoraRoot": "Default LoRA Root",
|
||||||
"defaultLoraRootHelp": "Set the default LoRA root directory for downloads, imports and moves",
|
"defaultLoraRootHelp": "Set the default LoRA root directory for downloads, imports and moves",
|
||||||
"defaultCheckpointRoot": "Default Checkpoint Root",
|
"defaultCheckpointRoot": "Default Checkpoint Root",
|
||||||
@@ -224,6 +309,26 @@
|
|||||||
"defaultEmbeddingRootHelp": "Set the default embedding root directory for downloads, imports and moves",
|
"defaultEmbeddingRootHelp": "Set the default embedding root directory for downloads, imports and moves",
|
||||||
"noDefault": "No Default"
|
"noDefault": "No Default"
|
||||||
},
|
},
|
||||||
|
"priorityTags": {
|
||||||
|
"title": "Priority Tags",
|
||||||
|
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
|
||||||
|
"placeholder": "character, concept, style(toon|toon_style)",
|
||||||
|
"helpLinkLabel": "Open priority tags help",
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"embedding": "Embedding"
|
||||||
|
},
|
||||||
|
"saveSuccess": "Priority tags updated.",
|
||||||
|
"saveError": "Failed to update priority tags.",
|
||||||
|
"loadingSuggestions": "Loading suggestions...",
|
||||||
|
"validation": {
|
||||||
|
"missingClosingParen": "Entry {index} is missing a closing parenthesis.",
|
||||||
|
"missingCanonical": "Entry {index} must include a canonical tag name.",
|
||||||
|
"duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.",
|
||||||
|
"unknown": "Invalid priority tag configuration."
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadPathTemplates": {
|
"downloadPathTemplates": {
|
||||||
"title": "Download Path Templates",
|
"title": "Download Path Templates",
|
||||||
"help": "Configure folder structures for different model types when downloading from Civitai.",
|
"help": "Configure folder structures for different model types when downloading from Civitai.",
|
||||||
@@ -236,6 +341,7 @@
|
|||||||
"baseModelFirstTag": "Base Model + First Tag",
|
"baseModelFirstTag": "Base Model + First Tag",
|
||||||
"baseModelAuthor": "Base Model + Author",
|
"baseModelAuthor": "Base Model + Author",
|
||||||
"authorFirstTag": "Author + First Tag",
|
"authorFirstTag": "Author + First Tag",
|
||||||
|
"baseModelAuthorFirstTag": "Base Model + Author + First Tag",
|
||||||
"customTemplate": "Custom Template"
|
"customTemplate": "Custom Template"
|
||||||
},
|
},
|
||||||
"customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})",
|
"customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})",
|
||||||
@@ -270,9 +376,59 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"restartRequired": "Requires restart"
|
"restartRequired": "Requires restart"
|
||||||
},
|
},
|
||||||
|
"updateFlagStrategy": {
|
||||||
|
"label": "Update Flag Strategy",
|
||||||
|
"help": "Decide whether update badges should only appear when a new release shares the same base model as your local files or whenever any newer version exists for that model.",
|
||||||
|
"options": {
|
||||||
|
"sameBase": "Match updates by base model",
|
||||||
|
"any": "Flag any available update"
|
||||||
|
}
|
||||||
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
||||||
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard"
|
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard"
|
||||||
|
},
|
||||||
|
"metadataArchive": {
|
||||||
|
"enableArchiveDb": "Enable Metadata Archive Database",
|
||||||
|
"enableArchiveDbHelp": "Use a local database to access metadata for models that have been deleted from Civitai.",
|
||||||
|
"status": "Status",
|
||||||
|
"statusAvailable": "Available",
|
||||||
|
"statusUnavailable": "Not Available",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"management": "Database Management",
|
||||||
|
"managementHelp": "Download or remove the metadata archive database",
|
||||||
|
"downloadButton": "Download Database",
|
||||||
|
"downloadingButton": "Downloading...",
|
||||||
|
"downloadedButton": "Downloaded",
|
||||||
|
"removeButton": "Remove Database",
|
||||||
|
"removingButton": "Removing...",
|
||||||
|
"downloadSuccess": "Metadata archive database downloaded successfully",
|
||||||
|
"downloadError": "Failed to download metadata archive database",
|
||||||
|
"removeSuccess": "Metadata archive database removed successfully",
|
||||||
|
"removeError": "Failed to remove metadata archive database",
|
||||||
|
"removeConfirm": "Are you sure you want to remove the metadata archive database? This will delete the local database file and you'll need to download it again to use this feature.",
|
||||||
|
"preparing": "Preparing download...",
|
||||||
|
"connecting": "Connecting to download server...",
|
||||||
|
"completed": "Completed",
|
||||||
|
"downloadComplete": "Download completed successfully"
|
||||||
|
},
|
||||||
|
"proxySettings": {
|
||||||
|
"enableProxy": "Enable App-level Proxy",
|
||||||
|
"enableProxyHelp": "Enable custom proxy settings for this application, overriding system proxy settings",
|
||||||
|
"proxyType": "Proxy Type",
|
||||||
|
"proxyTypeHelp": "Select the type of proxy server (HTTP, HTTPS, SOCKS4, SOCKS5)",
|
||||||
|
"proxyHost": "Proxy Host",
|
||||||
|
"proxyHostPlaceholder": "proxy.example.com",
|
||||||
|
"proxyHostHelp": "The hostname or IP address of your proxy server",
|
||||||
|
"proxyPort": "Proxy Port",
|
||||||
|
"proxyPortPlaceholder": "8080",
|
||||||
|
"proxyPortHelp": "The port number of your proxy server",
|
||||||
|
"proxyUsername": "Username (Optional)",
|
||||||
|
"proxyUsernamePlaceholder": "username",
|
||||||
|
"proxyUsernameHelp": "Username for proxy authentication (if required)",
|
||||||
|
"proxyPassword": "Password (Optional)",
|
||||||
|
"proxyPasswordPlaceholder": "password",
|
||||||
|
"proxyPasswordHelp": "Password for proxy authentication (if required)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"loras": {
|
"loras": {
|
||||||
@@ -291,8 +447,10 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh model list",
|
"title": "Refresh model list",
|
||||||
"quick": "Quick Refresh (incremental)",
|
"quick": "Sync Changes",
|
||||||
"full": "Full Rebuild (complete)"
|
"quickTooltip": "Scan for new or missing model files so the list stays current.",
|
||||||
|
"full": "Rebuild Cache",
|
||||||
|
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
|
||||||
},
|
},
|
||||||
"fetch": {
|
"fetch": {
|
||||||
"title": "Fetch metadata from Civitai",
|
"title": "Fetch metadata from Civitai",
|
||||||
@@ -313,19 +471,28 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "Show Favorites Only",
|
"title": "Show Favorites Only",
|
||||||
"action": "Favorites"
|
"action": "Favorites"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"title": "Show models with updates available",
|
||||||
|
"action": "Updates",
|
||||||
|
"menuLabel": "Show update options",
|
||||||
|
"check": "Check updates",
|
||||||
|
"checkTooltip": "Checking updates may take a while."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bulkOperations": {
|
"bulkOperations": {
|
||||||
"selected": "{count} selected",
|
"selected": "{count} selected",
|
||||||
"selectedSuffix": "selected",
|
"selectedSuffix": "selected",
|
||||||
"viewSelected": "View Selected",
|
"viewSelected": "View Selected",
|
||||||
"addTags": "Add Tags to All",
|
"addTags": "Add Tags to Selected",
|
||||||
"setBaseModel": "Set Base Model for All",
|
"setBaseModel": "Set Base Model for Selected",
|
||||||
"copyAll": "Copy All Syntax",
|
"setContentRating": "Set Content Rating for Selected",
|
||||||
"refreshAll": "Refresh All Metadata",
|
"copyAll": "Copy Selected Syntax",
|
||||||
"moveAll": "Move All to Folder",
|
"refreshAll": "Refresh Selected Metadata",
|
||||||
|
"checkUpdates": "Check Updates for Selected",
|
||||||
|
"moveAll": "Move Selected to Folder",
|
||||||
"autoOrganize": "Auto-Organize Selected",
|
"autoOrganize": "Auto-Organize Selected",
|
||||||
"deleteAll": "Delete All Models",
|
"deleteAll": "Delete Selected Models",
|
||||||
"clear": "Clear Selection",
|
"clear": "Clear Selection",
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Initializing auto-organize...",
|
"initializing": "Initializing auto-organize...",
|
||||||
@@ -339,6 +506,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "Refresh Civitai Data",
|
"refreshMetadata": "Refresh Civitai Data",
|
||||||
|
"checkUpdates": "Check Updates",
|
||||||
"relinkCivitai": "Re-link to Civitai",
|
"relinkCivitai": "Re-link to Civitai",
|
||||||
"copySyntax": "Copy LoRA Syntax",
|
"copySyntax": "Copy LoRA Syntax",
|
||||||
"copyFilename": "Copy Model Filename",
|
"copyFilename": "Copy Model Filename",
|
||||||
@@ -360,6 +528,9 @@
|
|||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRA Recipes",
|
"title": "LoRA Recipes",
|
||||||
|
"actions": {
|
||||||
|
"sendCheckpoint": "Send to ComfyUI"
|
||||||
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "Import",
|
"action": "Import",
|
||||||
@@ -456,13 +627,19 @@
|
|||||||
"title": "Embedding Models"
|
"title": "Embedding Models"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Model Root",
|
"modelRoot": "Root",
|
||||||
"collapseAll": "Collapse All Folders",
|
"collapseAll": "Collapse All Folders",
|
||||||
"pinSidebar": "Pin Sidebar",
|
"pinSidebar": "Pin Sidebar",
|
||||||
"unpinSidebar": "Unpin Sidebar",
|
"unpinSidebar": "Unpin Sidebar",
|
||||||
"switchToListView": "Switch to List View",
|
"switchToListView": "Switch to List View",
|
||||||
"switchToTreeView": "Switch to Tree View",
|
"switchToTreeView": "Switch to Tree View",
|
||||||
"collapseAllDisabled": "Not available in list view"
|
"recursiveOn": "Search subfolders",
|
||||||
|
"recursiveOff": "Search current folder only",
|
||||||
|
"recursiveUnavailable": "Recursive search is available in tree view only",
|
||||||
|
"collapseAllDisabled": "Not available in list view",
|
||||||
|
"dragDrop": {
|
||||||
|
"unableToResolveRoot": "Unable to determine destination path for move."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"title": "Statistics",
|
"title": "Statistics",
|
||||||
@@ -537,6 +714,14 @@
|
|||||||
"downloadedPreview": "Downloaded preview image",
|
"downloadedPreview": "Downloaded preview image",
|
||||||
"downloadingFile": "Downloading {type} file",
|
"downloadingFile": "Downloading {type} file",
|
||||||
"finalizing": "Finalizing download..."
|
"finalizing": "Finalizing download..."
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"currentFile": "Current file:",
|
||||||
|
"downloading": "Downloading: {name}",
|
||||||
|
"transferred": "Transferred: {downloaded} / {total}",
|
||||||
|
"transferredSimple": "Transferred: {downloaded}",
|
||||||
|
"transferredUnknown": "Transferred: --",
|
||||||
|
"speed": "Speed: {speed}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
@@ -545,6 +730,7 @@
|
|||||||
"contentRating": {
|
"contentRating": {
|
||||||
"title": "Set Content Rating",
|
"title": "Set Content Rating",
|
||||||
"current": "Current",
|
"current": "Current",
|
||||||
|
"multiple": "Multiple values",
|
||||||
"levels": {
|
"levels": {
|
||||||
"pg": "PG",
|
"pg": "PG",
|
||||||
"pg13": "PG13",
|
"pg13": "PG13",
|
||||||
@@ -583,6 +769,12 @@
|
|||||||
"countMessage": "models will be permanently deleted.",
|
"countMessage": "models will be permanently deleted.",
|
||||||
"action": "Delete All"
|
"action": "Delete All"
|
||||||
},
|
},
|
||||||
|
"checkUpdates": {
|
||||||
|
"title": "Check updates for all {typePlural}?",
|
||||||
|
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
|
||||||
|
"tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".",
|
||||||
|
"action": "Check All"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "Add Tags to Multiple Models",
|
"title": "Add Tags to Multiple Models",
|
||||||
"description": "Add tags to",
|
"description": "Add tags to",
|
||||||
@@ -651,7 +843,12 @@
|
|||||||
"editBaseModel": "Edit base model",
|
"editBaseModel": "Edit base model",
|
||||||
"viewOnCivitai": "View on Civitai",
|
"viewOnCivitai": "View on Civitai",
|
||||||
"viewOnCivitaiText": "View on Civitai",
|
"viewOnCivitaiText": "View on Civitai",
|
||||||
"viewCreatorProfile": "View Creator Profile"
|
"viewCreatorProfile": "View Creator Profile",
|
||||||
|
"openFileLocation": "Open File Location"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "File location opened successfully",
|
||||||
|
"failed": "Failed to open file location"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
@@ -675,6 +872,7 @@
|
|||||||
"strengthMin": "Strength Min",
|
"strengthMin": "Strength Min",
|
||||||
"strengthMax": "Strength Max",
|
"strengthMax": "Strength Max",
|
||||||
"strength": "Strength",
|
"strength": "Strength",
|
||||||
|
"clipStrength": "Clip Strength",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Value",
|
"valuePlaceholder": "Value",
|
||||||
"add": "Add"
|
"add": "Add"
|
||||||
@@ -713,13 +911,77 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"examples": "Examples",
|
"examples": "Examples",
|
||||||
"description": "Model Description",
|
"description": "Model Description",
|
||||||
"recipes": "Recipes"
|
"recipes": "Recipes",
|
||||||
|
"versions": "Versions"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"noImageSell": "No selling generated content",
|
||||||
|
"noRentCivit": "No Civitai generation",
|
||||||
|
"noRent": "No generation services",
|
||||||
|
"noSell": "No selling models",
|
||||||
|
"creditRequired": "Creator credit required",
|
||||||
|
"noDerivatives": "No sharing merges",
|
||||||
|
"noReLicense": "Same permissions required",
|
||||||
|
"restrictionsLabel": "License restrictions"
|
||||||
},
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"exampleImages": "Loading example images...",
|
"exampleImages": "Loading example images...",
|
||||||
"description": "Loading model description...",
|
"description": "Loading model description...",
|
||||||
"recipes": "Loading recipes...",
|
"recipes": "Loading recipes...",
|
||||||
"examples": "Loading examples..."
|
"examples": "Loading examples...",
|
||||||
|
"versions": "Loading versions..."
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"heading": "Model versions",
|
||||||
|
"copy": "Track and manage every version of this model in one place.",
|
||||||
|
"media": {
|
||||||
|
"placeholder": "No preview"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"unnamed": "Untitled Version",
|
||||||
|
"noDetails": "No additional details"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "Current Version",
|
||||||
|
"inLibrary": "In Library",
|
||||||
|
"newer": "Newer Version",
|
||||||
|
"ignored": "Ignored"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"download": "Download",
|
||||||
|
"delete": "Delete",
|
||||||
|
"ignore": "Ignore",
|
||||||
|
"unignore": "Unignore",
|
||||||
|
"resumeModelUpdates": "Resume updates for this model",
|
||||||
|
"ignoreModelUpdates": "Ignore updates for this model",
|
||||||
|
"viewLocalVersions": "View all local versions",
|
||||||
|
"viewLocalTooltip": "Coming soon"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"label": "Base filter",
|
||||||
|
"state": {
|
||||||
|
"showAll": "All versions",
|
||||||
|
"showSameBase": "Same base"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"showAllVersions": "Switch to showing all versions",
|
||||||
|
"showSameBaseVersions": "Switch to showing only versions that match the current base model"
|
||||||
|
},
|
||||||
|
"empty": "No versions match the current base model filter."
|
||||||
|
},
|
||||||
|
"empty": "No version history available for this model yet.",
|
||||||
|
"error": "Failed to load versions.",
|
||||||
|
"missingModelId": "This model is missing a Civitai model id.",
|
||||||
|
"confirm": {
|
||||||
|
"delete": "Delete this version from your library?"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"modelIgnored": "Updates ignored for this model",
|
||||||
|
"modelResumed": "Update tracking resumed",
|
||||||
|
"versionIgnored": "Updates ignored for this version",
|
||||||
|
"versionUnignored": "Version re-enabled",
|
||||||
|
"versionDeleted": "Version deleted"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -826,7 +1088,9 @@
|
|||||||
"loraFailedToSend": "Failed to send LoRA to workflow",
|
"loraFailedToSend": "Failed to send LoRA to workflow",
|
||||||
"recipeAdded": "Recipe appended to workflow",
|
"recipeAdded": "Recipe appended to workflow",
|
||||||
"recipeReplaced": "Recipe replaced in workflow",
|
"recipeReplaced": "Recipe replaced in workflow",
|
||||||
"recipeFailedToSend": "Failed to send recipe to workflow"
|
"recipeFailedToSend": "Failed to send recipe to workflow",
|
||||||
|
"noMatchingNodes": "No compatible nodes available in the current workflow",
|
||||||
|
"noTargetNodeSelected": "No target node selected"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Recipe",
|
"recipe": "Recipe",
|
||||||
@@ -871,6 +1135,11 @@
|
|||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "Check for Updates",
|
"title": "Check for Updates",
|
||||||
|
"notificationsTitle": "Notifications",
|
||||||
|
"tabs": {
|
||||||
|
"updates": "Updates",
|
||||||
|
"messages": "Messages"
|
||||||
|
},
|
||||||
"updateAvailable": "Update Available",
|
"updateAvailable": "Update Available",
|
||||||
"noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.",
|
"noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.",
|
||||||
"currentVersion": "Current Version",
|
"currentVersion": "Current Version",
|
||||||
@@ -902,6 +1171,13 @@
|
|||||||
"nightly": {
|
"nightly": {
|
||||||
"warning": "Warning: Nightly builds may contain experimental features and could be unstable.",
|
"warning": "Warning: Nightly builds may contain experimental features and could be unstable.",
|
||||||
"enable": "Enable Nightly Updates"
|
"enable": "Enable Nightly Updates"
|
||||||
|
},
|
||||||
|
"banners": {
|
||||||
|
"recent": "Recent messages",
|
||||||
|
"empty": "No recent banners yet.",
|
||||||
|
"shown": "Shown {time}",
|
||||||
|
"dismissed": "Dismissed {time}",
|
||||||
|
"active": "Active"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
@@ -981,6 +1257,9 @@
|
|||||||
"cannotSend": "Cannot send recipe: Missing recipe ID",
|
"cannotSend": "Cannot send recipe: Missing recipe ID",
|
||||||
"sendFailed": "Failed to send recipe to workflow",
|
"sendFailed": "Failed to send recipe to workflow",
|
||||||
"sendError": "Error sending recipe to workflow",
|
"sendError": "Error sending recipe to workflow",
|
||||||
|
"missingCheckpointPath": "Checkpoint path not available",
|
||||||
|
"missingCheckpointInfo": "Missing checkpoint information",
|
||||||
|
"downloadCheckpointFailed": "Failed to download checkpoint: {message}",
|
||||||
"cannotDelete": "Cannot delete recipe: Missing recipe ID",
|
"cannotDelete": "Cannot delete recipe: Missing recipe ID",
|
||||||
"deleteConfirmationError": "Error showing delete confirmation",
|
"deleteConfirmationError": "Error showing delete confirmation",
|
||||||
"deletedSuccessfully": "Recipe deleted successfully",
|
"deletedSuccessfully": "Recipe deleted successfully",
|
||||||
@@ -1017,6 +1296,16 @@
|
|||||||
"bulkBaseModelUpdateSuccess": "Successfully updated base model for {count} model(s)",
|
"bulkBaseModelUpdateSuccess": "Successfully updated base model for {count} model(s)",
|
||||||
"bulkBaseModelUpdatePartial": "Updated {success} model(s), failed {failed} model(s)",
|
"bulkBaseModelUpdatePartial": "Updated {success} model(s), failed {failed} model(s)",
|
||||||
"bulkBaseModelUpdateFailed": "Failed to update base model for selected models",
|
"bulkBaseModelUpdateFailed": "Failed to update base model for selected models",
|
||||||
|
"bulkContentRatingUpdating": "Updating content rating for {count} model(s)...",
|
||||||
|
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
|
||||||
|
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
|
||||||
|
"bulkContentRatingFailed": "Failed to update content rating for selected models",
|
||||||
|
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
|
||||||
|
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
|
||||||
|
"bulkUpdatesNone": "No updates found for selected {type}(s)",
|
||||||
|
"bulkUpdatesMissing": "Selected {type}(s) are not linked to Civitai updates",
|
||||||
|
"bulkUpdatesPartialMissing": "Skipped {missing} selected {type}(s) without Civitai links",
|
||||||
|
"bulkUpdatesFailed": "Failed to check updates for selected {type}(s): {message}",
|
||||||
"invalidCharactersRemoved": "Invalid characters removed from filename",
|
"invalidCharactersRemoved": "Invalid characters removed from filename",
|
||||||
"filenameCannotBeEmpty": "File name cannot be empty",
|
"filenameCannotBeEmpty": "File name cannot be empty",
|
||||||
"renameFailed": "Failed to rename file: {message}",
|
"renameFailed": "Failed to rename file: {message}",
|
||||||
@@ -1051,6 +1340,8 @@
|
|||||||
"compactModeToggled": "Compact Mode {state}",
|
"compactModeToggled": "Compact Mode {state}",
|
||||||
"settingSaveFailed": "Failed to save setting: {message}",
|
"settingSaveFailed": "Failed to save setting: {message}",
|
||||||
"displayDensitySet": "Display Density set to {density}",
|
"displayDensitySet": "Display Density set to {density}",
|
||||||
|
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||||
|
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||||
"languageChangeFailed": "Failed to change language: {message}",
|
"languageChangeFailed": "Failed to change language: {message}",
|
||||||
"cacheCleared": "Cache files have been cleared successfully. Cache will rebuild on next action.",
|
"cacheCleared": "Cache files have been cleared successfully. Cache will rebuild on next action.",
|
||||||
"cacheClearFailed": "Failed to clear cache: {error}",
|
"cacheClearFailed": "Failed to clear cache: {error}",
|
||||||
@@ -1075,7 +1366,7 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "Could not load trained words",
|
"loadFailed": "Could not load trained words",
|
||||||
"tooLong": "Trigger word should not exceed 30 words",
|
"tooLong": "Trigger word should not exceed 100 words",
|
||||||
"tooMany": "Maximum 30 trigger words allowed",
|
"tooMany": "Maximum 30 trigger words allowed",
|
||||||
"alreadyExists": "This trigger word already exists",
|
"alreadyExists": "This trigger word already exists",
|
||||||
"updateSuccess": "Trigger words updated successfully",
|
"updateSuccess": "Trigger words updated successfully",
|
||||||
@@ -1115,6 +1406,7 @@
|
|||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"pathUpdated": "Example images path updated successfully",
|
"pathUpdated": "Example images path updated successfully",
|
||||||
|
"pathUpdateFailed": "Failed to update example images path: {message}",
|
||||||
"downloadInProgress": "Download already in progress",
|
"downloadInProgress": "Download already in progress",
|
||||||
"enterLocationFirst": "Please enter a download location first",
|
"enterLocationFirst": "Please enter a download location first",
|
||||||
"downloadStarted": "Example images download started",
|
"downloadStarted": "Example images download started",
|
||||||
@@ -1123,6 +1415,8 @@
|
|||||||
"pauseFailed": "Failed to pause download: {error}",
|
"pauseFailed": "Failed to pause download: {error}",
|
||||||
"downloadResumed": "Download resumed",
|
"downloadResumed": "Download resumed",
|
||||||
"resumeFailed": "Failed to resume download: {error}",
|
"resumeFailed": "Failed to resume download: {error}",
|
||||||
|
"downloadStopped": "Download cancelled",
|
||||||
|
"stopFailed": "Failed to cancel download: {error}",
|
||||||
"deleted": "Example image deleted",
|
"deleted": "Example image deleted",
|
||||||
"deleteFailed": "Failed to delete example image",
|
"deleteFailed": "Failed to delete example image",
|
||||||
"setPreviewFailed": "Failed to set preview image"
|
"setPreviewFailed": "Failed to set preview image"
|
||||||
@@ -1169,6 +1463,12 @@
|
|||||||
"refreshNow": "Refresh Now",
|
"refreshNow": "Refresh Now",
|
||||||
"refreshingIn": "Refreshing in",
|
"refreshingIn": "Refreshing in",
|
||||||
"seconds": "seconds"
|
"seconds": "seconds"
|
||||||
|
},
|
||||||
|
"communitySupport": {
|
||||||
|
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||||
|
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||||
|
"supportCta": "Support on Ko-fi",
|
||||||
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
344
locales/es.json
344
locales/es.json
@@ -16,7 +16,9 @@
|
|||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
"unknown": "Desconocido",
|
"unknown": "Desconocido",
|
||||||
"date": "Fecha",
|
"date": "Fecha",
|
||||||
"version": "Versión"
|
"version": "Versión",
|
||||||
|
"enabled": "Habilitado",
|
||||||
|
"disabled": "Deshabilitado"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"select": "Idioma",
|
"select": "Idioma",
|
||||||
@@ -29,7 +31,8 @@
|
|||||||
"japanese": "日本語",
|
"japanese": "日本語",
|
||||||
"korean": "한국어",
|
"korean": "한국어",
|
||||||
"french": "Français",
|
"french": "Français",
|
||||||
"spanish": "Español"
|
"spanish": "Español",
|
||||||
|
"Hebrew": "עברית"
|
||||||
},
|
},
|
||||||
"fileSize": {
|
"fileSize": {
|
||||||
"zero": "0 Bytes",
|
"zero": "0 Bytes",
|
||||||
@@ -98,7 +101,12 @@
|
|||||||
"checkpointNameCopied": "Nombre del checkpoint copiado",
|
"checkpointNameCopied": "Nombre del checkpoint copiado",
|
||||||
"toggleBlur": "Alternar difuminado",
|
"toggleBlur": "Alternar difuminado",
|
||||||
"show": "Mostrar",
|
"show": "Mostrar",
|
||||||
"openExampleImages": "Abrir carpeta de imágenes de ejemplo"
|
"openExampleImages": "Abrir carpeta de imágenes de ejemplo",
|
||||||
|
"replacePreview": "Reemplazar vista previa",
|
||||||
|
"copyCheckpointName": "Copiar nombre del checkpoint",
|
||||||
|
"copyEmbeddingName": "Copiar nombre del embedding",
|
||||||
|
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
||||||
|
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
||||||
},
|
},
|
||||||
"nsfw": {
|
"nsfw": {
|
||||||
"matureContent": "Contenido para adultos",
|
"matureContent": "Contenido para adultos",
|
||||||
@@ -112,12 +120,45 @@
|
|||||||
"updateFailed": "Error al actualizar estado de favoritos"
|
"updateFailed": "Error al actualizar estado de favoritos"
|
||||||
},
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"checkpointNotImplemented": "Enviar checkpoint al flujo de trabajo - función por implementar"
|
"checkpointNotImplemented": "Enviar checkpoint al flujo de trabajo - función por implementar",
|
||||||
|
"missingPath": "No se puede determinar la ruta del modelo para esta tarjeta"
|
||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"checkError": "Error al verificar imágenes de ejemplo",
|
"checkError": "Error al verificar imágenes de ejemplo",
|
||||||
"missingHash": "Falta información del hash del modelo.",
|
"missingHash": "Falta información del hash del modelo.",
|
||||||
"noRemoteImagesAvailable": "No hay imágenes de ejemplo remotas disponibles para este modelo en Civitai"
|
"noRemoteImagesAvailable": "No hay imágenes de ejemplo remotas disponibles para este modelo en Civitai"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"update": "Actualización",
|
||||||
|
"updateAvailable": "Actualización disponible"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"globalContextMenu": {
|
||||||
|
"downloadExampleImages": {
|
||||||
|
"label": "Descargar imágenes de ejemplo",
|
||||||
|
"missingPath": "Establece una ubicación de descarga antes de descargar imágenes de ejemplo.",
|
||||||
|
"unavailable": "Las descargas de imágenes de ejemplo aún no están disponibles. Intenta de nuevo después de que la página termine de cargar."
|
||||||
|
},
|
||||||
|
"checkModelUpdates": {
|
||||||
|
"label": "Buscar actualizaciones",
|
||||||
|
"loading": "Buscando actualizaciones de {type}...",
|
||||||
|
"success": "Se encontraron {count} actualización(es) para {type}",
|
||||||
|
"none": "Todos los {type} están actualizados",
|
||||||
|
"error": "Error al buscar actualizaciones de {type}: {message}"
|
||||||
|
},
|
||||||
|
"cleanupExampleImages": {
|
||||||
|
"label": "Limpiar carpetas de imágenes de ejemplo",
|
||||||
|
"success": "Se movieron {count} carpeta(s) a la carpeta de eliminados",
|
||||||
|
"none": "No hay carpetas de imágenes de ejemplo que necesiten limpieza",
|
||||||
|
"partial": "Limpieza completada con {failures} carpeta(s) omitidas",
|
||||||
|
"error": "No se pudieron limpiar las carpetas de imágenes de ejemplo: {message}"
|
||||||
|
},
|
||||||
|
"fetchMissingLicenses": {
|
||||||
|
"label": "Refresh license metadata",
|
||||||
|
"loading": "Refreshing license metadata for {typePlural}...",
|
||||||
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
|
"none": "All {typePlural} already have license metadata",
|
||||||
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -154,6 +195,10 @@
|
|||||||
"title": "Filtrar modelos",
|
"title": "Filtrar modelos",
|
||||||
"baseModel": "Modelo base",
|
"baseModel": "Modelo base",
|
||||||
"modelTags": "Etiquetas (Top 20)",
|
"modelTags": "Etiquetas (Top 20)",
|
||||||
|
"modelTypes": "Model Types",
|
||||||
|
"license": "Licencia",
|
||||||
|
"noCreditRequired": "Sin crédito requerido",
|
||||||
|
"allowSellingGeneratedContent": "Venta permitida",
|
||||||
"clearAll": "Limpiar todos los filtros"
|
"clearAll": "Limpiar todos los filtros"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -164,6 +209,7 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"checkUpdates": "Comprobar actualizaciones",
|
"checkUpdates": "Comprobar actualizaciones",
|
||||||
|
"notifications": "Notificaciones",
|
||||||
"support": "Soporte"
|
"support": "Soporte"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -171,14 +217,30 @@
|
|||||||
"civitaiApiKey": "Clave API de Civitai",
|
"civitaiApiKey": "Clave API de Civitai",
|
||||||
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
||||||
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
||||||
|
"openSettingsFileLocation": {
|
||||||
|
"label": "Abrir carpeta de ajustes",
|
||||||
|
"tooltip": "Abrir la carpeta que contiene settings.json",
|
||||||
|
"success": "Carpeta de settings.json abierta",
|
||||||
|
"failed": "No se pudo abrir la carpeta de settings.json"
|
||||||
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "Filtrado de contenido",
|
"contentFiltering": "Filtrado de contenido",
|
||||||
"videoSettings": "Configuración de video",
|
"videoSettings": "Configuración de video",
|
||||||
"layoutSettings": "Configuración de diseño",
|
"layoutSettings": "Configuración de diseño",
|
||||||
"folderSettings": "Configuración de carpetas",
|
"folderSettings": "Configuración de carpetas",
|
||||||
|
"priorityTags": "Etiquetas prioritarias",
|
||||||
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
||||||
"exampleImages": "Imágenes de ejemplo",
|
"exampleImages": "Imágenes de ejemplo",
|
||||||
"misc": "Varios"
|
"updateFlags": "Indicadores de actualización",
|
||||||
|
"autoOrganize": "Auto-organize",
|
||||||
|
"misc": "Varios",
|
||||||
|
"metadataArchive": "Base de datos de archivo de metadatos",
|
||||||
|
"storageLocation": "Ubicación de ajustes",
|
||||||
|
"proxySettings": "Configuración de proxy"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"locationLabel": "Modo portátil",
|
||||||
|
"locationHelp": "Activa para mantener settings.json dentro del repositorio; desactívalo para guardarlo en tu directorio de configuración de usuario."
|
||||||
},
|
},
|
||||||
"contentFiltering": {
|
"contentFiltering": {
|
||||||
"blurNsfwContent": "Difuminar contenido NSFW",
|
"blurNsfwContent": "Difuminar contenido NSFW",
|
||||||
@@ -190,6 +252,15 @@
|
|||||||
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
|
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
|
||||||
"autoplayOnHoverHelp": "Solo reproducir vistas previas de video al pasar el ratón sobre ellas"
|
"autoplayOnHoverHelp": "Solo reproducir vistas previas de video al pasar el ratón sobre ellas"
|
||||||
},
|
},
|
||||||
|
"autoOrganizeExclusions": {
|
||||||
|
"label": "Exclusiones de auto-organización",
|
||||||
|
"placeholder": "Ejemplo: curated/*, */backups/*; *_temp.safetensors",
|
||||||
|
"help": "Omitir archivos que coincidan con estos patrones comodín. Separe múltiples patrones con comas o puntos y comas.",
|
||||||
|
"validation": {
|
||||||
|
"noPatterns": "Ingrese al menos un patrón separado por comas o puntos y comas.",
|
||||||
|
"saveFailed": "No se pudieron guardar las exclusiones: {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "Densidad de visualización",
|
"displayDensity": "Densidad de visualización",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -199,23 +270,37 @@
|
|||||||
},
|
},
|
||||||
"displayDensityHelp": "Elige cuántas tarjetas mostrar por fila:",
|
"displayDensityHelp": "Elige cuántas tarjetas mostrar por fila:",
|
||||||
"displayDensityDetails": {
|
"displayDensityDetails": {
|
||||||
"default": "Predeterminado: 5 (1080p), 6 (2K), 8 (4K)",
|
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||||
"medium": "Medio: 6 (1080p), 7 (2K), 9 (4K)",
|
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||||
"compact": "Compacto: 7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.",
|
"displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.",
|
||||||
|
"showFolderSidebar": "Mostrar barra lateral de carpetas",
|
||||||
|
"showFolderSidebarHelp": "Activa o desactiva la barra lateral de navegación de carpetas en las páginas de modelos. Cuando está desactivada, la barra lateral y el área de desplazamiento permanecen ocultas.",
|
||||||
"cardInfoDisplay": "Visualización de información de tarjeta",
|
"cardInfoDisplay": "Visualización de información de tarjeta",
|
||||||
"cardInfoDisplayOptions": {
|
"cardInfoDisplayOptions": {
|
||||||
"always": "Siempre visible",
|
"always": "Siempre visible",
|
||||||
"hover": "Mostrar al pasar el ratón"
|
"hover": "Mostrar al pasar el ratón"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción:",
|
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
|
||||||
"cardInfoDisplayDetails": {
|
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
|
||||||
"always": "Siempre visible: Los encabezados y pies de página siempre son visibles",
|
"modelCardFooterActionOptions": {
|
||||||
"hover": "Mostrar al pasar el ratón: Los encabezados y pies de página solo aparecen al pasar el ratón sobre una tarjeta"
|
"exampleImages": "Abrir imágenes de ejemplo",
|
||||||
}
|
"replacePreview": "Reemplazar vista previa"
|
||||||
|
},
|
||||||
|
"modelCardFooterActionHelp": "Elige qué hace el botón en la esquina inferior derecha de la tarjeta",
|
||||||
|
"modelNameDisplay": "Visualización del nombre del modelo",
|
||||||
|
"modelNameDisplayOptions": {
|
||||||
|
"modelName": "Nombre del modelo",
|
||||||
|
"fileName": "Nombre del archivo"
|
||||||
|
},
|
||||||
|
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo"
|
||||||
},
|
},
|
||||||
"folderSettings": {
|
"folderSettings": {
|
||||||
|
"activeLibrary": "Biblioteca activa",
|
||||||
|
"activeLibraryHelp": "Alterna entre las bibliotecas configuradas para actualizar las carpetas predeterminadas. Cambiar la selección recarga la página.",
|
||||||
|
"loadingLibraries": "Cargando bibliotecas...",
|
||||||
|
"noLibraries": "No hay bibliotecas configuradas",
|
||||||
"defaultLoraRoot": "Raíz predeterminada de LoRA",
|
"defaultLoraRoot": "Raíz predeterminada de LoRA",
|
||||||
"defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos",
|
"defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos",
|
||||||
"defaultCheckpointRoot": "Raíz predeterminada de checkpoint",
|
"defaultCheckpointRoot": "Raíz predeterminada de checkpoint",
|
||||||
@@ -224,6 +309,26 @@
|
|||||||
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
||||||
"noDefault": "Sin predeterminado"
|
"noDefault": "Sin predeterminado"
|
||||||
},
|
},
|
||||||
|
"priorityTags": {
|
||||||
|
"title": "Etiquetas prioritarias",
|
||||||
|
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
|
||||||
|
"placeholder": "character, concept, style(toon|toon_style)",
|
||||||
|
"helpLinkLabel": "Abrir ayuda de etiquetas prioritarias",
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"embedding": "Embedding"
|
||||||
|
},
|
||||||
|
"saveSuccess": "Etiquetas prioritarias actualizadas.",
|
||||||
|
"saveError": "Error al actualizar las etiquetas prioritarias.",
|
||||||
|
"loadingSuggestions": "Cargando sugerencias...",
|
||||||
|
"validation": {
|
||||||
|
"missingClosingParen": "A la entrada {index} le falta un paréntesis de cierre.",
|
||||||
|
"missingCanonical": "La entrada {index} debe incluir un nombre de etiqueta canónica.",
|
||||||
|
"duplicateCanonical": "La etiqueta canónica \"{tag}\" aparece más de una vez.",
|
||||||
|
"unknown": "Configuración de etiquetas prioritarias no válida."
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadPathTemplates": {
|
"downloadPathTemplates": {
|
||||||
"title": "Plantillas de rutas de descarga",
|
"title": "Plantillas de rutas de descarga",
|
||||||
"help": "Configurar estructuras de carpetas para diferentes tipos de modelos al descargar de Civitai.",
|
"help": "Configurar estructuras de carpetas para diferentes tipos de modelos al descargar de Civitai.",
|
||||||
@@ -236,6 +341,7 @@
|
|||||||
"baseModelFirstTag": "Modelo base + primera etiqueta",
|
"baseModelFirstTag": "Modelo base + primera etiqueta",
|
||||||
"baseModelAuthor": "Modelo base + autor",
|
"baseModelAuthor": "Modelo base + autor",
|
||||||
"authorFirstTag": "Autor + primera etiqueta",
|
"authorFirstTag": "Autor + primera etiqueta",
|
||||||
|
"baseModelAuthorFirstTag": "Modelo base + autor + primera etiqueta",
|
||||||
"customTemplate": "Plantilla personalizada"
|
"customTemplate": "Plantilla personalizada"
|
||||||
},
|
},
|
||||||
"customTemplatePlaceholder": "Introduce plantilla personalizada (ej., {base_model}/{author}/{first_tag})",
|
"customTemplatePlaceholder": "Introduce plantilla personalizada (ej., {base_model}/{author}/{first_tag})",
|
||||||
@@ -270,9 +376,59 @@
|
|||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
"restartRequired": "Requiere reinicio"
|
"restartRequired": "Requiere reinicio"
|
||||||
},
|
},
|
||||||
|
"updateFlagStrategy": {
|
||||||
|
"label": "Estrategia de indicadores de actualización",
|
||||||
|
"help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.",
|
||||||
|
"options": {
|
||||||
|
"sameBase": "Coincidir actualizaciones por modelo base",
|
||||||
|
"any": "Marcar cualquier actualización disponible"
|
||||||
|
}
|
||||||
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
|
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
|
||||||
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles"
|
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles"
|
||||||
|
},
|
||||||
|
"metadataArchive": {
|
||||||
|
"enableArchiveDb": "Habilitar base de datos de archivo de metadatos",
|
||||||
|
"enableArchiveDbHelp": "Utiliza una base de datos local para acceder a metadatos de modelos que han sido eliminados de Civitai.",
|
||||||
|
"status": "Estado",
|
||||||
|
"statusAvailable": "Disponible",
|
||||||
|
"statusUnavailable": "No disponible",
|
||||||
|
"enabled": "Habilitado",
|
||||||
|
"management": "Gestión de base de datos",
|
||||||
|
"managementHelp": "Descargar o eliminar la base de datos de archivo de metadatos",
|
||||||
|
"downloadButton": "Descargar base de datos",
|
||||||
|
"downloadingButton": "Descargando...",
|
||||||
|
"downloadedButton": "Descargado",
|
||||||
|
"removeButton": "Eliminar base de datos",
|
||||||
|
"removingButton": "Eliminando...",
|
||||||
|
"downloadSuccess": "Base de datos de archivo de metadatos descargada exitosamente",
|
||||||
|
"downloadError": "Error al descargar la base de datos de archivo de metadatos",
|
||||||
|
"removeSuccess": "Base de datos de archivo de metadatos eliminada exitosamente",
|
||||||
|
"removeError": "Error al eliminar la base de datos de archivo de metadatos",
|
||||||
|
"removeConfirm": "¿Estás seguro de que quieres eliminar la base de datos de archivo de metadatos? Esto eliminará el archivo de base de datos local y tendrás que descargarlo de nuevo para usar esta función.",
|
||||||
|
"preparing": "Preparando descarga...",
|
||||||
|
"connecting": "Conectando al servidor de descarga...",
|
||||||
|
"completed": "Completado",
|
||||||
|
"downloadComplete": "Descarga completada exitosamente"
|
||||||
|
},
|
||||||
|
"proxySettings": {
|
||||||
|
"enableProxy": "Habilitar proxy a nivel de aplicación",
|
||||||
|
"enableProxyHelp": "Habilita la configuración de proxy personalizada para esta aplicación, sobrescribiendo la configuración de proxy del sistema",
|
||||||
|
"proxyType": "Tipo de proxy",
|
||||||
|
"proxyTypeHelp": "Selecciona el tipo de servidor proxy (HTTP, HTTPS, SOCKS4, SOCKS5)",
|
||||||
|
"proxyHost": "Host del proxy",
|
||||||
|
"proxyHostPlaceholder": "proxy.ejemplo.com",
|
||||||
|
"proxyHostHelp": "El nombre de host o dirección IP de tu servidor proxy",
|
||||||
|
"proxyPort": "Puerto del proxy",
|
||||||
|
"proxyPortPlaceholder": "8080",
|
||||||
|
"proxyPortHelp": "El número de puerto de tu servidor proxy",
|
||||||
|
"proxyUsername": "Usuario (opcional)",
|
||||||
|
"proxyUsernamePlaceholder": "usuario",
|
||||||
|
"proxyUsernameHelp": "Usuario para autenticación de proxy (si es necesario)",
|
||||||
|
"proxyPassword": "Contraseña (opcional)",
|
||||||
|
"proxyPasswordPlaceholder": "contraseña",
|
||||||
|
"proxyPasswordHelp": "Contraseña para autenticación de proxy (si es necesario)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"loras": {
|
"loras": {
|
||||||
@@ -291,8 +447,10 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de modelos",
|
"title": "Actualizar lista de modelos",
|
||||||
"quick": "Actualización rápida (incremental)",
|
"quick": "Sincronizar cambios",
|
||||||
"full": "Reconstrucción completa"
|
"quickTooltip": "Busca archivos de modelo nuevos o faltantes para mantener la lista al día.",
|
||||||
|
"full": "Reconstruir caché",
|
||||||
|
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
|
||||||
},
|
},
|
||||||
"fetch": {
|
"fetch": {
|
||||||
"title": "Obtener metadatos de Civitai",
|
"title": "Obtener metadatos de Civitai",
|
||||||
@@ -313,6 +471,13 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "Mostrar solo favoritos",
|
"title": "Mostrar solo favoritos",
|
||||||
"action": "Favoritos"
|
"action": "Favoritos"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"title": "Mostrar solo modelos con actualizaciones disponibles",
|
||||||
|
"action": "Actualizaciones",
|
||||||
|
"menuLabel": "Mostrar opciones de actualización",
|
||||||
|
"check": "Buscar actualizaciones",
|
||||||
|
"checkTooltip": "Comprobar actualizaciones puede tardar."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bulkOperations": {
|
"bulkOperations": {
|
||||||
@@ -321,8 +486,10 @@
|
|||||||
"viewSelected": "Ver seleccionados",
|
"viewSelected": "Ver seleccionados",
|
||||||
"addTags": "Añadir etiquetas a todos",
|
"addTags": "Añadir etiquetas a todos",
|
||||||
"setBaseModel": "Establecer modelo base para todos",
|
"setBaseModel": "Establecer modelo base para todos",
|
||||||
|
"setContentRating": "Establecer clasificación de contenido para todos",
|
||||||
"copyAll": "Copiar toda la sintaxis",
|
"copyAll": "Copiar toda la sintaxis",
|
||||||
"refreshAll": "Actualizar todos los metadatos",
|
"refreshAll": "Actualizar todos los metadatos",
|
||||||
|
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||||
"moveAll": "Mover todos a carpeta",
|
"moveAll": "Mover todos a carpeta",
|
||||||
"autoOrganize": "Auto-organizar seleccionados",
|
"autoOrganize": "Auto-organizar seleccionados",
|
||||||
"deleteAll": "Eliminar todos los modelos",
|
"deleteAll": "Eliminar todos los modelos",
|
||||||
@@ -339,6 +506,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "Actualizar datos de Civitai",
|
"refreshMetadata": "Actualizar datos de Civitai",
|
||||||
|
"checkUpdates": "Comprobar actualizaciones",
|
||||||
"relinkCivitai": "Re-vincular a Civitai",
|
"relinkCivitai": "Re-vincular a Civitai",
|
||||||
"copySyntax": "Copiar sintaxis de LoRA",
|
"copySyntax": "Copiar sintaxis de LoRA",
|
||||||
"copyFilename": "Copiar nombre de archivo del modelo",
|
"copyFilename": "Copiar nombre de archivo del modelo",
|
||||||
@@ -360,6 +528,9 @@
|
|||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Recetas de LoRA",
|
"title": "Recetas de LoRA",
|
||||||
|
"actions": {
|
||||||
|
"sendCheckpoint": "Enviar a ComfyUI"
|
||||||
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "Importar",
|
"action": "Importar",
|
||||||
@@ -456,13 +627,19 @@
|
|||||||
"title": "Modelos embedding"
|
"title": "Modelos embedding"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Raíz del modelo",
|
"modelRoot": "Raíz",
|
||||||
"collapseAll": "Colapsar todas las carpetas",
|
"collapseAll": "Colapsar todas las carpetas",
|
||||||
"pinSidebar": "Fijar barra lateral",
|
"pinSidebar": "Fijar barra lateral",
|
||||||
"unpinSidebar": "Desfijar barra lateral",
|
"unpinSidebar": "Desfijar barra lateral",
|
||||||
"switchToListView": "Cambiar a vista de lista",
|
"switchToListView": "Cambiar a vista de lista",
|
||||||
"switchToTreeView": "Cambiar a vista de árbol",
|
"switchToTreeView": "Cambiar a vista de árbol",
|
||||||
"collapseAllDisabled": "No disponible en vista de lista"
|
"recursiveOn": "Buscar en subcarpetas",
|
||||||
|
"recursiveOff": "Buscar solo en la carpeta actual",
|
||||||
|
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
||||||
|
"collapseAllDisabled": "No disponible en vista de lista",
|
||||||
|
"dragDrop": {
|
||||||
|
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"title": "Estadísticas",
|
"title": "Estadísticas",
|
||||||
@@ -537,6 +714,14 @@
|
|||||||
"downloadedPreview": "Imagen de vista previa descargada",
|
"downloadedPreview": "Imagen de vista previa descargada",
|
||||||
"downloadingFile": "Descargando archivo de {type}",
|
"downloadingFile": "Descargando archivo de {type}",
|
||||||
"finalizing": "Finalizando descarga..."
|
"finalizing": "Finalizando descarga..."
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"currentFile": "Archivo actual:",
|
||||||
|
"downloading": "Descargando: {name}",
|
||||||
|
"transferred": "Descargado: {downloaded} / {total}",
|
||||||
|
"transferredSimple": "Descargado: {downloaded}",
|
||||||
|
"transferredUnknown": "Descargado: --",
|
||||||
|
"speed": "Velocidad: {speed}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
@@ -545,6 +730,7 @@
|
|||||||
"contentRating": {
|
"contentRating": {
|
||||||
"title": "Establecer clasificación de contenido",
|
"title": "Establecer clasificación de contenido",
|
||||||
"current": "Actual",
|
"current": "Actual",
|
||||||
|
"multiple": "Valores múltiples",
|
||||||
"levels": {
|
"levels": {
|
||||||
"pg": "PG",
|
"pg": "PG",
|
||||||
"pg13": "PG13",
|
"pg13": "PG13",
|
||||||
@@ -583,6 +769,12 @@
|
|||||||
"countMessage": "modelos serán eliminados permanentemente.",
|
"countMessage": "modelos serán eliminados permanentemente.",
|
||||||
"action": "Eliminar todo"
|
"action": "Eliminar todo"
|
||||||
},
|
},
|
||||||
|
"checkUpdates": {
|
||||||
|
"title": "¿Comprobar actualizaciones para todos los {typePlural}?",
|
||||||
|
"message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.",
|
||||||
|
"tip": "¿Quieres hacerlo por partes? Activa el modo por lotes, selecciona los modelos que necesites y usa \"Comprobar actualizaciones para la selección\".",
|
||||||
|
"action": "Comprobar todo"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "Añadir etiquetas a múltiples modelos",
|
"title": "Añadir etiquetas a múltiples modelos",
|
||||||
"description": "Añadir etiquetas a",
|
"description": "Añadir etiquetas a",
|
||||||
@@ -651,7 +843,12 @@
|
|||||||
"editBaseModel": "Editar modelo base",
|
"editBaseModel": "Editar modelo base",
|
||||||
"viewOnCivitai": "Ver en Civitai",
|
"viewOnCivitai": "Ver en Civitai",
|
||||||
"viewOnCivitaiText": "Ver en Civitai",
|
"viewOnCivitaiText": "Ver en Civitai",
|
||||||
"viewCreatorProfile": "Ver perfil del creador"
|
"viewCreatorProfile": "Ver perfil del creador",
|
||||||
|
"openFileLocation": "Abrir ubicación del archivo"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "Ubicación del archivo abierta exitosamente",
|
||||||
|
"failed": "Error al abrir la ubicación del archivo"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "Versión",
|
"version": "Versión",
|
||||||
@@ -675,6 +872,7 @@
|
|||||||
"strengthMin": "Fuerza mínima",
|
"strengthMin": "Fuerza mínima",
|
||||||
"strengthMax": "Fuerza máxima",
|
"strengthMax": "Fuerza máxima",
|
||||||
"strength": "Fuerza",
|
"strength": "Fuerza",
|
||||||
|
"clipStrength": "Fuerza de Clip",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Valor",
|
"valuePlaceholder": "Valor",
|
||||||
"add": "Añadir"
|
"add": "Añadir"
|
||||||
@@ -713,13 +911,77 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"examples": "Ejemplos",
|
"examples": "Ejemplos",
|
||||||
"description": "Descripción del modelo",
|
"description": "Descripción del modelo",
|
||||||
"recipes": "Recetas"
|
"recipes": "Recetas",
|
||||||
|
"versions": "Versiones"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"noImageSell": "No selling generated content",
|
||||||
|
"noRentCivit": "No Civitai generation",
|
||||||
|
"noRent": "No generation services",
|
||||||
|
"noSell": "No selling models",
|
||||||
|
"creditRequired": "Crédito del creador requerido",
|
||||||
|
"noDerivatives": "No se permiten fusiones",
|
||||||
|
"noReLicense": "Se requieren mismos permisos",
|
||||||
|
"restrictionsLabel": "Restricciones de licencia"
|
||||||
},
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"exampleImages": "Cargando imágenes de ejemplo...",
|
"exampleImages": "Cargando imágenes de ejemplo...",
|
||||||
"description": "Cargando descripción del modelo...",
|
"description": "Cargando descripción del modelo...",
|
||||||
"recipes": "Cargando recetas...",
|
"recipes": "Cargando recetas...",
|
||||||
"examples": "Cargando ejemplos..."
|
"examples": "Cargando ejemplos...",
|
||||||
|
"versions": "Cargando versiones..."
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"heading": "Versiones del modelo",
|
||||||
|
"copy": "Administra todas las versiones de este modelo en un solo lugar.",
|
||||||
|
"media": {
|
||||||
|
"placeholder": "Sin vista previa"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"unnamed": "Versión sin nombre",
|
||||||
|
"noDetails": "Sin detalles adicionales"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "Versión actual",
|
||||||
|
"inLibrary": "En la biblioteca",
|
||||||
|
"newer": "Versión más reciente",
|
||||||
|
"ignored": "Ignorada"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"download": "Descargar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"ignore": "Ignorar",
|
||||||
|
"unignore": "Dejar de ignorar",
|
||||||
|
"resumeModelUpdates": "Reanudar actualizaciones para este modelo",
|
||||||
|
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
|
||||||
|
"viewLocalVersions": "Ver todas las versiones locales",
|
||||||
|
"viewLocalTooltip": "Disponible pronto"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"label": "Filtro base",
|
||||||
|
"state": {
|
||||||
|
"showAll": "Todas las versiones",
|
||||||
|
"showSameBase": "Mismo modelo base"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"showAllVersions": "Cambiar para mostrar todas las versiones",
|
||||||
|
"showSameBaseVersions": "Cambiar para mostrar solo versiones del mismo modelo base"
|
||||||
|
},
|
||||||
|
"empty": "Ninguna versión coincide con el filtro del modelo base actual."
|
||||||
|
},
|
||||||
|
"empty": "Aún no hay historial de versiones para este modelo.",
|
||||||
|
"error": "No se pudieron cargar las versiones.",
|
||||||
|
"missingModelId": "Este modelo no tiene un ID de modelo de Civitai.",
|
||||||
|
"confirm": {
|
||||||
|
"delete": "¿Eliminar esta versión de tu biblioteca?"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"modelIgnored": "Se ignoran las actualizaciones de este modelo",
|
||||||
|
"modelResumed": "Seguimiento de actualizaciones reanudado",
|
||||||
|
"versionIgnored": "Se ignoran las actualizaciones de esta versión",
|
||||||
|
"versionUnignored": "Versión habilitada nuevamente",
|
||||||
|
"versionDeleted": "Versión eliminada"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -826,7 +1088,9 @@
|
|||||||
"loraFailedToSend": "Error al enviar LoRA al flujo de trabajo",
|
"loraFailedToSend": "Error al enviar LoRA al flujo de trabajo",
|
||||||
"recipeAdded": "Receta añadida al flujo de trabajo",
|
"recipeAdded": "Receta añadida al flujo de trabajo",
|
||||||
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
||||||
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo"
|
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
|
||||||
|
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
||||||
|
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Receta",
|
"recipe": "Receta",
|
||||||
@@ -871,6 +1135,11 @@
|
|||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "Comprobar actualizaciones",
|
"title": "Comprobar actualizaciones",
|
||||||
|
"notificationsTitle": "Centro de notificaciones",
|
||||||
|
"tabs": {
|
||||||
|
"updates": "Actualizaciones",
|
||||||
|
"messages": "Mensajes"
|
||||||
|
},
|
||||||
"updateAvailable": "Actualización disponible",
|
"updateAvailable": "Actualización disponible",
|
||||||
"noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.",
|
"noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.",
|
||||||
"currentVersion": "Versión actual",
|
"currentVersion": "Versión actual",
|
||||||
@@ -902,6 +1171,13 @@
|
|||||||
"nightly": {
|
"nightly": {
|
||||||
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
|
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
|
||||||
"enable": "Habilitar actualizaciones nocturnas"
|
"enable": "Habilitar actualizaciones nocturnas"
|
||||||
|
},
|
||||||
|
"banners": {
|
||||||
|
"recent": "Notificaciones recientes",
|
||||||
|
"empty": "No hay banners recientes.",
|
||||||
|
"shown": "Mostrado {time}",
|
||||||
|
"dismissed": "Descartado {time}",
|
||||||
|
"active": "Activo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
@@ -981,6 +1257,9 @@
|
|||||||
"cannotSend": "No se puede enviar receta: Falta ID de receta",
|
"cannotSend": "No se puede enviar receta: Falta ID de receta",
|
||||||
"sendFailed": "Error al enviar receta al flujo de trabajo",
|
"sendFailed": "Error al enviar receta al flujo de trabajo",
|
||||||
"sendError": "Error enviando receta al flujo de trabajo",
|
"sendError": "Error enviando receta al flujo de trabajo",
|
||||||
|
"missingCheckpointPath": "Ruta del checkpoint no disponible",
|
||||||
|
"missingCheckpointInfo": "Falta información del checkpoint",
|
||||||
|
"downloadCheckpointFailed": "Error al descargar el checkpoint: {message}",
|
||||||
"cannotDelete": "No se puede eliminar receta: Falta ID de receta",
|
"cannotDelete": "No se puede eliminar receta: Falta ID de receta",
|
||||||
"deleteConfirmationError": "Error mostrando confirmación de eliminación",
|
"deleteConfirmationError": "Error mostrando confirmación de eliminación",
|
||||||
"deletedSuccessfully": "Receta eliminada exitosamente",
|
"deletedSuccessfully": "Receta eliminada exitosamente",
|
||||||
@@ -1017,6 +1296,16 @@
|
|||||||
"bulkBaseModelUpdateSuccess": "Modelo base actualizado exitosamente para {count} modelo(s)",
|
"bulkBaseModelUpdateSuccess": "Modelo base actualizado exitosamente para {count} modelo(s)",
|
||||||
"bulkBaseModelUpdatePartial": "Actualizados {success} modelo(s), fallaron {failed} modelo(s)",
|
"bulkBaseModelUpdatePartial": "Actualizados {success} modelo(s), fallaron {failed} modelo(s)",
|
||||||
"bulkBaseModelUpdateFailed": "Error al actualizar el modelo base para los modelos seleccionados",
|
"bulkBaseModelUpdateFailed": "Error al actualizar el modelo base para los modelos seleccionados",
|
||||||
|
"bulkContentRatingUpdating": "Actualizando la clasificación de contenido para {count} modelo(s)...",
|
||||||
|
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
|
||||||
|
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
|
||||||
|
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
|
||||||
|
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
|
||||||
|
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
|
||||||
|
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
|
||||||
|
"bulkUpdatesMissing": "Los {type} seleccionados no están vinculados a actualizaciones de Civitai",
|
||||||
|
"bulkUpdatesPartialMissing": "Se omitieron {missing} {type} seleccionados sin enlace de Civitai",
|
||||||
|
"bulkUpdatesFailed": "Error al comprobar actualizaciones para los {type} seleccionados: {message}",
|
||||||
"invalidCharactersRemoved": "Caracteres inválidos eliminados del nombre de archivo",
|
"invalidCharactersRemoved": "Caracteres inválidos eliminados del nombre de archivo",
|
||||||
"filenameCannotBeEmpty": "El nombre de archivo no puede estar vacío",
|
"filenameCannotBeEmpty": "El nombre de archivo no puede estar vacío",
|
||||||
"renameFailed": "Error al renombrar archivo: {message}",
|
"renameFailed": "Error al renombrar archivo: {message}",
|
||||||
@@ -1051,6 +1340,8 @@
|
|||||||
"compactModeToggled": "Modo compacto {state}",
|
"compactModeToggled": "Modo compacto {state}",
|
||||||
"settingSaveFailed": "Error al guardar configuración: {message}",
|
"settingSaveFailed": "Error al guardar configuración: {message}",
|
||||||
"displayDensitySet": "Densidad de visualización establecida a {density}",
|
"displayDensitySet": "Densidad de visualización establecida a {density}",
|
||||||
|
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||||
|
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||||
"languageChangeFailed": "Error al cambiar idioma: {message}",
|
"languageChangeFailed": "Error al cambiar idioma: {message}",
|
||||||
"cacheCleared": "Archivos de caché limpiados exitosamente. La caché se reconstruirá en la próxima acción.",
|
"cacheCleared": "Archivos de caché limpiados exitosamente. La caché se reconstruirá en la próxima acción.",
|
||||||
"cacheClearFailed": "Error al limpiar caché: {error}",
|
"cacheClearFailed": "Error al limpiar caché: {error}",
|
||||||
@@ -1075,7 +1366,7 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "No se pudieron cargar palabras entrenadas",
|
"loadFailed": "No se pudieron cargar palabras entrenadas",
|
||||||
"tooLong": "La palabra clave no debe exceder 30 palabras",
|
"tooLong": "La palabra clave no debe exceder 100 palabras",
|
||||||
"tooMany": "Máximo 30 palabras clave permitidas",
|
"tooMany": "Máximo 30 palabras clave permitidas",
|
||||||
"alreadyExists": "Esta palabra clave ya existe",
|
"alreadyExists": "Esta palabra clave ya existe",
|
||||||
"updateSuccess": "Palabras clave actualizadas exitosamente",
|
"updateSuccess": "Palabras clave actualizadas exitosamente",
|
||||||
@@ -1115,6 +1406,7 @@
|
|||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"pathUpdated": "Ruta de imágenes de ejemplo actualizada exitosamente",
|
"pathUpdated": "Ruta de imágenes de ejemplo actualizada exitosamente",
|
||||||
|
"pathUpdateFailed": "Error al actualizar la ruta de imágenes de ejemplo: {message}",
|
||||||
"downloadInProgress": "Descarga ya en progreso",
|
"downloadInProgress": "Descarga ya en progreso",
|
||||||
"enterLocationFirst": "Por favor introduce primero una ubicación de descarga",
|
"enterLocationFirst": "Por favor introduce primero una ubicación de descarga",
|
||||||
"downloadStarted": "Descarga de imágenes de ejemplo iniciada",
|
"downloadStarted": "Descarga de imágenes de ejemplo iniciada",
|
||||||
@@ -1123,6 +1415,8 @@
|
|||||||
"pauseFailed": "Error al pausar descarga: {error}",
|
"pauseFailed": "Error al pausar descarga: {error}",
|
||||||
"downloadResumed": "Descarga reanudada",
|
"downloadResumed": "Descarga reanudada",
|
||||||
"resumeFailed": "Error al reanudar descarga: {error}",
|
"resumeFailed": "Error al reanudar descarga: {error}",
|
||||||
|
"downloadStopped": "Descarga cancelada",
|
||||||
|
"stopFailed": "Error al cancelar descarga: {error}",
|
||||||
"deleted": "Imagen de ejemplo eliminada",
|
"deleted": "Imagen de ejemplo eliminada",
|
||||||
"deleteFailed": "Error al eliminar imagen de ejemplo",
|
"deleteFailed": "Error al eliminar imagen de ejemplo",
|
||||||
"setPreviewFailed": "Error al establecer imagen de vista previa"
|
"setPreviewFailed": "Error al establecer imagen de vista previa"
|
||||||
@@ -1169,6 +1463,12 @@
|
|||||||
"refreshNow": "Actualizar ahora",
|
"refreshNow": "Actualizar ahora",
|
||||||
"refreshingIn": "Actualizando en",
|
"refreshingIn": "Actualizando en",
|
||||||
"seconds": "segundos"
|
"seconds": "segundos"
|
||||||
|
},
|
||||||
|
"communitySupport": {
|
||||||
|
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||||
|
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||||
|
"supportCta": "Support on Ko-fi",
|
||||||
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
344
locales/fr.json
344
locales/fr.json
@@ -16,7 +16,9 @@
|
|||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
"unknown": "Inconnu",
|
"unknown": "Inconnu",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"version": "Version"
|
"version": "Version",
|
||||||
|
"enabled": "Activé",
|
||||||
|
"disabled": "Désactivé"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"select": "Langue",
|
"select": "Langue",
|
||||||
@@ -29,7 +31,8 @@
|
|||||||
"japanese": "日本語",
|
"japanese": "日本語",
|
||||||
"korean": "한국어",
|
"korean": "한국어",
|
||||||
"french": "Français",
|
"french": "Français",
|
||||||
"spanish": "Español"
|
"spanish": "Español",
|
||||||
|
"Hebrew": "עברית"
|
||||||
},
|
},
|
||||||
"fileSize": {
|
"fileSize": {
|
||||||
"zero": "0 Octets",
|
"zero": "0 Octets",
|
||||||
@@ -98,7 +101,12 @@
|
|||||||
"checkpointNameCopied": "Nom du checkpoint copié",
|
"checkpointNameCopied": "Nom du checkpoint copié",
|
||||||
"toggleBlur": "Basculer le flou",
|
"toggleBlur": "Basculer le flou",
|
||||||
"show": "Afficher",
|
"show": "Afficher",
|
||||||
"openExampleImages": "Ouvrir le dossier d'images d'exemple"
|
"openExampleImages": "Ouvrir le dossier d'images d'exemple",
|
||||||
|
"replacePreview": "Remplacer l'aperçu",
|
||||||
|
"copyCheckpointName": "Copier le nom du checkpoint",
|
||||||
|
"copyEmbeddingName": "Copier le nom de l'embedding",
|
||||||
|
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
||||||
|
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
|
||||||
},
|
},
|
||||||
"nsfw": {
|
"nsfw": {
|
||||||
"matureContent": "Contenu pour adultes",
|
"matureContent": "Contenu pour adultes",
|
||||||
@@ -112,12 +120,45 @@
|
|||||||
"updateFailed": "Échec de la mise à jour du statut des favoris"
|
"updateFailed": "Échec de la mise à jour du statut des favoris"
|
||||||
},
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"checkpointNotImplemented": "Envoyer le checkpoint vers le workflow - fonctionnalité à implémenter"
|
"checkpointNotImplemented": "Envoyer le checkpoint vers le workflow - fonctionnalité à implémenter",
|
||||||
|
"missingPath": "Impossible de déterminer le chemin du modèle pour cette carte"
|
||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"checkError": "Erreur lors de la vérification des images d'exemple",
|
"checkError": "Erreur lors de la vérification des images d'exemple",
|
||||||
"missingHash": "Informations de hachage du modèle manquantes.",
|
"missingHash": "Informations de hachage du modèle manquantes.",
|
||||||
"noRemoteImagesAvailable": "Aucune image d'exemple distante disponible pour ce modèle sur Civitai"
|
"noRemoteImagesAvailable": "Aucune image d'exemple distante disponible pour ce modèle sur Civitai"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"update": "Mise à jour",
|
||||||
|
"updateAvailable": "Mise à jour disponible"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"globalContextMenu": {
|
||||||
|
"downloadExampleImages": {
|
||||||
|
"label": "Télécharger les images d'exemple",
|
||||||
|
"missingPath": "Définissez un emplacement de téléchargement avant de télécharger les images d'exemple.",
|
||||||
|
"unavailable": "Le téléchargement des images d'exemple n'est pas encore disponible. Réessayez après le chargement complet de la page."
|
||||||
|
},
|
||||||
|
"checkModelUpdates": {
|
||||||
|
"label": "Vérifier les mises à jour",
|
||||||
|
"loading": "Recherche de mises à jour pour {type}...",
|
||||||
|
"success": "{count} mise(s) à jour trouvée(s) pour {type}",
|
||||||
|
"none": "Tous les {type} sont à jour",
|
||||||
|
"error": "Échec de la vérification des mises à jour pour {type} : {message}"
|
||||||
|
},
|
||||||
|
"cleanupExampleImages": {
|
||||||
|
"label": "Supprimer les dossiers d'exemples orphelins",
|
||||||
|
"success": "{count} dossier(s) déplacé(s) vers le dossier supprimé",
|
||||||
|
"none": "Aucun dossier d'images d'exemple à nettoyer",
|
||||||
|
"partial": "Nettoyage terminé avec {failures} dossier(s) ignoré(s)",
|
||||||
|
"error": "Échec du nettoyage des dossiers d'images d'exemple : {message}"
|
||||||
|
},
|
||||||
|
"fetchMissingLicenses": {
|
||||||
|
"label": "Refresh license metadata",
|
||||||
|
"loading": "Refreshing license metadata for {typePlural}...",
|
||||||
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
|
"none": "All {typePlural} already have license metadata",
|
||||||
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -154,6 +195,10 @@
|
|||||||
"title": "Filtrer les modèles",
|
"title": "Filtrer les modèles",
|
||||||
"baseModel": "Modèle de base",
|
"baseModel": "Modèle de base",
|
||||||
"modelTags": "Tags (Top 20)",
|
"modelTags": "Tags (Top 20)",
|
||||||
|
"modelTypes": "Model Types",
|
||||||
|
"license": "Licence",
|
||||||
|
"noCreditRequired": "Crédit non requis",
|
||||||
|
"allowSellingGeneratedContent": "Vente autorisée",
|
||||||
"clearAll": "Effacer tous les filtres"
|
"clearAll": "Effacer tous les filtres"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -164,6 +209,7 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"checkUpdates": "Vérifier les mises à jour",
|
"checkUpdates": "Vérifier les mises à jour",
|
||||||
|
"notifications": "Notifications",
|
||||||
"support": "Support"
|
"support": "Support"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -171,14 +217,30 @@
|
|||||||
"civitaiApiKey": "Clé API Civitai",
|
"civitaiApiKey": "Clé API Civitai",
|
||||||
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
||||||
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
||||||
|
"openSettingsFileLocation": {
|
||||||
|
"label": "Ouvrir le dossier des paramètres",
|
||||||
|
"tooltip": "Ouvrir le dossier contenant settings.json",
|
||||||
|
"success": "Dossier settings.json ouvert",
|
||||||
|
"failed": "Impossible d'ouvrir le dossier settings.json"
|
||||||
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "Filtrage du contenu",
|
"contentFiltering": "Filtrage du contenu",
|
||||||
"videoSettings": "Paramètres vidéo",
|
"videoSettings": "Paramètres vidéo",
|
||||||
"layoutSettings": "Paramètres d'affichage",
|
"layoutSettings": "Paramètres d'affichage",
|
||||||
"folderSettings": "Paramètres des dossiers",
|
"folderSettings": "Paramètres des dossiers",
|
||||||
|
"priorityTags": "Étiquettes prioritaires",
|
||||||
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
||||||
"exampleImages": "Images d'exemple",
|
"exampleImages": "Images d'exemple",
|
||||||
"misc": "Divers"
|
"updateFlags": "Indicateurs de mise à jour",
|
||||||
|
"autoOrganize": "Auto-organize",
|
||||||
|
"misc": "Divers",
|
||||||
|
"metadataArchive": "Base de données d'archive des métadonnées",
|
||||||
|
"storageLocation": "Emplacement des paramètres",
|
||||||
|
"proxySettings": "Paramètres du proxy"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"locationLabel": "Mode portable",
|
||||||
|
"locationHelp": "Activez pour garder settings.json dans le dépôt ; désactivez pour le placer dans votre dossier de configuration utilisateur."
|
||||||
},
|
},
|
||||||
"contentFiltering": {
|
"contentFiltering": {
|
||||||
"blurNsfwContent": "Flouter le contenu NSFW",
|
"blurNsfwContent": "Flouter le contenu NSFW",
|
||||||
@@ -190,6 +252,15 @@
|
|||||||
"autoplayOnHover": "Lecture automatique vidéo au survol",
|
"autoplayOnHover": "Lecture automatique vidéo au survol",
|
||||||
"autoplayOnHoverHelp": "Lire les aperçus vidéo uniquement lors du survol"
|
"autoplayOnHoverHelp": "Lire les aperçus vidéo uniquement lors du survol"
|
||||||
},
|
},
|
||||||
|
"autoOrganizeExclusions": {
|
||||||
|
"label": "Exclusions de l'auto-organisation",
|
||||||
|
"placeholder": "Exemple : curated/*, */backups/*; *_temp.safetensors",
|
||||||
|
"help": "Ignorer les fichiers correspondant à ces motifs génériques. Séparez plusieurs motifs par des virgules ou des points-virgules.",
|
||||||
|
"validation": {
|
||||||
|
"noPatterns": "Entrez au moins un motif séparé par des virgules ou des points-virgules.",
|
||||||
|
"saveFailed": "Impossible d'enregistrer les exclusions : {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "Densité d'affichage",
|
"displayDensity": "Densité d'affichage",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -199,23 +270,37 @@
|
|||||||
},
|
},
|
||||||
"displayDensityHelp": "Choisissez combien de cartes afficher par ligne :",
|
"displayDensityHelp": "Choisissez combien de cartes afficher par ligne :",
|
||||||
"displayDensityDetails": {
|
"displayDensityDetails": {
|
||||||
"default": "Par défaut : 5 (1080p), 6 (2K), 8 (4K)",
|
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||||
"medium": "Moyen : 6 (1080p), 7 (2K), 9 (4K)",
|
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||||
"compact": "Compact : 7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "Attention : Des densités plus élevées peuvent causer des problèmes de performance sur les systèmes avec des ressources limitées.",
|
"displayDensityWarning": "Attention : Des densités plus élevées peuvent causer des problèmes de performance sur les systèmes avec des ressources limitées.",
|
||||||
|
"showFolderSidebar": "Afficher la barre latérale des dossiers",
|
||||||
|
"showFolderSidebarHelp": "Activez ou désactivez la barre latérale de navigation des dossiers sur les pages de modèles. Lorsqu'elle est désactivée, la barre latérale et la zone de survol restent masquées.",
|
||||||
"cardInfoDisplay": "Affichage des informations de carte",
|
"cardInfoDisplay": "Affichage des informations de carte",
|
||||||
"cardInfoDisplayOptions": {
|
"cardInfoDisplayOptions": {
|
||||||
"always": "Toujours visible",
|
"always": "Toujours visible",
|
||||||
"hover": "Révéler au survol"
|
"hover": "Révéler au survol"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action :",
|
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
|
||||||
"cardInfoDisplayDetails": {
|
"modelCardFooterAction": "Action du bouton de carte de modèle",
|
||||||
"always": "Toujours visible : Les en-têtes et pieds de page sont toujours visibles",
|
"modelCardFooterActionOptions": {
|
||||||
"hover": "Révéler au survol : Les en-têtes et pieds de page n'apparaissent qu'au survol d'une carte"
|
"exampleImages": "Ouvrir les images d'exemple",
|
||||||
}
|
"replacePreview": "Remplacer l'aperçu"
|
||||||
|
},
|
||||||
|
"modelCardFooterActionHelp": "Choisissez ce que fait le bouton en bas à droite de la carte",
|
||||||
|
"modelNameDisplay": "Affichage du nom du modèle",
|
||||||
|
"modelNameDisplayOptions": {
|
||||||
|
"modelName": "Nom du modèle",
|
||||||
|
"fileName": "Nom du fichier"
|
||||||
|
},
|
||||||
|
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle"
|
||||||
},
|
},
|
||||||
"folderSettings": {
|
"folderSettings": {
|
||||||
|
"activeLibrary": "Bibliothèque active",
|
||||||
|
"activeLibraryHelp": "Basculer entre les bibliothèques configurées pour mettre à jour les dossiers par défaut. Changer la sélection recharge la page.",
|
||||||
|
"loadingLibraries": "Chargement des bibliothèques...",
|
||||||
|
"noLibraries": "Aucune bibliothèque configurée",
|
||||||
"defaultLoraRoot": "Racine LoRA par défaut",
|
"defaultLoraRoot": "Racine LoRA par défaut",
|
||||||
"defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements",
|
"defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements",
|
||||||
"defaultCheckpointRoot": "Racine Checkpoint par défaut",
|
"defaultCheckpointRoot": "Racine Checkpoint par défaut",
|
||||||
@@ -224,6 +309,26 @@
|
|||||||
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
||||||
"noDefault": "Aucun par défaut"
|
"noDefault": "Aucun par défaut"
|
||||||
},
|
},
|
||||||
|
"priorityTags": {
|
||||||
|
"title": "Étiquettes prioritaires",
|
||||||
|
"description": "Personnalisez l'ordre de priorité des étiquettes pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
|
||||||
|
"placeholder": "character, concept, style(toon|toon_style)",
|
||||||
|
"helpLinkLabel": "Ouvrir l'aide sur les étiquettes prioritaires",
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"embedding": "Embedding"
|
||||||
|
},
|
||||||
|
"saveSuccess": "Étiquettes prioritaires mises à jour.",
|
||||||
|
"saveError": "Échec de la mise à jour des étiquettes prioritaires.",
|
||||||
|
"loadingSuggestions": "Chargement des suggestions...",
|
||||||
|
"validation": {
|
||||||
|
"missingClosingParen": "L'entrée {index} n'a pas de parenthèse fermante.",
|
||||||
|
"missingCanonical": "L'entrée {index} doit inclure un nom d'étiquette canonique.",
|
||||||
|
"duplicateCanonical": "L'étiquette canonique \"{tag}\" apparaît plusieurs fois.",
|
||||||
|
"unknown": "Configuration d'étiquettes prioritaires invalide."
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadPathTemplates": {
|
"downloadPathTemplates": {
|
||||||
"title": "Modèles de chemin de téléchargement",
|
"title": "Modèles de chemin de téléchargement",
|
||||||
"help": "Configurer les structures de dossiers pour différents types de modèles lors du téléchargement depuis Civitai.",
|
"help": "Configurer les structures de dossiers pour différents types de modèles lors du téléchargement depuis Civitai.",
|
||||||
@@ -236,6 +341,7 @@
|
|||||||
"baseModelFirstTag": "Modèle de base + Premier tag",
|
"baseModelFirstTag": "Modèle de base + Premier tag",
|
||||||
"baseModelAuthor": "Modèle de base + Auteur",
|
"baseModelAuthor": "Modèle de base + Auteur",
|
||||||
"authorFirstTag": "Auteur + Premier tag",
|
"authorFirstTag": "Auteur + Premier tag",
|
||||||
|
"baseModelAuthorFirstTag": "Modèle de base + Auteur + Premier tag",
|
||||||
"customTemplate": "Modèle personnalisé"
|
"customTemplate": "Modèle personnalisé"
|
||||||
},
|
},
|
||||||
"customTemplatePlaceholder": "Entrez un modèle personnalisé (ex: {base_model}/{author}/{first_tag})",
|
"customTemplatePlaceholder": "Entrez un modèle personnalisé (ex: {base_model}/{author}/{first_tag})",
|
||||||
@@ -270,9 +376,59 @@
|
|||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"restartRequired": "Redémarrage requis"
|
"restartRequired": "Redémarrage requis"
|
||||||
},
|
},
|
||||||
|
"updateFlagStrategy": {
|
||||||
|
"label": "Stratégie des indicateurs de mise à jour",
|
||||||
|
"help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsqu’une nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès qu’il existe une version plus récente pour ce modèle.",
|
||||||
|
"options": {
|
||||||
|
"sameBase": "Faire correspondre les mises à jour par modèle de base",
|
||||||
|
"any": "Signaler n’importe quelle mise à jour disponible"
|
||||||
|
}
|
||||||
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
|
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
|
||||||
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers"
|
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers"
|
||||||
|
},
|
||||||
|
"metadataArchive": {
|
||||||
|
"enableArchiveDb": "Activer la base de données d'archive des métadonnées",
|
||||||
|
"enableArchiveDbHelp": "Utiliser une base de données locale pour accéder aux métadonnées des modèles supprimés de Civitai.",
|
||||||
|
"status": "Statut",
|
||||||
|
"statusAvailable": "Disponible",
|
||||||
|
"statusUnavailable": "Non disponible",
|
||||||
|
"enabled": "Activé",
|
||||||
|
"management": "Gestion de la base de données",
|
||||||
|
"managementHelp": "Télécharger ou supprimer la base de données d'archive des métadonnées",
|
||||||
|
"downloadButton": "Télécharger la base de données",
|
||||||
|
"downloadingButton": "Téléchargement...",
|
||||||
|
"downloadedButton": "Téléchargé",
|
||||||
|
"removeButton": "Supprimer la base de données",
|
||||||
|
"removingButton": "Suppression...",
|
||||||
|
"downloadSuccess": "Base de données d'archive des métadonnées téléchargée avec succès",
|
||||||
|
"downloadError": "Échec du téléchargement de la base de données d'archive des métadonnées",
|
||||||
|
"removeSuccess": "Base de données d'archive des métadonnées supprimée avec succès",
|
||||||
|
"removeError": "Échec de la suppression de la base de données d'archive des métadonnées",
|
||||||
|
"removeConfirm": "Êtes-vous sûr de vouloir supprimer la base de données d'archive des métadonnées ? Cela supprimera le fichier local et vous devrez la télécharger à nouveau pour utiliser cette fonctionnalité.",
|
||||||
|
"preparing": "Préparation du téléchargement...",
|
||||||
|
"connecting": "Connexion au serveur de téléchargement...",
|
||||||
|
"completed": "Terminé",
|
||||||
|
"downloadComplete": "Téléchargement terminé avec succès"
|
||||||
|
},
|
||||||
|
"proxySettings": {
|
||||||
|
"enableProxy": "Activer le proxy au niveau de l'application",
|
||||||
|
"enableProxyHelp": "Activer les paramètres de proxy personnalisés pour cette application, remplaçant les paramètres de proxy système",
|
||||||
|
"proxyType": "Type de proxy",
|
||||||
|
"proxyTypeHelp": "Sélectionnez le type de serveur proxy (HTTP, HTTPS, SOCKS4, SOCKS5)",
|
||||||
|
"proxyHost": "Hôte du proxy",
|
||||||
|
"proxyHostPlaceholder": "proxy.exemple.com",
|
||||||
|
"proxyHostHelp": "Le nom d'hôte ou l'adresse IP de votre serveur proxy",
|
||||||
|
"proxyPort": "Port du proxy",
|
||||||
|
"proxyPortPlaceholder": "8080",
|
||||||
|
"proxyPortHelp": "Le numéro de port de votre serveur proxy",
|
||||||
|
"proxyUsername": "Nom d'utilisateur (optionnel)",
|
||||||
|
"proxyUsernamePlaceholder": "nom_utilisateur",
|
||||||
|
"proxyUsernameHelp": "Nom d'utilisateur pour l'authentification proxy (si nécessaire)",
|
||||||
|
"proxyPassword": "Mot de passe (optionnel)",
|
||||||
|
"proxyPasswordPlaceholder": "mot_de_passe",
|
||||||
|
"proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"loras": {
|
"loras": {
|
||||||
@@ -291,8 +447,10 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des modèles",
|
"title": "Actualiser la liste des modèles",
|
||||||
"quick": "Actualisation rapide (incrémentale)",
|
"quick": "Synchroniser les changements",
|
||||||
"full": "Reconstruction complète"
|
"quickTooltip": "Analyse les nouveaux fichiers de modèle ou les fichiers manquants pour garder la liste à jour.",
|
||||||
|
"full": "Reconstruire le cache",
|
||||||
|
"fullTooltip": "Recharge tous les détails des modèles depuis les fichiers metadata — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles."
|
||||||
},
|
},
|
||||||
"fetch": {
|
"fetch": {
|
||||||
"title": "Récupérer les métadonnées depuis Civitai",
|
"title": "Récupérer les métadonnées depuis Civitai",
|
||||||
@@ -313,6 +471,13 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "Afficher uniquement les favoris",
|
"title": "Afficher uniquement les favoris",
|
||||||
"action": "Favoris"
|
"action": "Favoris"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"title": "Afficher uniquement les modèles avec des mises à jour disponibles",
|
||||||
|
"action": "Mises à jour",
|
||||||
|
"menuLabel": "Afficher les options de mise à jour",
|
||||||
|
"check": "Rechercher des mises à jour",
|
||||||
|
"checkTooltip": "La vérification peut prendre du temps."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bulkOperations": {
|
"bulkOperations": {
|
||||||
@@ -321,8 +486,10 @@
|
|||||||
"viewSelected": "Voir la sélection",
|
"viewSelected": "Voir la sélection",
|
||||||
"addTags": "Ajouter des tags à tous",
|
"addTags": "Ajouter des tags à tous",
|
||||||
"setBaseModel": "Définir le modèle de base pour tous",
|
"setBaseModel": "Définir le modèle de base pour tous",
|
||||||
|
"setContentRating": "Définir la classification du contenu pour tous",
|
||||||
"copyAll": "Copier toute la syntaxe",
|
"copyAll": "Copier toute la syntaxe",
|
||||||
"refreshAll": "Actualiser toutes les métadonnées",
|
"refreshAll": "Actualiser toutes les métadonnées",
|
||||||
|
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||||
"moveAll": "Déplacer tout vers un dossier",
|
"moveAll": "Déplacer tout vers un dossier",
|
||||||
"autoOrganize": "Auto-organiser la sélection",
|
"autoOrganize": "Auto-organiser la sélection",
|
||||||
"deleteAll": "Supprimer tous les modèles",
|
"deleteAll": "Supprimer tous les modèles",
|
||||||
@@ -339,6 +506,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "Actualiser les données Civitai",
|
"refreshMetadata": "Actualiser les données Civitai",
|
||||||
|
"checkUpdates": "Vérifier les mises à jour",
|
||||||
"relinkCivitai": "Relier à nouveau à Civitai",
|
"relinkCivitai": "Relier à nouveau à Civitai",
|
||||||
"copySyntax": "Copier la syntaxe LoRA",
|
"copySyntax": "Copier la syntaxe LoRA",
|
||||||
"copyFilename": "Copier le nom de fichier du modèle",
|
"copyFilename": "Copier le nom de fichier du modèle",
|
||||||
@@ -360,6 +528,9 @@
|
|||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRA Recipes",
|
"title": "LoRA Recipes",
|
||||||
|
"actions": {
|
||||||
|
"sendCheckpoint": "Envoyer vers ComfyUI"
|
||||||
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "Importer",
|
"action": "Importer",
|
||||||
@@ -456,13 +627,19 @@
|
|||||||
"title": "Modèles Embedding"
|
"title": "Modèles Embedding"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Racine du modèle",
|
"modelRoot": "Racine",
|
||||||
"collapseAll": "Réduire tous les dossiers",
|
"collapseAll": "Réduire tous les dossiers",
|
||||||
"pinSidebar": "Épingler la barre latérale",
|
"pinSidebar": "Épingler la barre latérale",
|
||||||
"unpinSidebar": "Désépingler la barre latérale",
|
"unpinSidebar": "Désépingler la barre latérale",
|
||||||
"switchToListView": "Passer en vue liste",
|
"switchToListView": "Passer en vue liste",
|
||||||
"switchToTreeView": "Passer en vue arborescence",
|
"switchToTreeView": "Passer en vue arborescence",
|
||||||
"collapseAllDisabled": "Non disponible en vue liste"
|
"recursiveOn": "Rechercher dans les sous-dossiers",
|
||||||
|
"recursiveOff": "Rechercher uniquement dans le dossier actuel",
|
||||||
|
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
||||||
|
"collapseAllDisabled": "Non disponible en vue liste",
|
||||||
|
"dragDrop": {
|
||||||
|
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"title": "Statistiques",
|
"title": "Statistiques",
|
||||||
@@ -537,6 +714,14 @@
|
|||||||
"downloadedPreview": "Image d'aperçu téléchargée",
|
"downloadedPreview": "Image d'aperçu téléchargée",
|
||||||
"downloadingFile": "Téléchargement du fichier {type}",
|
"downloadingFile": "Téléchargement du fichier {type}",
|
||||||
"finalizing": "Finalisation du téléchargement..."
|
"finalizing": "Finalisation du téléchargement..."
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"currentFile": "Fichier actuel :",
|
||||||
|
"downloading": "Téléchargement : {name}",
|
||||||
|
"transferred": "Téléchargé : {downloaded} / {total}",
|
||||||
|
"transferredSimple": "Téléchargé : {downloaded}",
|
||||||
|
"transferredUnknown": "Téléchargé : --",
|
||||||
|
"speed": "Vitesse : {speed}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
@@ -545,6 +730,7 @@
|
|||||||
"contentRating": {
|
"contentRating": {
|
||||||
"title": "Définir la classification du contenu",
|
"title": "Définir la classification du contenu",
|
||||||
"current": "Actuel",
|
"current": "Actuel",
|
||||||
|
"multiple": "Valeurs multiples",
|
||||||
"levels": {
|
"levels": {
|
||||||
"pg": "PG",
|
"pg": "PG",
|
||||||
"pg13": "PG13",
|
"pg13": "PG13",
|
||||||
@@ -583,6 +769,12 @@
|
|||||||
"countMessage": "modèles seront définitivement supprimés.",
|
"countMessage": "modèles seront définitivement supprimés.",
|
||||||
"action": "Tout supprimer"
|
"action": "Tout supprimer"
|
||||||
},
|
},
|
||||||
|
"checkUpdates": {
|
||||||
|
"title": "Vérifier les mises à jour pour tous les {typePlural} ?",
|
||||||
|
"message": "Cette action vérifie les mises à jour pour tous les {typePlural} de votre bibliothèque. Les grandes collections peuvent prendre un peu plus de temps.",
|
||||||
|
"tip": "Besoin de procéder par étapes ? Passez en mode lot, sélectionnez les modèles souhaités puis utilisez \"Vérifier les mises à jour pour la sélection\".",
|
||||||
|
"action": "Tout vérifier"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "Ajouter des tags à plusieurs modèles",
|
"title": "Ajouter des tags à plusieurs modèles",
|
||||||
"description": "Ajouter des tags à",
|
"description": "Ajouter des tags à",
|
||||||
@@ -651,7 +843,12 @@
|
|||||||
"editBaseModel": "Modifier le modèle de base",
|
"editBaseModel": "Modifier le modèle de base",
|
||||||
"viewOnCivitai": "Voir sur Civitai",
|
"viewOnCivitai": "Voir sur Civitai",
|
||||||
"viewOnCivitaiText": "Voir sur Civitai",
|
"viewOnCivitaiText": "Voir sur Civitai",
|
||||||
"viewCreatorProfile": "Voir le profil du créateur"
|
"viewCreatorProfile": "Voir le profil du créateur",
|
||||||
|
"openFileLocation": "Ouvrir l'emplacement du fichier"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "Emplacement du fichier ouvert avec succès",
|
||||||
|
"failed": "Échec de l'ouverture de l'emplacement du fichier"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
@@ -675,6 +872,7 @@
|
|||||||
"strengthMin": "Force Min",
|
"strengthMin": "Force Min",
|
||||||
"strengthMax": "Force Max",
|
"strengthMax": "Force Max",
|
||||||
"strength": "Force",
|
"strength": "Force",
|
||||||
|
"clipStrength": "Force Clip",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Valeur",
|
"valuePlaceholder": "Valeur",
|
||||||
"add": "Ajouter"
|
"add": "Ajouter"
|
||||||
@@ -713,13 +911,77 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"examples": "Exemples",
|
"examples": "Exemples",
|
||||||
"description": "Description du modèle",
|
"description": "Description du modèle",
|
||||||
"recipes": "Recipes"
|
"recipes": "Recipes",
|
||||||
|
"versions": "Versions"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"noImageSell": "No selling generated content",
|
||||||
|
"noRentCivit": "No Civitai generation",
|
||||||
|
"noRent": "No generation services",
|
||||||
|
"noSell": "No selling models",
|
||||||
|
"creditRequired": "Crédit du créateur requis",
|
||||||
|
"noDerivatives": "Pas de fusion de partage",
|
||||||
|
"noReLicense": "Mêmes autorisations requises",
|
||||||
|
"restrictionsLabel": "Restrictions de licence"
|
||||||
},
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"exampleImages": "Chargement des images d'exemple...",
|
"exampleImages": "Chargement des images d'exemple...",
|
||||||
"description": "Chargement de la description du modèle...",
|
"description": "Chargement de la description du modèle...",
|
||||||
"recipes": "Chargement des recipes...",
|
"recipes": "Chargement des recipes...",
|
||||||
"examples": "Chargement des exemples..."
|
"examples": "Chargement des exemples...",
|
||||||
|
"versions": "Chargement des versions..."
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"heading": "Versions du modèle",
|
||||||
|
"copy": "Gérez toutes les versions de ce modèle en un seul endroit.",
|
||||||
|
"media": {
|
||||||
|
"placeholder": "Aucune prévisualisation"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"unnamed": "Version sans nom",
|
||||||
|
"noDetails": "Aucun détail supplémentaire"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "Version actuelle",
|
||||||
|
"inLibrary": "Dans la bibliothèque",
|
||||||
|
"newer": "Version plus récente",
|
||||||
|
"ignored": "Ignorée"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"download": "Télécharger",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"ignore": "Ignorer",
|
||||||
|
"unignore": "Ne plus ignorer",
|
||||||
|
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
|
||||||
|
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
|
||||||
|
"viewLocalVersions": "Voir toutes les versions locales",
|
||||||
|
"viewLocalTooltip": "Bientôt disponible"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"label": "Filtre de base",
|
||||||
|
"state": {
|
||||||
|
"showAll": "Toutes les versions",
|
||||||
|
"showSameBase": "Même modèle de base"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"showAllVersions": "Passer à l'affichage de toutes les versions",
|
||||||
|
"showSameBaseVersions": "Passer à l'affichage des versions du même modèle de base"
|
||||||
|
},
|
||||||
|
"empty": "Aucune version ne correspond au filtre du modèle de base actuel."
|
||||||
|
},
|
||||||
|
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
|
||||||
|
"error": "Échec du chargement des versions.",
|
||||||
|
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.",
|
||||||
|
"confirm": {
|
||||||
|
"delete": "Supprimer cette version de votre bibliothèque ?"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"modelIgnored": "Les mises à jour de ce modèle sont ignorées",
|
||||||
|
"modelResumed": "Suivi des mises à jour repris",
|
||||||
|
"versionIgnored": "Les mises à jour de cette version sont ignorées",
|
||||||
|
"versionUnignored": "Version réactivée",
|
||||||
|
"versionDeleted": "Version supprimée"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -826,7 +1088,9 @@
|
|||||||
"loraFailedToSend": "Échec de l'envoi du LoRA au workflow",
|
"loraFailedToSend": "Échec de l'envoi du LoRA au workflow",
|
||||||
"recipeAdded": "Recipe ajoutée au workflow",
|
"recipeAdded": "Recipe ajoutée au workflow",
|
||||||
"recipeReplaced": "Recipe remplacée dans le workflow",
|
"recipeReplaced": "Recipe remplacée dans le workflow",
|
||||||
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow"
|
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
|
||||||
|
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
||||||
|
"noTargetNodeSelected": "Aucun nœud cible sélectionné"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Recipe",
|
"recipe": "Recipe",
|
||||||
@@ -871,6 +1135,11 @@
|
|||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "Vérifier les mises à jour",
|
"title": "Vérifier les mises à jour",
|
||||||
|
"notificationsTitle": "Notifications",
|
||||||
|
"tabs": {
|
||||||
|
"updates": "Mises à jour",
|
||||||
|
"messages": "Messages"
|
||||||
|
},
|
||||||
"updateAvailable": "Mise à jour disponible",
|
"updateAvailable": "Mise à jour disponible",
|
||||||
"noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.",
|
"noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.",
|
||||||
"currentVersion": "Version actuelle",
|
"currentVersion": "Version actuelle",
|
||||||
@@ -902,6 +1171,13 @@
|
|||||||
"nightly": {
|
"nightly": {
|
||||||
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
|
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
|
||||||
"enable": "Activer les mises à jour nightly"
|
"enable": "Activer les mises à jour nightly"
|
||||||
|
},
|
||||||
|
"banners": {
|
||||||
|
"recent": "Messages récents",
|
||||||
|
"empty": "Aucune bannière récente.",
|
||||||
|
"shown": "Affiché {time}",
|
||||||
|
"dismissed": "Ignoré {time}",
|
||||||
|
"active": "Actif"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
@@ -981,6 +1257,9 @@
|
|||||||
"cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant",
|
"cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant",
|
||||||
"sendFailed": "Échec de l'envoi de la recipe vers le workflow",
|
"sendFailed": "Échec de l'envoi de la recipe vers le workflow",
|
||||||
"sendError": "Erreur lors de l'envoi de la recipe vers le workflow",
|
"sendError": "Erreur lors de l'envoi de la recipe vers le workflow",
|
||||||
|
"missingCheckpointPath": "Chemin du checkpoint indisponible",
|
||||||
|
"missingCheckpointInfo": "Informations sur le checkpoint manquantes",
|
||||||
|
"downloadCheckpointFailed": "Échec du téléchargement du checkpoint : {message}",
|
||||||
"cannotDelete": "Impossible de supprimer la recipe : ID de recipe manquant",
|
"cannotDelete": "Impossible de supprimer la recipe : ID de recipe manquant",
|
||||||
"deleteConfirmationError": "Erreur lors de l'affichage de la confirmation de suppression",
|
"deleteConfirmationError": "Erreur lors de l'affichage de la confirmation de suppression",
|
||||||
"deletedSuccessfully": "Recipe supprimée avec succès",
|
"deletedSuccessfully": "Recipe supprimée avec succès",
|
||||||
@@ -1017,6 +1296,16 @@
|
|||||||
"bulkBaseModelUpdateSuccess": "Modèle de base mis à jour avec succès pour {count} modèle(s)",
|
"bulkBaseModelUpdateSuccess": "Modèle de base mis à jour avec succès pour {count} modèle(s)",
|
||||||
"bulkBaseModelUpdatePartial": "{success} modèle(s) mis à jour, {failed} modèle(s) en échec",
|
"bulkBaseModelUpdatePartial": "{success} modèle(s) mis à jour, {failed} modèle(s) en échec",
|
||||||
"bulkBaseModelUpdateFailed": "Échec de la mise à jour du modèle de base pour les modèles sélectionnés",
|
"bulkBaseModelUpdateFailed": "Échec de la mise à jour du modèle de base pour les modèles sélectionnés",
|
||||||
|
"bulkContentRatingUpdating": "Mise à jour de la classification du contenu pour {count} modèle(s)...",
|
||||||
|
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
|
||||||
|
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
|
||||||
|
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
|
||||||
|
"bulkUpdatesChecking": "Vérification des mises à jour pour les {type} sélectionnés...",
|
||||||
|
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
|
||||||
|
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
|
||||||
|
"bulkUpdatesMissing": "Les {type} sélectionnés ne sont pas liés aux mises à jour Civitai",
|
||||||
|
"bulkUpdatesPartialMissing": "{missing} {type} sélectionnés sans lien Civitai ignorés",
|
||||||
|
"bulkUpdatesFailed": "Échec de la vérification des mises à jour pour les {type} sélectionnés : {message}",
|
||||||
"invalidCharactersRemoved": "Caractères invalides supprimés du nom de fichier",
|
"invalidCharactersRemoved": "Caractères invalides supprimés du nom de fichier",
|
||||||
"filenameCannotBeEmpty": "Le nom de fichier ne peut pas être vide",
|
"filenameCannotBeEmpty": "Le nom de fichier ne peut pas être vide",
|
||||||
"renameFailed": "Échec du renommage du fichier : {message}",
|
"renameFailed": "Échec du renommage du fichier : {message}",
|
||||||
@@ -1051,6 +1340,8 @@
|
|||||||
"compactModeToggled": "Mode compact {state}",
|
"compactModeToggled": "Mode compact {state}",
|
||||||
"settingSaveFailed": "Échec de la sauvegarde du paramètre : {message}",
|
"settingSaveFailed": "Échec de la sauvegarde du paramètre : {message}",
|
||||||
"displayDensitySet": "Densité d'affichage définie sur {density}",
|
"displayDensitySet": "Densité d'affichage définie sur {density}",
|
||||||
|
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||||
|
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||||
"languageChangeFailed": "Échec du changement de langue : {message}",
|
"languageChangeFailed": "Échec du changement de langue : {message}",
|
||||||
"cacheCleared": "Les fichiers de cache ont été vidés avec succès. Le cache sera reconstruit à la prochaine action.",
|
"cacheCleared": "Les fichiers de cache ont été vidés avec succès. Le cache sera reconstruit à la prochaine action.",
|
||||||
"cacheClearFailed": "Échec du vidage du cache : {error}",
|
"cacheClearFailed": "Échec du vidage du cache : {error}",
|
||||||
@@ -1075,7 +1366,7 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "Impossible de charger les mots entraînés",
|
"loadFailed": "Impossible de charger les mots entraînés",
|
||||||
"tooLong": "Le mot-clé ne doit pas dépasser 30 mots",
|
"tooLong": "Le mot-clé ne doit pas dépasser 100 mots",
|
||||||
"tooMany": "Maximum 30 mots-clés autorisés",
|
"tooMany": "Maximum 30 mots-clés autorisés",
|
||||||
"alreadyExists": "Ce mot-clé existe déjà",
|
"alreadyExists": "Ce mot-clé existe déjà",
|
||||||
"updateSuccess": "Mots-clés mis à jour avec succès",
|
"updateSuccess": "Mots-clés mis à jour avec succès",
|
||||||
@@ -1115,6 +1406,7 @@
|
|||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"pathUpdated": "Chemin des images d'exemple mis à jour avec succès",
|
"pathUpdated": "Chemin des images d'exemple mis à jour avec succès",
|
||||||
|
"pathUpdateFailed": "Échec de la mise à jour du chemin des images d'exemple : {message}",
|
||||||
"downloadInProgress": "Téléchargement déjà en cours",
|
"downloadInProgress": "Téléchargement déjà en cours",
|
||||||
"enterLocationFirst": "Veuillez d'abord entrer un emplacement de téléchargement",
|
"enterLocationFirst": "Veuillez d'abord entrer un emplacement de téléchargement",
|
||||||
"downloadStarted": "Téléchargement des images d'exemple démarré",
|
"downloadStarted": "Téléchargement des images d'exemple démarré",
|
||||||
@@ -1123,6 +1415,8 @@
|
|||||||
"pauseFailed": "Échec de la mise en pause du téléchargement : {error}",
|
"pauseFailed": "Échec de la mise en pause du téléchargement : {error}",
|
||||||
"downloadResumed": "Téléchargement repris",
|
"downloadResumed": "Téléchargement repris",
|
||||||
"resumeFailed": "Échec de la reprise du téléchargement : {error}",
|
"resumeFailed": "Échec de la reprise du téléchargement : {error}",
|
||||||
|
"downloadStopped": "Téléchargement annulé",
|
||||||
|
"stopFailed": "Échec de l'annulation du téléchargement : {error}",
|
||||||
"deleted": "Image d'exemple supprimée",
|
"deleted": "Image d'exemple supprimée",
|
||||||
"deleteFailed": "Échec de la suppression de l'image d'exemple",
|
"deleteFailed": "Échec de la suppression de l'image d'exemple",
|
||||||
"setPreviewFailed": "Échec de la définition de l'image d'aperçu"
|
"setPreviewFailed": "Échec de la définition de l'image d'aperçu"
|
||||||
@@ -1169,6 +1463,12 @@
|
|||||||
"refreshNow": "Actualiser maintenant",
|
"refreshNow": "Actualiser maintenant",
|
||||||
"refreshingIn": "Actualisation dans",
|
"refreshingIn": "Actualisation dans",
|
||||||
"seconds": "secondes"
|
"seconds": "secondes"
|
||||||
|
},
|
||||||
|
"communitySupport": {
|
||||||
|
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||||
|
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||||
|
"supportCta": "Support on Ko-fi",
|
||||||
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1474
locales/he.json
Normal file
1474
locales/he.json
Normal file
File diff suppressed because it is too large
Load Diff
346
locales/ja.json
346
locales/ja.json
@@ -16,7 +16,9 @@
|
|||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
"unknown": "不明",
|
"unknown": "不明",
|
||||||
"date": "日付",
|
"date": "日付",
|
||||||
"version": "バージョン"
|
"version": "バージョン",
|
||||||
|
"enabled": "有効",
|
||||||
|
"disabled": "無効"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"select": "言語",
|
"select": "言語",
|
||||||
@@ -29,7 +31,8 @@
|
|||||||
"japanese": "日本語",
|
"japanese": "日本語",
|
||||||
"korean": "한국어",
|
"korean": "한국어",
|
||||||
"french": "Français",
|
"french": "Français",
|
||||||
"spanish": "Español"
|
"spanish": "Español",
|
||||||
|
"Hebrew": "עברית"
|
||||||
},
|
},
|
||||||
"fileSize": {
|
"fileSize": {
|
||||||
"zero": "0バイト",
|
"zero": "0バイト",
|
||||||
@@ -98,7 +101,12 @@
|
|||||||
"checkpointNameCopied": "checkpointの名前をコピーしました",
|
"checkpointNameCopied": "checkpointの名前をコピーしました",
|
||||||
"toggleBlur": "ぼかしの切り替え",
|
"toggleBlur": "ぼかしの切り替え",
|
||||||
"show": "表示",
|
"show": "表示",
|
||||||
"openExampleImages": "例画像フォルダを開く"
|
"openExampleImages": "例画像フォルダを開く",
|
||||||
|
"replacePreview": "プレビューを置換",
|
||||||
|
"copyCheckpointName": "checkpoint名をコピー",
|
||||||
|
"copyEmbeddingName": "embedding名をコピー",
|
||||||
|
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
||||||
|
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
||||||
},
|
},
|
||||||
"nsfw": {
|
"nsfw": {
|
||||||
"matureContent": "成人向けコンテンツ",
|
"matureContent": "成人向けコンテンツ",
|
||||||
@@ -112,12 +120,45 @@
|
|||||||
"updateFailed": "お気に入り状態の更新に失敗しました"
|
"updateFailed": "お気に入り状態の更新に失敗しました"
|
||||||
},
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"checkpointNotImplemented": "checkpointをワークフローに送信 - 実装予定の機能"
|
"checkpointNotImplemented": "checkpointをワークフローに送信 - 実装予定の機能",
|
||||||
|
"missingPath": "このカードのモデルパスを特定できません"
|
||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"checkError": "例画像の確認中にエラーが発生しました",
|
"checkError": "例画像の確認中にエラーが発生しました",
|
||||||
"missingHash": "モデルハッシュ情報がありません。",
|
"missingHash": "モデルハッシュ情報がありません。",
|
||||||
"noRemoteImagesAvailable": "このモデルのCivitaiでのリモート例画像は利用できません"
|
"noRemoteImagesAvailable": "このモデルのCivitaiでのリモート例画像は利用できません"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"update": "アップデート",
|
||||||
|
"updateAvailable": "アップデートがあります"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"globalContextMenu": {
|
||||||
|
"downloadExampleImages": {
|
||||||
|
"label": "例画像をダウンロード",
|
||||||
|
"missingPath": "例画像をダウンロードする前にダウンロード場所を設定してください。",
|
||||||
|
"unavailable": "例画像のダウンロードはまだ利用できません。ページの読み込みが完了してから再度お試しください。"
|
||||||
|
},
|
||||||
|
"checkModelUpdates": {
|
||||||
|
"label": "アップデートを確認",
|
||||||
|
"loading": "{type} のアップデートを確認中…",
|
||||||
|
"success": "{type} のアップデートが {count} 件見つかりました",
|
||||||
|
"none": "すべての {type} は最新です",
|
||||||
|
"error": "{type} のアップデート確認に失敗しました: {message}"
|
||||||
|
},
|
||||||
|
"cleanupExampleImages": {
|
||||||
|
"label": "例画像フォルダをクリーンアップ",
|
||||||
|
"success": "{count} 個のフォルダを削除フォルダに移動しました",
|
||||||
|
"none": "クリーンアップが必要な例画像フォルダはありません",
|
||||||
|
"partial": "クリーンアップが完了しましたが、{failures} 個のフォルダはスキップされました",
|
||||||
|
"error": "例画像フォルダのクリーンアップに失敗しました:{message}"
|
||||||
|
},
|
||||||
|
"fetchMissingLicenses": {
|
||||||
|
"label": "Refresh license metadata",
|
||||||
|
"loading": "Refreshing license metadata for {typePlural}...",
|
||||||
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
|
"none": "All {typePlural} already have license metadata",
|
||||||
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -154,6 +195,10 @@
|
|||||||
"title": "モデルをフィルタ",
|
"title": "モデルをフィルタ",
|
||||||
"baseModel": "ベースモデル",
|
"baseModel": "ベースモデル",
|
||||||
"modelTags": "タグ(上位20)",
|
"modelTags": "タグ(上位20)",
|
||||||
|
"modelTypes": "Model Types",
|
||||||
|
"license": "ライセンス",
|
||||||
|
"noCreditRequired": "クレジット不要",
|
||||||
|
"allowSellingGeneratedContent": "販売許可",
|
||||||
"clearAll": "すべてのフィルタをクリア"
|
"clearAll": "すべてのフィルタをクリア"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -164,6 +209,7 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"checkUpdates": "更新確認",
|
"checkUpdates": "更新確認",
|
||||||
|
"notifications": "通知",
|
||||||
"support": "サポート"
|
"support": "サポート"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -171,14 +217,30 @@
|
|||||||
"civitaiApiKey": "Civitai APIキー",
|
"civitaiApiKey": "Civitai APIキー",
|
||||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||||
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
||||||
|
"openSettingsFileLocation": {
|
||||||
|
"label": "設定フォルダーを開く",
|
||||||
|
"tooltip": "settings.json を含むフォルダーを開きます",
|
||||||
|
"success": "settings.json フォルダーを開きました",
|
||||||
|
"failed": "settings.json フォルダーを開けませんでした"
|
||||||
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "コンテンツフィルタリング",
|
"contentFiltering": "コンテンツフィルタリング",
|
||||||
"videoSettings": "動画設定",
|
"videoSettings": "動画設定",
|
||||||
"layoutSettings": "レイアウト設定",
|
"layoutSettings": "レイアウト設定",
|
||||||
"folderSettings": "フォルダ設定",
|
"folderSettings": "フォルダ設定",
|
||||||
|
"priorityTags": "優先タグ",
|
||||||
"downloadPathTemplates": "ダウンロードパステンプレート",
|
"downloadPathTemplates": "ダウンロードパステンプレート",
|
||||||
"exampleImages": "例画像",
|
"exampleImages": "例画像",
|
||||||
"misc": "その他"
|
"updateFlags": "アップデートフラグ",
|
||||||
|
"autoOrganize": "Auto-organize",
|
||||||
|
"misc": "その他",
|
||||||
|
"metadataArchive": "メタデータアーカイブデータベース",
|
||||||
|
"storageLocation": "設定の場所",
|
||||||
|
"proxySettings": "プロキシ設定"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"locationLabel": "ポータブルモード",
|
||||||
|
"locationHelp": "有効にすると settings.json をリポジトリ内に保持し、無効にするとユーザー設定ディレクトリに格納します。"
|
||||||
},
|
},
|
||||||
"contentFiltering": {
|
"contentFiltering": {
|
||||||
"blurNsfwContent": "NSFWコンテンツをぼかす",
|
"blurNsfwContent": "NSFWコンテンツをぼかす",
|
||||||
@@ -190,6 +252,15 @@
|
|||||||
"autoplayOnHover": "ホバー時に動画を自動再生",
|
"autoplayOnHover": "ホバー時に動画を自動再生",
|
||||||
"autoplayOnHoverHelp": "動画プレビューはホバー時にのみ再生されます"
|
"autoplayOnHoverHelp": "動画プレビューはホバー時にのみ再生されます"
|
||||||
},
|
},
|
||||||
|
"autoOrganizeExclusions": {
|
||||||
|
"label": "自動整理除外設定",
|
||||||
|
"placeholder": "例: curated/*, */backups/*; *_temp.safetensors",
|
||||||
|
"help": "これらのワイルドカードパターンに一致するファイルの移動をスキップします。複数のパターンはカンマまたはセミコロンで区切ってください。",
|
||||||
|
"validation": {
|
||||||
|
"noPatterns": "カンマまたはセミコロンで区切られた少なくとも1つのパターンを入力してください。",
|
||||||
|
"saveFailed": "除外設定を保存できませんでした: {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "表示密度",
|
"displayDensity": "表示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -199,23 +270,37 @@
|
|||||||
},
|
},
|
||||||
"displayDensityHelp": "1行に表示するカード数を選択:",
|
"displayDensityHelp": "1行に表示するカード数を選択:",
|
||||||
"displayDensityDetails": {
|
"displayDensityDetails": {
|
||||||
"default": "デフォルト:5(1080p)、6(2K)、8(4K)",
|
"default": "5(1080p)、6(2K)、8(4K)",
|
||||||
"medium": "中:6(1080p)、7(2K)、9(4K)",
|
"medium": "6(1080p)、7(2K)、9(4K)",
|
||||||
"compact": "コンパクト:7(1080p)、8(2K)、10(4K)"
|
"compact": "7(1080p)、8(2K)、10(4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。",
|
"displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。",
|
||||||
|
"showFolderSidebar": "フォルダサイドバーを表示",
|
||||||
|
"showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。",
|
||||||
"cardInfoDisplay": "カード情報表示",
|
"cardInfoDisplay": "カード情報表示",
|
||||||
"cardInfoDisplayOptions": {
|
"cardInfoDisplayOptions": {
|
||||||
"always": "常に表示",
|
"always": "常に表示",
|
||||||
"hover": "ホバー時に表示"
|
"hover": "ホバー時に表示"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択:",
|
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
||||||
"cardInfoDisplayDetails": {
|
"modelCardFooterAction": "モデルカードボタンのアクション",
|
||||||
"always": "常に表示:ヘッダーとフッターが常に表示されます",
|
"modelCardFooterActionOptions": {
|
||||||
"hover": "ホバー時に表示:カードにホバーしたときのみヘッダーとフッターが表示されます"
|
"exampleImages": "例画像を開く",
|
||||||
}
|
"replacePreview": "プレビューを置換"
|
||||||
|
},
|
||||||
|
"modelCardFooterActionHelp": "カード右下のボタンが何をするかを選択します",
|
||||||
|
"modelNameDisplay": "モデル名表示",
|
||||||
|
"modelNameDisplayOptions": {
|
||||||
|
"modelName": "モデル名",
|
||||||
|
"fileName": "ファイル名"
|
||||||
|
},
|
||||||
|
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択"
|
||||||
},
|
},
|
||||||
"folderSettings": {
|
"folderSettings": {
|
||||||
|
"activeLibrary": "アクティブライブラリ",
|
||||||
|
"activeLibraryHelp": "設定済みのライブラリを切り替えてデフォルトのフォルダを更新します。選択を変更するとページが再読み込みされます。",
|
||||||
|
"loadingLibraries": "ライブラリを読み込み中...",
|
||||||
|
"noLibraries": "ライブラリが設定されていません",
|
||||||
"defaultLoraRoot": "デフォルトLoRAルート",
|
"defaultLoraRoot": "デフォルトLoRAルート",
|
||||||
"defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定",
|
"defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定",
|
||||||
"defaultCheckpointRoot": "デフォルトCheckpointルート",
|
"defaultCheckpointRoot": "デフォルトCheckpointルート",
|
||||||
@@ -224,6 +309,26 @@
|
|||||||
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
||||||
"noDefault": "デフォルトなし"
|
"noDefault": "デフォルトなし"
|
||||||
},
|
},
|
||||||
|
"priorityTags": {
|
||||||
|
"title": "優先タグ",
|
||||||
|
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
|
||||||
|
"placeholder": "character, concept, style(toon|toon_style)",
|
||||||
|
"helpLinkLabel": "優先タグのヘルプを開く",
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"checkpoint": "チェックポイント",
|
||||||
|
"embedding": "埋め込み"
|
||||||
|
},
|
||||||
|
"saveSuccess": "優先タグを更新しました。",
|
||||||
|
"saveError": "優先タグの更新に失敗しました。",
|
||||||
|
"loadingSuggestions": "候補を読み込み中...",
|
||||||
|
"validation": {
|
||||||
|
"missingClosingParen": "エントリ {index} に閉じ括弧がありません。",
|
||||||
|
"missingCanonical": "エントリ {index} には正規タグ名を含める必要があります。",
|
||||||
|
"duplicateCanonical": "正規タグ \"{tag}\" が複数回登場しています。",
|
||||||
|
"unknown": "無効な優先タグ設定です。"
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadPathTemplates": {
|
"downloadPathTemplates": {
|
||||||
"title": "ダウンロードパステンプレート",
|
"title": "ダウンロードパステンプレート",
|
||||||
"help": "Civitaiからダウンロードする際の異なるモデルタイプのフォルダ構造を設定します。",
|
"help": "Civitaiからダウンロードする際の異なるモデルタイプのフォルダ構造を設定します。",
|
||||||
@@ -236,6 +341,7 @@
|
|||||||
"baseModelFirstTag": "ベースモデル + 最初のタグ",
|
"baseModelFirstTag": "ベースモデル + 最初のタグ",
|
||||||
"baseModelAuthor": "ベースモデル + 作成者",
|
"baseModelAuthor": "ベースモデル + 作成者",
|
||||||
"authorFirstTag": "作成者 + 最初のタグ",
|
"authorFirstTag": "作成者 + 最初のタグ",
|
||||||
|
"baseModelAuthorFirstTag": "ベースモデル + 作成者 + 最初のタグ",
|
||||||
"customTemplate": "カスタムテンプレート"
|
"customTemplate": "カスタムテンプレート"
|
||||||
},
|
},
|
||||||
"customTemplatePlaceholder": "カスタムテンプレートを入力(例:{base_model}/{author}/{first_tag})",
|
"customTemplatePlaceholder": "カスタムテンプレートを入力(例:{base_model}/{author}/{first_tag})",
|
||||||
@@ -270,9 +376,59 @@
|
|||||||
"download": "ダウンロード",
|
"download": "ダウンロード",
|
||||||
"restartRequired": "再起動が必要"
|
"restartRequired": "再起動が必要"
|
||||||
},
|
},
|
||||||
|
"updateFlagStrategy": {
|
||||||
|
"label": "アップデートフラグの表示戦略",
|
||||||
|
"help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。",
|
||||||
|
"options": {
|
||||||
|
"sameBase": "ベースモデルで更新をマッチ",
|
||||||
|
"any": "利用可能な更新すべてを表示"
|
||||||
|
}
|
||||||
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
|
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
|
||||||
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます"
|
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます"
|
||||||
|
},
|
||||||
|
"metadataArchive": {
|
||||||
|
"enableArchiveDb": "メタデータアーカイブデータベースを有効化",
|
||||||
|
"enableArchiveDbHelp": "Civitaiから削除されたモデルのメタデータにアクセスするためにローカルデータベースを使用します。",
|
||||||
|
"status": "ステータス",
|
||||||
|
"statusAvailable": "利用可能",
|
||||||
|
"statusUnavailable": "利用不可",
|
||||||
|
"enabled": "有効",
|
||||||
|
"management": "データベース管理",
|
||||||
|
"managementHelp": "メタデータアーカイブデータベースのダウンロードまたは削除",
|
||||||
|
"downloadButton": "データベースをダウンロード",
|
||||||
|
"downloadingButton": "ダウンロード中...",
|
||||||
|
"downloadedButton": "ダウンロード済み",
|
||||||
|
"removeButton": "データベースを削除",
|
||||||
|
"removingButton": "削除中...",
|
||||||
|
"downloadSuccess": "メタデータアーカイブデータベースのダウンロードが完了しました",
|
||||||
|
"downloadError": "メタデータアーカイブデータベースのダウンロードに失敗しました",
|
||||||
|
"removeSuccess": "メタデータアーカイブデータベースが削除されました",
|
||||||
|
"removeError": "メタデータアーカイブデータベースの削除に失敗しました",
|
||||||
|
"removeConfirm": "本当にメタデータアーカイブデータベースを削除しますか?ローカルのデータベースファイルが削除され、この機能を再度利用するには再ダウンロードが必要です。",
|
||||||
|
"preparing": "ダウンロードを準備中...",
|
||||||
|
"connecting": "ダウンロードサーバーに接続中...",
|
||||||
|
"completed": "完了",
|
||||||
|
"downloadComplete": "ダウンロードが正常に完了しました"
|
||||||
|
},
|
||||||
|
"proxySettings": {
|
||||||
|
"enableProxy": "アプリレベルのプロキシを有効化",
|
||||||
|
"enableProxyHelp": "このアプリケーション専用のカスタムプロキシ設定を有効にします(システムのプロキシ設定を上書きします)",
|
||||||
|
"proxyType": "プロキシタイプ",
|
||||||
|
"proxyTypeHelp": "プロキシサーバーの種類を選択(HTTP、HTTPS、SOCKS4、SOCKS5)",
|
||||||
|
"proxyHost": "プロキシホスト",
|
||||||
|
"proxyHostPlaceholder": "proxy.example.com",
|
||||||
|
"proxyHostHelp": "プロキシサーバーのホスト名またはIPアドレス",
|
||||||
|
"proxyPort": "プロキシポート",
|
||||||
|
"proxyPortPlaceholder": "8080",
|
||||||
|
"proxyPortHelp": "プロキシサーバーのポート番号",
|
||||||
|
"proxyUsername": "ユーザー名(任意)",
|
||||||
|
"proxyUsernamePlaceholder": "ユーザー名",
|
||||||
|
"proxyUsernameHelp": "プロキシ認証用のユーザー名(必要な場合)",
|
||||||
|
"proxyPassword": "パスワード(任意)",
|
||||||
|
"proxyPasswordPlaceholder": "パスワード",
|
||||||
|
"proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"loras": {
|
"loras": {
|
||||||
@@ -291,8 +447,10 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "モデルリストを更新",
|
"title": "モデルリストを更新",
|
||||||
"quick": "クイック更新(増分)",
|
"quick": "変更を同期",
|
||||||
"full": "完全再構築(完全)"
|
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
|
||||||
|
"full": "キャッシュを再構築",
|
||||||
|
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
||||||
},
|
},
|
||||||
"fetch": {
|
"fetch": {
|
||||||
"title": "Civitaiからメタデータを取得",
|
"title": "Civitaiからメタデータを取得",
|
||||||
@@ -313,6 +471,13 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "お気に入りのみ表示",
|
"title": "お気に入りのみ表示",
|
||||||
"action": "お気に入り"
|
"action": "お気に入り"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"title": "アップデート可能なモデルのみ表示",
|
||||||
|
"action": "アップデート",
|
||||||
|
"menuLabel": "更新オプションを表示",
|
||||||
|
"check": "アップデートを確認",
|
||||||
|
"checkTooltip": "確認には時間がかかる場合があります。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bulkOperations": {
|
"bulkOperations": {
|
||||||
@@ -321,8 +486,10 @@
|
|||||||
"viewSelected": "選択中を表示",
|
"viewSelected": "選択中を表示",
|
||||||
"addTags": "すべてにタグを追加",
|
"addTags": "すべてにタグを追加",
|
||||||
"setBaseModel": "すべてにベースモデルを設定",
|
"setBaseModel": "すべてにベースモデルを設定",
|
||||||
|
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
||||||
"copyAll": "すべての構文をコピー",
|
"copyAll": "すべての構文をコピー",
|
||||||
"refreshAll": "すべてのメタデータを更新",
|
"refreshAll": "すべてのメタデータを更新",
|
||||||
|
"checkUpdates": "選択項目の更新を確認",
|
||||||
"moveAll": "すべてをフォルダに移動",
|
"moveAll": "すべてをフォルダに移動",
|
||||||
"autoOrganize": "自動整理を実行",
|
"autoOrganize": "自動整理を実行",
|
||||||
"deleteAll": "すべてのモデルを削除",
|
"deleteAll": "すべてのモデルを削除",
|
||||||
@@ -339,6 +506,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "Civitaiデータを更新",
|
"refreshMetadata": "Civitaiデータを更新",
|
||||||
|
"checkUpdates": "更新確認",
|
||||||
"relinkCivitai": "Civitaiに再リンク",
|
"relinkCivitai": "Civitaiに再リンク",
|
||||||
"copySyntax": "LoRA構文をコピー",
|
"copySyntax": "LoRA構文をコピー",
|
||||||
"copyFilename": "モデルファイル名をコピー",
|
"copyFilename": "モデルファイル名をコピー",
|
||||||
@@ -360,6 +528,9 @@
|
|||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRAレシピ",
|
"title": "LoRAレシピ",
|
||||||
|
"actions": {
|
||||||
|
"sendCheckpoint": "ComfyUIへ送信"
|
||||||
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "インポート",
|
"action": "インポート",
|
||||||
@@ -456,13 +627,19 @@
|
|||||||
"title": "Embeddingモデル"
|
"title": "Embeddingモデル"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "モデルルート",
|
"modelRoot": "ルート",
|
||||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||||
"pinSidebar": "サイドバーを固定",
|
"pinSidebar": "サイドバーを固定",
|
||||||
"unpinSidebar": "サイドバーの固定を解除",
|
"unpinSidebar": "サイドバーの固定を解除",
|
||||||
"switchToListView": "リストビューに切り替え",
|
"switchToListView": "リストビューに切り替え",
|
||||||
"switchToTreeView": "ツリービューに切り替え",
|
"switchToTreeView": "ツリー表示に切り替え",
|
||||||
"collapseAllDisabled": "リストビューでは利用できません"
|
"recursiveOn": "サブフォルダーを検索",
|
||||||
|
"recursiveOff": "現在のフォルダーのみを検索",
|
||||||
|
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
||||||
|
"collapseAllDisabled": "リストビューでは利用できません",
|
||||||
|
"dragDrop": {
|
||||||
|
"unableToResolveRoot": "移動先のパスを特定できません。"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"title": "統計",
|
"title": "統計",
|
||||||
@@ -537,6 +714,14 @@
|
|||||||
"downloadedPreview": "プレビュー画像をダウンロードしました",
|
"downloadedPreview": "プレビュー画像をダウンロードしました",
|
||||||
"downloadingFile": "{type}ファイルをダウンロード中",
|
"downloadingFile": "{type}ファイルをダウンロード中",
|
||||||
"finalizing": "ダウンロードを完了中..."
|
"finalizing": "ダウンロードを完了中..."
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"currentFile": "現在のファイル:",
|
||||||
|
"downloading": "ダウンロード中: {name}",
|
||||||
|
"transferred": "ダウンロード済み: {downloaded} / {total}",
|
||||||
|
"transferredSimple": "ダウンロード済み: {downloaded}",
|
||||||
|
"transferredUnknown": "ダウンロード済み: --",
|
||||||
|
"speed": "速度: {speed}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
@@ -545,6 +730,7 @@
|
|||||||
"contentRating": {
|
"contentRating": {
|
||||||
"title": "コンテンツレーティングを設定",
|
"title": "コンテンツレーティングを設定",
|
||||||
"current": "現在",
|
"current": "現在",
|
||||||
|
"multiple": "複数の値",
|
||||||
"levels": {
|
"levels": {
|
||||||
"pg": "PG",
|
"pg": "PG",
|
||||||
"pg13": "PG13",
|
"pg13": "PG13",
|
||||||
@@ -583,6 +769,12 @@
|
|||||||
"countMessage": "モデルが完全に削除されます。",
|
"countMessage": "モデルが完全に削除されます。",
|
||||||
"action": "すべて削除"
|
"action": "すべて削除"
|
||||||
},
|
},
|
||||||
|
"checkUpdates": {
|
||||||
|
"title": "すべての{type}の更新を確認しますか?",
|
||||||
|
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
||||||
|
"tip": "少しずつ確認したい場合はバルクモードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
|
||||||
|
"action": "すべて確認"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "複数モデルにタグを追加",
|
"title": "複数モデルにタグを追加",
|
||||||
"description": "タグを追加するモデル:",
|
"description": "タグを追加するモデル:",
|
||||||
@@ -651,7 +843,12 @@
|
|||||||
"editBaseModel": "ベースモデルを編集",
|
"editBaseModel": "ベースモデルを編集",
|
||||||
"viewOnCivitai": "Civitaiで表示",
|
"viewOnCivitai": "Civitaiで表示",
|
||||||
"viewOnCivitaiText": "Civitaiで表示",
|
"viewOnCivitaiText": "Civitaiで表示",
|
||||||
"viewCreatorProfile": "作成者プロフィールを表示"
|
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||||
|
"openFileLocation": "ファイルの場所を開く"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "ファイルの場所を正常に開きました",
|
||||||
|
"failed": "ファイルの場所を開くのに失敗しました"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "バージョン",
|
"version": "バージョン",
|
||||||
@@ -675,6 +872,7 @@
|
|||||||
"strengthMin": "強度最小",
|
"strengthMin": "強度最小",
|
||||||
"strengthMax": "強度最大",
|
"strengthMax": "強度最大",
|
||||||
"strength": "強度",
|
"strength": "強度",
|
||||||
|
"clipStrength": "クリップ強度",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "値",
|
"valuePlaceholder": "値",
|
||||||
"add": "追加"
|
"add": "追加"
|
||||||
@@ -713,13 +911,77 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"examples": "例",
|
"examples": "例",
|
||||||
"description": "モデル説明",
|
"description": "モデル説明",
|
||||||
"recipes": "レシピ"
|
"recipes": "レシピ",
|
||||||
|
"versions": "バージョン"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"noImageSell": "No selling generated content",
|
||||||
|
"noRentCivit": "No Civitai generation",
|
||||||
|
"noRent": "No generation services",
|
||||||
|
"noSell": "No selling models",
|
||||||
|
"creditRequired": "作成者のクレジットが必要",
|
||||||
|
"noDerivatives": "共有マージ不可",
|
||||||
|
"noReLicense": "同じ権限が必要",
|
||||||
|
"restrictionsLabel": "ライセンス制限"
|
||||||
},
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"exampleImages": "例画像を読み込み中...",
|
"exampleImages": "例画像を読み込み中...",
|
||||||
"description": "モデル説明を読み込み中...",
|
"description": "モデル説明を読み込み中...",
|
||||||
"recipes": "レシピを読み込み中...",
|
"recipes": "レシピを読み込み中...",
|
||||||
"examples": "例を読み込み中..."
|
"examples": "例を読み込み中...",
|
||||||
|
"versions": "バージョンを読み込み中..."
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"heading": "モデルバージョン",
|
||||||
|
"copy": "このモデルのすべてのバージョンを一か所で管理します。",
|
||||||
|
"media": {
|
||||||
|
"placeholder": "プレビューなし"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"unnamed": "名前のないバージョン",
|
||||||
|
"noDetails": "追加情報なし"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "現在のバージョン",
|
||||||
|
"inLibrary": "ライブラリにあります",
|
||||||
|
"newer": "新しいバージョン",
|
||||||
|
"ignored": "無視中"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"download": "ダウンロード",
|
||||||
|
"delete": "削除",
|
||||||
|
"ignore": "無視",
|
||||||
|
"unignore": "無視を解除",
|
||||||
|
"resumeModelUpdates": "このモデルの更新を再開",
|
||||||
|
"ignoreModelUpdates": "このモデルの更新を無視",
|
||||||
|
"viewLocalVersions": "ローカルの全バージョンを表示",
|
||||||
|
"viewLocalTooltip": "近日対応予定"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"label": "ベースフィルター",
|
||||||
|
"state": {
|
||||||
|
"showAll": "すべてのバージョン",
|
||||||
|
"showSameBase": "同じベース"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"showAllVersions": "すべてのバージョンを表示する",
|
||||||
|
"showSameBaseVersions": "同じベースモデルのバージョンのみ表示する"
|
||||||
|
},
|
||||||
|
"empty": "現在のベースモデルフィルターに一致するバージョンがありません。"
|
||||||
|
},
|
||||||
|
"empty": "このモデルにはまだバージョン履歴がありません。",
|
||||||
|
"error": "バージョンの読み込みに失敗しました。",
|
||||||
|
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。",
|
||||||
|
"confirm": {
|
||||||
|
"delete": "このバージョンをライブラリから削除しますか?"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"modelIgnored": "このモデルの更新は無視されます",
|
||||||
|
"modelResumed": "更新の監視を再開しました",
|
||||||
|
"versionIgnored": "このバージョンの更新は無視されます",
|
||||||
|
"versionUnignored": "バージョンを再度有効にしました",
|
||||||
|
"versionDeleted": "バージョンを削除しました"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -826,7 +1088,9 @@
|
|||||||
"loraFailedToSend": "LoRAをワークフローに送信できませんでした",
|
"loraFailedToSend": "LoRAをワークフローに送信できませんでした",
|
||||||
"recipeAdded": "レシピがワークフローに追加されました",
|
"recipeAdded": "レシピがワークフローに追加されました",
|
||||||
"recipeReplaced": "レシピがワークフローで置換されました",
|
"recipeReplaced": "レシピがワークフローで置換されました",
|
||||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした"
|
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
||||||
|
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||||
|
"noTargetNodeSelected": "ターゲットノードが選択されていません"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "レシピ",
|
"recipe": "レシピ",
|
||||||
@@ -871,6 +1135,11 @@
|
|||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "更新確認",
|
"title": "更新確認",
|
||||||
|
"notificationsTitle": "通知センター",
|
||||||
|
"tabs": {
|
||||||
|
"updates": "更新",
|
||||||
|
"messages": "メッセージ"
|
||||||
|
},
|
||||||
"updateAvailable": "更新が利用可能",
|
"updateAvailable": "更新が利用可能",
|
||||||
"noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。",
|
"noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。",
|
||||||
"currentVersion": "現在のバージョン",
|
"currentVersion": "現在のバージョン",
|
||||||
@@ -902,6 +1171,13 @@
|
|||||||
"nightly": {
|
"nightly": {
|
||||||
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
|
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
|
||||||
"enable": "ナイトリー更新を有効にする"
|
"enable": "ナイトリー更新を有効にする"
|
||||||
|
},
|
||||||
|
"banners": {
|
||||||
|
"recent": "最近の通知",
|
||||||
|
"empty": "最近のバナーはありません。",
|
||||||
|
"shown": "{time} に表示",
|
||||||
|
"dismissed": "{time} に非表示",
|
||||||
|
"active": "アクティブ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
@@ -981,6 +1257,9 @@
|
|||||||
"cannotSend": "レシピを送信できません:レシピIDがありません",
|
"cannotSend": "レシピを送信できません:レシピIDがありません",
|
||||||
"sendFailed": "レシピのワークフローへの送信に失敗しました",
|
"sendFailed": "レシピのワークフローへの送信に失敗しました",
|
||||||
"sendError": "レシピのワークフロー送信エラー",
|
"sendError": "レシピのワークフロー送信エラー",
|
||||||
|
"missingCheckpointPath": "チェックポイントのパスがありません",
|
||||||
|
"missingCheckpointInfo": "チェックポイント情報が不足しています",
|
||||||
|
"downloadCheckpointFailed": "チェックポイントのダウンロードに失敗しました: {message}",
|
||||||
"cannotDelete": "レシピを削除できません:レシピIDがありません",
|
"cannotDelete": "レシピを削除できません:レシピIDがありません",
|
||||||
"deleteConfirmationError": "削除確認の表示中にエラーが発生しました",
|
"deleteConfirmationError": "削除確認の表示中にエラーが発生しました",
|
||||||
"deletedSuccessfully": "レシピが正常に削除されました",
|
"deletedSuccessfully": "レシピが正常に削除されました",
|
||||||
@@ -1017,6 +1296,16 @@
|
|||||||
"bulkBaseModelUpdateSuccess": "{count} モデルのベースモデルが正常に更新されました",
|
"bulkBaseModelUpdateSuccess": "{count} モデルのベースモデルが正常に更新されました",
|
||||||
"bulkBaseModelUpdatePartial": "{success} モデルを更新、{failed} モデルは失敗しました",
|
"bulkBaseModelUpdatePartial": "{success} モデルを更新、{failed} モデルは失敗しました",
|
||||||
"bulkBaseModelUpdateFailed": "選択したモデルのベースモデルの更新に失敗しました",
|
"bulkBaseModelUpdateFailed": "選択したモデルのベースモデルの更新に失敗しました",
|
||||||
|
"bulkContentRatingUpdating": "{count} 件のモデルのコンテンツレーティングを更新中...",
|
||||||
|
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
|
||||||
|
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
|
||||||
|
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
|
||||||
|
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
|
||||||
|
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
|
||||||
|
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
|
||||||
|
"bulkUpdatesMissing": "選択された{type}はCivitaiの更新にリンクされていません",
|
||||||
|
"bulkUpdatesPartialMissing": "Civitaiリンクがない{missing} 件の{type}をスキップしました",
|
||||||
|
"bulkUpdatesFailed": "選択された{type}の更新確認に失敗しました: {message}",
|
||||||
"invalidCharactersRemoved": "ファイル名から無効な文字が削除されました",
|
"invalidCharactersRemoved": "ファイル名から無効な文字が削除されました",
|
||||||
"filenameCannotBeEmpty": "ファイル名を空にすることはできません",
|
"filenameCannotBeEmpty": "ファイル名を空にすることはできません",
|
||||||
"renameFailed": "ファイル名の変更に失敗しました:{message}",
|
"renameFailed": "ファイル名の変更に失敗しました:{message}",
|
||||||
@@ -1051,6 +1340,8 @@
|
|||||||
"compactModeToggled": "コンパクトモード {state}",
|
"compactModeToggled": "コンパクトモード {state}",
|
||||||
"settingSaveFailed": "設定の保存に失敗しました:{message}",
|
"settingSaveFailed": "設定の保存に失敗しました:{message}",
|
||||||
"displayDensitySet": "表示密度が {density} に設定されました",
|
"displayDensitySet": "表示密度が {density} に設定されました",
|
||||||
|
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||||
|
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||||
"languageChangeFailed": "言語の変更に失敗しました:{message}",
|
"languageChangeFailed": "言語の変更に失敗しました:{message}",
|
||||||
"cacheCleared": "キャッシュファイルが正常にクリアされました。次回のアクションでキャッシュが再構築されます。",
|
"cacheCleared": "キャッシュファイルが正常にクリアされました。次回のアクションでキャッシュが再構築されます。",
|
||||||
"cacheClearFailed": "キャッシュのクリアに失敗しました:{error}",
|
"cacheClearFailed": "キャッシュのクリアに失敗しました:{error}",
|
||||||
@@ -1075,7 +1366,7 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "学習済みワードを読み込めませんでした",
|
"loadFailed": "学習済みワードを読み込めませんでした",
|
||||||
"tooLong": "トリガーワードは30ワードを超えてはいけません",
|
"tooLong": "トリガーワードは100ワードを超えてはいけません",
|
||||||
"tooMany": "最大30トリガーワードまで許可されています",
|
"tooMany": "最大30トリガーワードまで許可されています",
|
||||||
"alreadyExists": "このトリガーワードは既に存在します",
|
"alreadyExists": "このトリガーワードは既に存在します",
|
||||||
"updateSuccess": "トリガーワードが正常に更新されました",
|
"updateSuccess": "トリガーワードが正常に更新されました",
|
||||||
@@ -1115,6 +1406,7 @@
|
|||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"pathUpdated": "例画像パスが正常に更新されました",
|
"pathUpdated": "例画像パスが正常に更新されました",
|
||||||
|
"pathUpdateFailed": "例画像パスの更新に失敗しました:{message}",
|
||||||
"downloadInProgress": "ダウンロードは既に進行中です",
|
"downloadInProgress": "ダウンロードは既に進行中です",
|
||||||
"enterLocationFirst": "最初にダウンロード場所を入力してください",
|
"enterLocationFirst": "最初にダウンロード場所を入力してください",
|
||||||
"downloadStarted": "例画像のダウンロードが開始されました",
|
"downloadStarted": "例画像のダウンロードが開始されました",
|
||||||
@@ -1123,6 +1415,8 @@
|
|||||||
"pauseFailed": "ダウンロードの一時停止に失敗しました:{error}",
|
"pauseFailed": "ダウンロードの一時停止に失敗しました:{error}",
|
||||||
"downloadResumed": "ダウンロードが再開されました",
|
"downloadResumed": "ダウンロードが再開されました",
|
||||||
"resumeFailed": "ダウンロードの再開に失敗しました:{error}",
|
"resumeFailed": "ダウンロードの再開に失敗しました:{error}",
|
||||||
|
"downloadStopped": "ダウンロードをキャンセルしました",
|
||||||
|
"stopFailed": "ダウンロードのキャンセルに失敗しました:{error}",
|
||||||
"deleted": "例画像が削除されました",
|
"deleted": "例画像が削除されました",
|
||||||
"deleteFailed": "例画像の削除に失敗しました",
|
"deleteFailed": "例画像の削除に失敗しました",
|
||||||
"setPreviewFailed": "プレビュー画像の設定に失敗しました"
|
"setPreviewFailed": "プレビュー画像の設定に失敗しました"
|
||||||
@@ -1169,6 +1463,12 @@
|
|||||||
"refreshNow": "今すぐ更新",
|
"refreshNow": "今すぐ更新",
|
||||||
"refreshingIn": "更新まで",
|
"refreshingIn": "更新まで",
|
||||||
"seconds": "秒"
|
"seconds": "秒"
|
||||||
|
},
|
||||||
|
"communitySupport": {
|
||||||
|
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||||
|
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||||
|
"supportCta": "Support on Ko-fi",
|
||||||
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
344
locales/ko.json
344
locales/ko.json
@@ -16,7 +16,9 @@
|
|||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
"unknown": "알 수 없음",
|
"unknown": "알 수 없음",
|
||||||
"date": "날짜",
|
"date": "날짜",
|
||||||
"version": "버전"
|
"version": "버전",
|
||||||
|
"enabled": "활성화됨",
|
||||||
|
"disabled": "비활성화됨"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"select": "언어",
|
"select": "언어",
|
||||||
@@ -29,7 +31,8 @@
|
|||||||
"japanese": "日本語",
|
"japanese": "日本語",
|
||||||
"korean": "한국어",
|
"korean": "한국어",
|
||||||
"french": "Français",
|
"french": "Français",
|
||||||
"spanish": "Español"
|
"spanish": "Español",
|
||||||
|
"Hebrew": "עברית"
|
||||||
},
|
},
|
||||||
"fileSize": {
|
"fileSize": {
|
||||||
"zero": "0 바이트",
|
"zero": "0 바이트",
|
||||||
@@ -98,7 +101,12 @@
|
|||||||
"checkpointNameCopied": "Checkpoint 이름 복사됨",
|
"checkpointNameCopied": "Checkpoint 이름 복사됨",
|
||||||
"toggleBlur": "블러 토글",
|
"toggleBlur": "블러 토글",
|
||||||
"show": "보기",
|
"show": "보기",
|
||||||
"openExampleImages": "예시 이미지 폴더 열기"
|
"openExampleImages": "예시 이미지 폴더 열기",
|
||||||
|
"replacePreview": "미리보기 교체",
|
||||||
|
"copyCheckpointName": "Checkpoint 이름 복사",
|
||||||
|
"copyEmbeddingName": "Embedding 이름 복사",
|
||||||
|
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
||||||
|
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
||||||
},
|
},
|
||||||
"nsfw": {
|
"nsfw": {
|
||||||
"matureContent": "성인 콘텐츠",
|
"matureContent": "성인 콘텐츠",
|
||||||
@@ -112,12 +120,45 @@
|
|||||||
"updateFailed": "즐겨찾기 상태 업데이트 실패"
|
"updateFailed": "즐겨찾기 상태 업데이트 실패"
|
||||||
},
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"checkpointNotImplemented": "Checkpoint을 워크플로로 전송 - 구현 예정 기능"
|
"checkpointNotImplemented": "Checkpoint을 워크플로로 전송 - 구현 예정 기능",
|
||||||
|
"missingPath": "이 카드의 모델 경로를 확인할 수 없습니다"
|
||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"checkError": "예시 이미지 확인 중 오류",
|
"checkError": "예시 이미지 확인 중 오류",
|
||||||
"missingHash": "모델 해시 정보가 없습니다.",
|
"missingHash": "모델 해시 정보가 없습니다.",
|
||||||
"noRemoteImagesAvailable": "Civitai에서 이 모델의 원격 예시 이미지를 사용할 수 없습니다"
|
"noRemoteImagesAvailable": "Civitai에서 이 모델의 원격 예시 이미지를 사용할 수 없습니다"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"update": "업데이트",
|
||||||
|
"updateAvailable": "업데이트 가능"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"globalContextMenu": {
|
||||||
|
"downloadExampleImages": {
|
||||||
|
"label": "예시 이미지 다운로드",
|
||||||
|
"missingPath": "예시 이미지를 다운로드하기 전에 다운로드 위치를 설정하세요.",
|
||||||
|
"unavailable": "예시 이미지 다운로드는 아직 사용할 수 없습니다. 페이지 로딩이 완료된 후 다시 시도하세요."
|
||||||
|
},
|
||||||
|
"checkModelUpdates": {
|
||||||
|
"label": "업데이트 확인",
|
||||||
|
"loading": "{type} 업데이트를 확인 중...",
|
||||||
|
"success": "{type} 업데이트 {count}개를 찾았습니다",
|
||||||
|
"none": "모든 {type}가 최신 상태입니다",
|
||||||
|
"error": "{type} 업데이트 확인 실패: {message}"
|
||||||
|
},
|
||||||
|
"cleanupExampleImages": {
|
||||||
|
"label": "예시 이미지 폴더 정리",
|
||||||
|
"success": "{count}개의 폴더가 삭제 폴더로 이동되었습니다",
|
||||||
|
"none": "정리가 필요한 예시 이미지 폴더가 없습니다",
|
||||||
|
"partial": "정리가 완료되었으나 {failures}개의 폴더가 건너뛰어졌습니다",
|
||||||
|
"error": "예시 이미지 폴더 정리에 실패했습니다: {message}"
|
||||||
|
},
|
||||||
|
"fetchMissingLicenses": {
|
||||||
|
"label": "Refresh license metadata",
|
||||||
|
"loading": "Refreshing license metadata for {typePlural}...",
|
||||||
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
|
"none": "All {typePlural} already have license metadata",
|
||||||
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -154,6 +195,10 @@
|
|||||||
"title": "모델 필터",
|
"title": "모델 필터",
|
||||||
"baseModel": "베이스 모델",
|
"baseModel": "베이스 모델",
|
||||||
"modelTags": "태그 (상위 20개)",
|
"modelTags": "태그 (상위 20개)",
|
||||||
|
"modelTypes": "Model Types",
|
||||||
|
"license": "라이선스",
|
||||||
|
"noCreditRequired": "크레딧 표기 없음",
|
||||||
|
"allowSellingGeneratedContent": "판매 허용",
|
||||||
"clearAll": "모든 필터 지우기"
|
"clearAll": "모든 필터 지우기"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -164,6 +209,7 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"checkUpdates": "업데이트 확인",
|
"checkUpdates": "업데이트 확인",
|
||||||
|
"notifications": "알림",
|
||||||
"support": "지원"
|
"support": "지원"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -171,14 +217,30 @@
|
|||||||
"civitaiApiKey": "Civitai API 키",
|
"civitaiApiKey": "Civitai API 키",
|
||||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||||
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||||
|
"openSettingsFileLocation": {
|
||||||
|
"label": "설정 폴더 열기",
|
||||||
|
"tooltip": "settings.json이 있는 폴더를 엽니다",
|
||||||
|
"success": "settings.json 폴더를 열었습니다",
|
||||||
|
"failed": "settings.json 폴더를 열지 못했습니다"
|
||||||
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "콘텐츠 필터링",
|
"contentFiltering": "콘텐츠 필터링",
|
||||||
"videoSettings": "비디오 설정",
|
"videoSettings": "비디오 설정",
|
||||||
"layoutSettings": "레이아웃 설정",
|
"layoutSettings": "레이아웃 설정",
|
||||||
"folderSettings": "폴더 설정",
|
"folderSettings": "폴더 설정",
|
||||||
|
"priorityTags": "우선순위 태그",
|
||||||
"downloadPathTemplates": "다운로드 경로 템플릿",
|
"downloadPathTemplates": "다운로드 경로 템플릿",
|
||||||
"exampleImages": "예시 이미지",
|
"exampleImages": "예시 이미지",
|
||||||
"misc": "기타"
|
"updateFlags": "업데이트 표시",
|
||||||
|
"autoOrganize": "Auto-organize",
|
||||||
|
"misc": "기타",
|
||||||
|
"metadataArchive": "메타데이터 아카이브 데이터베이스",
|
||||||
|
"storageLocation": "설정 위치",
|
||||||
|
"proxySettings": "프록시 설정"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"locationLabel": "휴대용 모드",
|
||||||
|
"locationHelp": "활성화하면 settings.json을 리포지토리에 유지하고, 비활성화하면 사용자 구성 디렉터리에 저장합니다."
|
||||||
},
|
},
|
||||||
"contentFiltering": {
|
"contentFiltering": {
|
||||||
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
|
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
|
||||||
@@ -190,6 +252,15 @@
|
|||||||
"autoplayOnHover": "호버 시 비디오 자동 재생",
|
"autoplayOnHover": "호버 시 비디오 자동 재생",
|
||||||
"autoplayOnHoverHelp": "마우스를 올렸을 때만 비디오 미리보기를 재생합니다"
|
"autoplayOnHoverHelp": "마우스를 올렸을 때만 비디오 미리보기를 재생합니다"
|
||||||
},
|
},
|
||||||
|
"autoOrganizeExclusions": {
|
||||||
|
"label": "자동 정리 제외 항목",
|
||||||
|
"placeholder": "예: curated/*, */backups/*; *_temp.safetensors",
|
||||||
|
"help": "이 와일드카드 패턴과 일치하는 파일 이동을 건너뜁니다. 여러 패턴은 쉼표 또는 세미콜론으로 구분하십시오.",
|
||||||
|
"validation": {
|
||||||
|
"noPatterns": "쉼표 또는 세미콜론으로 구분된 최소한 하나의 패턴을 입력하십시오.",
|
||||||
|
"saveFailed": "제외 항목을 저장할 수 없습니다: {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "표시 밀도",
|
"displayDensity": "표시 밀도",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -199,23 +270,37 @@
|
|||||||
},
|
},
|
||||||
"displayDensityHelp": "한 줄에 표시할 카드 수를 선택하세요:",
|
"displayDensityHelp": "한 줄에 표시할 카드 수를 선택하세요:",
|
||||||
"displayDensityDetails": {
|
"displayDensityDetails": {
|
||||||
"default": "기본: 5개 (1080p), 6개 (2K), 8개 (4K)",
|
"default": "5개 (1080p), 6개 (2K), 8개 (4K)",
|
||||||
"medium": "중간: 6개 (1080p), 7개 (2K), 9개 (4K)",
|
"medium": "6개 (1080p), 7개 (2K), 9개 (4K)",
|
||||||
"compact": "조밀: 7개 (1080p), 8개 (2K), 10개 (4K)"
|
"compact": "7개 (1080p), 8개 (2K), 10개 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.",
|
"displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.",
|
||||||
|
"showFolderSidebar": "폴더 사이드바 표시",
|
||||||
|
"showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.",
|
||||||
"cardInfoDisplay": "카드 정보 표시",
|
"cardInfoDisplay": "카드 정보 표시",
|
||||||
"cardInfoDisplayOptions": {
|
"cardInfoDisplayOptions": {
|
||||||
"always": "항상 표시",
|
"always": "항상 표시",
|
||||||
"hover": "호버 시 표시"
|
"hover": "호버 시 표시"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요:",
|
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
||||||
"cardInfoDisplayDetails": {
|
"modelCardFooterAction": "모델 카드 버튼 동작",
|
||||||
"always": "항상 표시: 헤더와 푸터가 항상 보입니다",
|
"modelCardFooterActionOptions": {
|
||||||
"hover": "호버 시 표시: 카드에 마우스를 올렸을 때만 헤더와 푸터가 나타납니다"
|
"exampleImages": "예시 이미지 열기",
|
||||||
}
|
"replacePreview": "미리보기 교체"
|
||||||
|
},
|
||||||
|
"modelCardFooterActionHelp": "카드 우측 하단 버튼이 수행할 작업을 선택하세요",
|
||||||
|
"modelNameDisplay": "모델명 표시",
|
||||||
|
"modelNameDisplayOptions": {
|
||||||
|
"modelName": "모델명",
|
||||||
|
"fileName": "파일명"
|
||||||
|
},
|
||||||
|
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요"
|
||||||
},
|
},
|
||||||
"folderSettings": {
|
"folderSettings": {
|
||||||
|
"activeLibrary": "활성 라이브러리",
|
||||||
|
"activeLibraryHelp": "구성된 라이브러리를 전환하여 기본 폴더를 업데이트합니다. 선택을 변경하면 페이지가 다시 로드됩니다.",
|
||||||
|
"loadingLibraries": "라이브러리를 불러오는 중...",
|
||||||
|
"noLibraries": "구성된 라이브러리가 없습니다",
|
||||||
"defaultLoraRoot": "기본 LoRA 루트",
|
"defaultLoraRoot": "기본 LoRA 루트",
|
||||||
"defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다",
|
"defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다",
|
||||||
"defaultCheckpointRoot": "기본 Checkpoint 루트",
|
"defaultCheckpointRoot": "기본 Checkpoint 루트",
|
||||||
@@ -224,6 +309,26 @@
|
|||||||
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
||||||
"noDefault": "기본값 없음"
|
"noDefault": "기본값 없음"
|
||||||
},
|
},
|
||||||
|
"priorityTags": {
|
||||||
|
"title": "우선순위 태그",
|
||||||
|
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
|
||||||
|
"placeholder": "character, concept, style(toon|toon_style)",
|
||||||
|
"helpLinkLabel": "우선순위 태그 도움말 열기",
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"checkpoint": "체크포인트",
|
||||||
|
"embedding": "임베딩"
|
||||||
|
},
|
||||||
|
"saveSuccess": "우선순위 태그가 업데이트되었습니다.",
|
||||||
|
"saveError": "우선순위 태그를 업데이트하지 못했습니다.",
|
||||||
|
"loadingSuggestions": "추천을 불러오는 중...",
|
||||||
|
"validation": {
|
||||||
|
"missingClosingParen": "{index}번째 항목에 닫는 괄호가 없습니다.",
|
||||||
|
"missingCanonical": "{index}번째 항목에는 정식 태그 이름이 포함되어야 합니다.",
|
||||||
|
"duplicateCanonical": "정식 태그 \"{tag}\"가 여러 번 나타납니다.",
|
||||||
|
"unknown": "잘못된 우선순위 태그 구성입니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadPathTemplates": {
|
"downloadPathTemplates": {
|
||||||
"title": "다운로드 경로 템플릿",
|
"title": "다운로드 경로 템플릿",
|
||||||
"help": "Civitai에서 다운로드할 때 다양한 모델 유형의 폴더 구조를 구성합니다.",
|
"help": "Civitai에서 다운로드할 때 다양한 모델 유형의 폴더 구조를 구성합니다.",
|
||||||
@@ -236,6 +341,7 @@
|
|||||||
"baseModelFirstTag": "베이스 모델 + 첫 번째 태그",
|
"baseModelFirstTag": "베이스 모델 + 첫 번째 태그",
|
||||||
"baseModelAuthor": "베이스 모델 + 제작자",
|
"baseModelAuthor": "베이스 모델 + 제작자",
|
||||||
"authorFirstTag": "제작자 + 첫 번째 태그",
|
"authorFirstTag": "제작자 + 첫 번째 태그",
|
||||||
|
"baseModelAuthorFirstTag": "베이스 모델 + 제작자 + 첫 번째 태그",
|
||||||
"customTemplate": "사용자 정의 템플릿"
|
"customTemplate": "사용자 정의 템플릿"
|
||||||
},
|
},
|
||||||
"customTemplatePlaceholder": "사용자 정의 템플릿 입력 (예: {base_model}/{author}/{first_tag})",
|
"customTemplatePlaceholder": "사용자 정의 템플릿 입력 (예: {base_model}/{author}/{first_tag})",
|
||||||
@@ -270,9 +376,59 @@
|
|||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
"restartRequired": "재시작 필요"
|
"restartRequired": "재시작 필요"
|
||||||
},
|
},
|
||||||
|
"updateFlagStrategy": {
|
||||||
|
"label": "업데이트 표시 전략",
|
||||||
|
"help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.",
|
||||||
|
"options": {
|
||||||
|
"sameBase": "베이스 모델로 업데이트 일치",
|
||||||
|
"any": "사용 가능한 모든 업데이트 표시"
|
||||||
|
}
|
||||||
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
|
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
|
||||||
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다"
|
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다"
|
||||||
|
},
|
||||||
|
"metadataArchive": {
|
||||||
|
"enableArchiveDb": "메타데이터 아카이브 데이터베이스 활성화",
|
||||||
|
"enableArchiveDbHelp": "Civitai에서 삭제된 모델의 메타데이터에 접근하기 위해 로컬 데이터베이스를 사용합니다.",
|
||||||
|
"status": "상태",
|
||||||
|
"statusAvailable": "사용 가능",
|
||||||
|
"statusUnavailable": "사용 불가",
|
||||||
|
"enabled": "활성화됨",
|
||||||
|
"management": "데이터베이스 관리",
|
||||||
|
"managementHelp": "메타데이터 아카이브 데이터베이스를 다운로드하거나 제거합니다",
|
||||||
|
"downloadButton": "데이터베이스 다운로드",
|
||||||
|
"downloadingButton": "다운로드 중...",
|
||||||
|
"downloadedButton": "다운로드 완료",
|
||||||
|
"removeButton": "데이터베이스 제거",
|
||||||
|
"removingButton": "제거 중...",
|
||||||
|
"downloadSuccess": "메타데이터 아카이브 데이터베이스가 성공적으로 다운로드되었습니다",
|
||||||
|
"downloadError": "메타데이터 아카이브 데이터베이스 다운로드 실패",
|
||||||
|
"removeSuccess": "메타데이터 아카이브 데이터베이스가 성공적으로 제거되었습니다",
|
||||||
|
"removeError": "메타데이터 아카이브 데이터베이스 제거 실패",
|
||||||
|
"removeConfirm": "메타데이터 아카이브 데이터베이스를 제거하시겠습니까? 이 작업은 로컬 데이터베이스 파일을 삭제하며, 이 기능을 사용하려면 다시 다운로드해야 합니다.",
|
||||||
|
"preparing": "다운로드 준비 중...",
|
||||||
|
"connecting": "다운로드 서버에 연결 중...",
|
||||||
|
"completed": "완료됨",
|
||||||
|
"downloadComplete": "다운로드가 성공적으로 완료되었습니다"
|
||||||
|
},
|
||||||
|
"proxySettings": {
|
||||||
|
"enableProxy": "앱 수준 프록시 활성화",
|
||||||
|
"enableProxyHelp": "이 애플리케이션에 대한 사용자 지정 프록시 설정을 활성화하여 시스템 프록시 설정을 무시합니다",
|
||||||
|
"proxyType": "프록시 유형",
|
||||||
|
"proxyTypeHelp": "프록시 서버 유형을 선택하세요 (HTTP, HTTPS, SOCKS4, SOCKS5)",
|
||||||
|
"proxyHost": "프록시 호스트",
|
||||||
|
"proxyHostPlaceholder": "proxy.example.com",
|
||||||
|
"proxyHostHelp": "프록시 서버의 호스트명 또는 IP 주소",
|
||||||
|
"proxyPort": "프록시 포트",
|
||||||
|
"proxyPortPlaceholder": "8080",
|
||||||
|
"proxyPortHelp": "프록시 서버의 포트 번호",
|
||||||
|
"proxyUsername": "사용자 이름 (선택사항)",
|
||||||
|
"proxyUsernamePlaceholder": "username",
|
||||||
|
"proxyUsernameHelp": "프록시 인증에 필요한 사용자 이름 (필요한 경우)",
|
||||||
|
"proxyPassword": "비밀번호 (선택사항)",
|
||||||
|
"proxyPasswordPlaceholder": "password",
|
||||||
|
"proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"loras": {
|
"loras": {
|
||||||
@@ -291,8 +447,10 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "모델 목록 새로고침",
|
"title": "모델 목록 새로고침",
|
||||||
"quick": "빠른 새로고침 (증분)",
|
"quick": "변경 사항 동기화",
|
||||||
"full": "전체 재구성 (완전)"
|
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
|
||||||
|
"full": "캐시 재구성",
|
||||||
|
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
||||||
},
|
},
|
||||||
"fetch": {
|
"fetch": {
|
||||||
"title": "Civitai에서 메타데이터 가져오기",
|
"title": "Civitai에서 메타데이터 가져오기",
|
||||||
@@ -313,6 +471,13 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "즐겨찾기만 보기",
|
"title": "즐겨찾기만 보기",
|
||||||
"action": "즐겨찾기"
|
"action": "즐겨찾기"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"title": "업데이트 가능한 모델만 표시",
|
||||||
|
"action": "업데이트",
|
||||||
|
"menuLabel": "업데이트 옵션 표시",
|
||||||
|
"check": "업데이트 확인",
|
||||||
|
"checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bulkOperations": {
|
"bulkOperations": {
|
||||||
@@ -321,8 +486,10 @@
|
|||||||
"viewSelected": "선택 항목 보기",
|
"viewSelected": "선택 항목 보기",
|
||||||
"addTags": "모두에 태그 추가",
|
"addTags": "모두에 태그 추가",
|
||||||
"setBaseModel": "모두에 베이스 모델 설정",
|
"setBaseModel": "모두에 베이스 모델 설정",
|
||||||
|
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
||||||
"copyAll": "모든 문법 복사",
|
"copyAll": "모든 문법 복사",
|
||||||
"refreshAll": "모든 메타데이터 새로고침",
|
"refreshAll": "모든 메타데이터 새로고침",
|
||||||
|
"checkUpdates": "선택 항목 업데이트 확인",
|
||||||
"moveAll": "모두 폴더로 이동",
|
"moveAll": "모두 폴더로 이동",
|
||||||
"autoOrganize": "자동 정리 선택",
|
"autoOrganize": "자동 정리 선택",
|
||||||
"deleteAll": "모든 모델 삭제",
|
"deleteAll": "모든 모델 삭제",
|
||||||
@@ -339,6 +506,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "Civitai 데이터 새로고침",
|
"refreshMetadata": "Civitai 데이터 새로고침",
|
||||||
|
"checkUpdates": "업데이트 확인",
|
||||||
"relinkCivitai": "Civitai에 다시 연결",
|
"relinkCivitai": "Civitai에 다시 연결",
|
||||||
"copySyntax": "LoRA 문법 복사",
|
"copySyntax": "LoRA 문법 복사",
|
||||||
"copyFilename": "모델 파일명 복사",
|
"copyFilename": "모델 파일명 복사",
|
||||||
@@ -360,6 +528,9 @@
|
|||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRA 레시피",
|
"title": "LoRA 레시피",
|
||||||
|
"actions": {
|
||||||
|
"sendCheckpoint": "ComfyUI로 보내기"
|
||||||
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "가져오기",
|
"action": "가져오기",
|
||||||
@@ -456,13 +627,19 @@
|
|||||||
"title": "Embedding 모델"
|
"title": "Embedding 모델"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "모델 루트",
|
"modelRoot": "루트",
|
||||||
"collapseAll": "모든 폴더 접기",
|
"collapseAll": "모든 폴더 접기",
|
||||||
"pinSidebar": "사이드바 고정",
|
"pinSidebar": "사이드바 고정",
|
||||||
"unpinSidebar": "사이드바 고정 해제",
|
"unpinSidebar": "사이드바 고정 해제",
|
||||||
"switchToListView": "목록 보기로 전환",
|
"switchToListView": "목록 보기로 전환",
|
||||||
"switchToTreeView": "트리 보기로 전환",
|
"switchToTreeView": "트리 보기로 전환",
|
||||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다"
|
"recursiveOn": "하위 폴더 검색",
|
||||||
|
"recursiveOff": "현재 폴더만 검색",
|
||||||
|
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
||||||
|
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||||
|
"dragDrop": {
|
||||||
|
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"title": "통계",
|
"title": "통계",
|
||||||
@@ -537,6 +714,14 @@
|
|||||||
"downloadedPreview": "미리보기 이미지 다운로드됨",
|
"downloadedPreview": "미리보기 이미지 다운로드됨",
|
||||||
"downloadingFile": "{type} 파일 다운로드 중",
|
"downloadingFile": "{type} 파일 다운로드 중",
|
||||||
"finalizing": "다운로드 완료 중..."
|
"finalizing": "다운로드 완료 중..."
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"currentFile": "현재 파일:",
|
||||||
|
"downloading": "다운로드 중: {name}",
|
||||||
|
"transferred": "다운로드됨: {downloaded} / {total}",
|
||||||
|
"transferredSimple": "다운로드됨: {downloaded}",
|
||||||
|
"transferredUnknown": "다운로드됨: --",
|
||||||
|
"speed": "속도: {speed}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
@@ -545,6 +730,7 @@
|
|||||||
"contentRating": {
|
"contentRating": {
|
||||||
"title": "콘텐츠 등급 설정",
|
"title": "콘텐츠 등급 설정",
|
||||||
"current": "현재",
|
"current": "현재",
|
||||||
|
"multiple": "여러 값",
|
||||||
"levels": {
|
"levels": {
|
||||||
"pg": "PG",
|
"pg": "PG",
|
||||||
"pg13": "PG13",
|
"pg13": "PG13",
|
||||||
@@ -583,6 +769,12 @@
|
|||||||
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
||||||
"action": "모두 삭제"
|
"action": "모두 삭제"
|
||||||
},
|
},
|
||||||
|
"checkUpdates": {
|
||||||
|
"title": "{type} 전체 업데이트를 확인할까요?",
|
||||||
|
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
||||||
|
"tip": "나눠서 진행하고 싶다면 벌크 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
|
||||||
|
"action": "전체 확인"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "여러 모델에 태그 추가",
|
"title": "여러 모델에 태그 추가",
|
||||||
"description": "다음에 태그를 추가합니다:",
|
"description": "다음에 태그를 추가합니다:",
|
||||||
@@ -651,7 +843,12 @@
|
|||||||
"editBaseModel": "베이스 모델 편집",
|
"editBaseModel": "베이스 모델 편집",
|
||||||
"viewOnCivitai": "Civitai에서 보기",
|
"viewOnCivitai": "Civitai에서 보기",
|
||||||
"viewOnCivitaiText": "Civitai에서 보기",
|
"viewOnCivitaiText": "Civitai에서 보기",
|
||||||
"viewCreatorProfile": "제작자 프로필 보기"
|
"viewCreatorProfile": "제작자 프로필 보기",
|
||||||
|
"openFileLocation": "파일 위치 열기"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "파일 위치가 성공적으로 열렸습니다",
|
||||||
|
"failed": "파일 위치 열기에 실패했습니다"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "버전",
|
"version": "버전",
|
||||||
@@ -675,6 +872,7 @@
|
|||||||
"strengthMin": "최소 강도",
|
"strengthMin": "최소 강도",
|
||||||
"strengthMax": "최대 강도",
|
"strengthMax": "최대 강도",
|
||||||
"strength": "강도",
|
"strength": "강도",
|
||||||
|
"clipStrength": "클립 강도",
|
||||||
"clipSkip": "클립 스킵",
|
"clipSkip": "클립 스킵",
|
||||||
"valuePlaceholder": "값",
|
"valuePlaceholder": "값",
|
||||||
"add": "추가"
|
"add": "추가"
|
||||||
@@ -713,13 +911,77 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"examples": "예시",
|
"examples": "예시",
|
||||||
"description": "모델 설명",
|
"description": "모델 설명",
|
||||||
"recipes": "레시피"
|
"recipes": "레시피",
|
||||||
|
"versions": "버전"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"noImageSell": "No selling generated content",
|
||||||
|
"noRentCivit": "No Civitai generation",
|
||||||
|
"noRent": "No generation services",
|
||||||
|
"noSell": "No selling models",
|
||||||
|
"creditRequired": "제작자 크레딧 필요",
|
||||||
|
"noDerivatives": "공유 병합 불가",
|
||||||
|
"noReLicense": "동일한 권한 필요",
|
||||||
|
"restrictionsLabel": "라이선스 제한"
|
||||||
},
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"exampleImages": "예시 이미지 로딩 중...",
|
"exampleImages": "예시 이미지 로딩 중...",
|
||||||
"description": "모델 설명 로딩 중...",
|
"description": "모델 설명 로딩 중...",
|
||||||
"recipes": "레시피 로딩 중...",
|
"recipes": "레시피 로딩 중...",
|
||||||
"examples": "예시 로딩 중..."
|
"examples": "예시 로딩 중...",
|
||||||
|
"versions": "버전 로딩 중..."
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"heading": "모델 버전",
|
||||||
|
"copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.",
|
||||||
|
"media": {
|
||||||
|
"placeholder": "미리보기 없음"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"unnamed": "이름 없는 버전",
|
||||||
|
"noDetails": "추가 정보 없음"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "현재 버전",
|
||||||
|
"inLibrary": "라이브러리에 있음",
|
||||||
|
"newer": "최신 버전",
|
||||||
|
"ignored": "무시됨"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"download": "다운로드",
|
||||||
|
"delete": "삭제",
|
||||||
|
"ignore": "무시",
|
||||||
|
"unignore": "무시 해제",
|
||||||
|
"resumeModelUpdates": "이 모델 업데이트 재개",
|
||||||
|
"ignoreModelUpdates": "이 모델 업데이트 무시",
|
||||||
|
"viewLocalVersions": "로컬 버전 모두 보기",
|
||||||
|
"viewLocalTooltip": "곧 제공 예정"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"label": "기본 필터",
|
||||||
|
"state": {
|
||||||
|
"showAll": "모든 버전",
|
||||||
|
"showSameBase": "같은 베이스"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"showAllVersions": "모든 버전을 표시하도록 전환",
|
||||||
|
"showSameBaseVersions": "같은 베이스 모델 버전만 표시하도록 전환"
|
||||||
|
},
|
||||||
|
"empty": "현재 베이스 모델 필터와 일치하는 버전이 없습니다."
|
||||||
|
},
|
||||||
|
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
|
||||||
|
"error": "버전을 불러오지 못했습니다.",
|
||||||
|
"missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.",
|
||||||
|
"confirm": {
|
||||||
|
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"modelIgnored": "이 모델의 업데이트가 무시됩니다",
|
||||||
|
"modelResumed": "업데이트 추적이 재개되었습니다",
|
||||||
|
"versionIgnored": "이 버전의 업데이트가 무시됩니다",
|
||||||
|
"versionUnignored": "버전이 다시 활성화되었습니다",
|
||||||
|
"versionDeleted": "버전이 삭제되었습니다"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -826,7 +1088,9 @@
|
|||||||
"loraFailedToSend": "LoRA를 워크플로로 전송하지 못했습니다",
|
"loraFailedToSend": "LoRA를 워크플로로 전송하지 못했습니다",
|
||||||
"recipeAdded": "레시피가 워크플로에 추가되었습니다",
|
"recipeAdded": "레시피가 워크플로에 추가되었습니다",
|
||||||
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
||||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다"
|
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
||||||
|
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||||
|
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "레시피",
|
"recipe": "레시피",
|
||||||
@@ -871,6 +1135,11 @@
|
|||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "업데이트 확인",
|
"title": "업데이트 확인",
|
||||||
|
"notificationsTitle": "알림 센터",
|
||||||
|
"tabs": {
|
||||||
|
"updates": "업데이트",
|
||||||
|
"messages": "메시지"
|
||||||
|
},
|
||||||
"updateAvailable": "업데이트 사용 가능",
|
"updateAvailable": "업데이트 사용 가능",
|
||||||
"noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.",
|
"noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.",
|
||||||
"currentVersion": "현재 버전",
|
"currentVersion": "현재 버전",
|
||||||
@@ -902,6 +1171,13 @@
|
|||||||
"nightly": {
|
"nightly": {
|
||||||
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
|
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
|
||||||
"enable": "나이틀리 업데이트 활성화"
|
"enable": "나이틀리 업데이트 활성화"
|
||||||
|
},
|
||||||
|
"banners": {
|
||||||
|
"recent": "최근 알림",
|
||||||
|
"empty": "최근 배너가 없습니다.",
|
||||||
|
"shown": "{time}에 표시",
|
||||||
|
"dismissed": "{time}에 닫힘",
|
||||||
|
"active": "활성"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
@@ -981,6 +1257,9 @@
|
|||||||
"cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락",
|
"cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락",
|
||||||
"sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다",
|
"sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다",
|
||||||
"sendError": "레시피를 워크플로로 전송하는 중 오류",
|
"sendError": "레시피를 워크플로로 전송하는 중 오류",
|
||||||
|
"missingCheckpointPath": "체크포인트 경로를 사용할 수 없습니다",
|
||||||
|
"missingCheckpointInfo": "체크포인트 정보가 부족합니다",
|
||||||
|
"downloadCheckpointFailed": "체크포인트 다운로드 실패: {message}",
|
||||||
"cannotDelete": "레시피를 삭제할 수 없습니다: 레시피 ID 누락",
|
"cannotDelete": "레시피를 삭제할 수 없습니다: 레시피 ID 누락",
|
||||||
"deleteConfirmationError": "삭제 확인 표시 오류",
|
"deleteConfirmationError": "삭제 확인 표시 오류",
|
||||||
"deletedSuccessfully": "레시피가 성공적으로 삭제되었습니다",
|
"deletedSuccessfully": "레시피가 성공적으로 삭제되었습니다",
|
||||||
@@ -1017,6 +1296,16 @@
|
|||||||
"bulkBaseModelUpdateSuccess": "{count}개의 모델에 베이스 모델이 성공적으로 업데이트되었습니다",
|
"bulkBaseModelUpdateSuccess": "{count}개의 모델에 베이스 모델이 성공적으로 업데이트되었습니다",
|
||||||
"bulkBaseModelUpdatePartial": "{success}개의 모델이 업데이트되었고, {failed}개의 모델이 실패했습니다",
|
"bulkBaseModelUpdatePartial": "{success}개의 모델이 업데이트되었고, {failed}개의 모델이 실패했습니다",
|
||||||
"bulkBaseModelUpdateFailed": "선택한 모델의 베이스 모델 업데이트에 실패했습니다",
|
"bulkBaseModelUpdateFailed": "선택한 모델의 베이스 모델 업데이트에 실패했습니다",
|
||||||
|
"bulkContentRatingUpdating": "{count}개 모델의 콘텐츠 등급을 업데이트하는 중...",
|
||||||
|
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
|
||||||
|
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
|
||||||
|
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
|
||||||
|
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
|
||||||
|
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
|
||||||
|
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
|
||||||
|
"bulkUpdatesMissing": "선택한 {type}이 Civitai 업데이트에 연결되어 있지 않습니다",
|
||||||
|
"bulkUpdatesPartialMissing": "Civitai 링크가 없는 {missing}개의 {type}을 건너뛰었습니다",
|
||||||
|
"bulkUpdatesFailed": "선택한 {type}의 업데이트 확인에 실패했습니다: {message}",
|
||||||
"invalidCharactersRemoved": "파일명에서 잘못된 문자가 제거되었습니다",
|
"invalidCharactersRemoved": "파일명에서 잘못된 문자가 제거되었습니다",
|
||||||
"filenameCannotBeEmpty": "파일 이름은 비어있을 수 없습니다",
|
"filenameCannotBeEmpty": "파일 이름은 비어있을 수 없습니다",
|
||||||
"renameFailed": "파일 이름 변경 실패: {message}",
|
"renameFailed": "파일 이름 변경 실패: {message}",
|
||||||
@@ -1051,6 +1340,8 @@
|
|||||||
"compactModeToggled": "컴팩트 모드 {state}",
|
"compactModeToggled": "컴팩트 모드 {state}",
|
||||||
"settingSaveFailed": "설정 저장 실패: {message}",
|
"settingSaveFailed": "설정 저장 실패: {message}",
|
||||||
"displayDensitySet": "표시 밀도가 {density}로 설정되었습니다",
|
"displayDensitySet": "표시 밀도가 {density}로 설정되었습니다",
|
||||||
|
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||||
|
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||||
"languageChangeFailed": "언어 변경 실패: {message}",
|
"languageChangeFailed": "언어 변경 실패: {message}",
|
||||||
"cacheCleared": "캐시 파일이 성공적으로 지워졌습니다. 다음 작업 시 캐시가 재구축됩니다.",
|
"cacheCleared": "캐시 파일이 성공적으로 지워졌습니다. 다음 작업 시 캐시가 재구축됩니다.",
|
||||||
"cacheClearFailed": "캐시 지우기 실패: {error}",
|
"cacheClearFailed": "캐시 지우기 실패: {error}",
|
||||||
@@ -1075,7 +1366,7 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "학습된 단어를 로딩할 수 없습니다",
|
"loadFailed": "학습된 단어를 로딩할 수 없습니다",
|
||||||
"tooLong": "트리거 단어는 30단어를 초과할 수 없습니다",
|
"tooLong": "트리거 단어는 100단어를 초과할 수 없습니다",
|
||||||
"tooMany": "최대 30개의 트리거 단어만 허용됩니다",
|
"tooMany": "최대 30개의 트리거 단어만 허용됩니다",
|
||||||
"alreadyExists": "이 트리거 단어는 이미 존재합니다",
|
"alreadyExists": "이 트리거 단어는 이미 존재합니다",
|
||||||
"updateSuccess": "트리거 단어가 성공적으로 업데이트되었습니다",
|
"updateSuccess": "트리거 단어가 성공적으로 업데이트되었습니다",
|
||||||
@@ -1115,6 +1406,7 @@
|
|||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"pathUpdated": "예시 이미지 경로가 성공적으로 업데이트되었습니다",
|
"pathUpdated": "예시 이미지 경로가 성공적으로 업데이트되었습니다",
|
||||||
|
"pathUpdateFailed": "예시 이미지 경로 업데이트 실패: {message}",
|
||||||
"downloadInProgress": "이미 다운로드가 진행 중입니다",
|
"downloadInProgress": "이미 다운로드가 진행 중입니다",
|
||||||
"enterLocationFirst": "먼저 다운로드 위치를 입력해주세요",
|
"enterLocationFirst": "먼저 다운로드 위치를 입력해주세요",
|
||||||
"downloadStarted": "예시 이미지 다운로드가 시작되었습니다",
|
"downloadStarted": "예시 이미지 다운로드가 시작되었습니다",
|
||||||
@@ -1123,6 +1415,8 @@
|
|||||||
"pauseFailed": "다운로드 일시정지 실패: {error}",
|
"pauseFailed": "다운로드 일시정지 실패: {error}",
|
||||||
"downloadResumed": "다운로드가 재개되었습니다",
|
"downloadResumed": "다운로드가 재개되었습니다",
|
||||||
"resumeFailed": "다운로드 재개 실패: {error}",
|
"resumeFailed": "다운로드 재개 실패: {error}",
|
||||||
|
"downloadStopped": "다운로드가 취소되었습니다",
|
||||||
|
"stopFailed": "다운로드 취소 실패: {error}",
|
||||||
"deleted": "예시 이미지가 삭제되었습니다",
|
"deleted": "예시 이미지가 삭제되었습니다",
|
||||||
"deleteFailed": "예시 이미지 삭제 실패",
|
"deleteFailed": "예시 이미지 삭제 실패",
|
||||||
"setPreviewFailed": "미리보기 이미지 설정 실패"
|
"setPreviewFailed": "미리보기 이미지 설정 실패"
|
||||||
@@ -1169,6 +1463,12 @@
|
|||||||
"refreshNow": "지금 새로고침",
|
"refreshNow": "지금 새로고침",
|
||||||
"refreshingIn": "새로고침까지",
|
"refreshingIn": "새로고침까지",
|
||||||
"seconds": "초"
|
"seconds": "초"
|
||||||
|
},
|
||||||
|
"communitySupport": {
|
||||||
|
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||||
|
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||||
|
"supportCta": "Support on Ko-fi",
|
||||||
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
344
locales/ru.json
344
locales/ru.json
@@ -16,7 +16,9 @@
|
|||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
"unknown": "Неизвестно",
|
"unknown": "Неизвестно",
|
||||||
"date": "Дата",
|
"date": "Дата",
|
||||||
"version": "Версия"
|
"version": "Версия",
|
||||||
|
"enabled": "Включено",
|
||||||
|
"disabled": "Отключено"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"select": "Язык",
|
"select": "Язык",
|
||||||
@@ -29,7 +31,8 @@
|
|||||||
"japanese": "日本語",
|
"japanese": "日本語",
|
||||||
"korean": "한국어",
|
"korean": "한국어",
|
||||||
"french": "Français",
|
"french": "Français",
|
||||||
"spanish": "Español"
|
"spanish": "Español",
|
||||||
|
"Hebrew": "עברית"
|
||||||
},
|
},
|
||||||
"fileSize": {
|
"fileSize": {
|
||||||
"zero": "0 Байт",
|
"zero": "0 Байт",
|
||||||
@@ -98,7 +101,12 @@
|
|||||||
"checkpointNameCopied": "Имя checkpoint скопировано",
|
"checkpointNameCopied": "Имя checkpoint скопировано",
|
||||||
"toggleBlur": "Переключить размытие",
|
"toggleBlur": "Переключить размытие",
|
||||||
"show": "Показать",
|
"show": "Показать",
|
||||||
"openExampleImages": "Открыть папку с примерами"
|
"openExampleImages": "Открыть папку с примерами",
|
||||||
|
"replacePreview": "Заменить превью",
|
||||||
|
"copyCheckpointName": "Копировать имя checkpoint",
|
||||||
|
"copyEmbeddingName": "Копировать имя embedding",
|
||||||
|
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
||||||
|
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
||||||
},
|
},
|
||||||
"nsfw": {
|
"nsfw": {
|
||||||
"matureContent": "Контент для взрослых",
|
"matureContent": "Контент для взрослых",
|
||||||
@@ -112,12 +120,45 @@
|
|||||||
"updateFailed": "Не удалось обновить статус избранного"
|
"updateFailed": "Не удалось обновить статус избранного"
|
||||||
},
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"checkpointNotImplemented": "Отправка checkpoint в workflow - функция будет реализована"
|
"checkpointNotImplemented": "Отправка checkpoint в workflow - функция будет реализована",
|
||||||
|
"missingPath": "Невозможно определить путь модели для этой карточки"
|
||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"checkError": "Ошибка проверки примеров изображений",
|
"checkError": "Ошибка проверки примеров изображений",
|
||||||
"missingHash": "Отсутствует хеш модели.",
|
"missingHash": "Отсутствует хеш модели.",
|
||||||
"noRemoteImagesAvailable": "Нет удаленных примеров изображений для этой модели на Civitai"
|
"noRemoteImagesAvailable": "Нет удаленных примеров изображений для этой модели на Civitai"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"update": "Обновление",
|
||||||
|
"updateAvailable": "Доступно обновление"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"globalContextMenu": {
|
||||||
|
"downloadExampleImages": {
|
||||||
|
"label": "Загрузить примеры изображений",
|
||||||
|
"missingPath": "Укажите место загрузки перед загрузкой примеров изображений.",
|
||||||
|
"unavailable": "Загрузка примеров изображений пока недоступна. Попробуйте снова после полной загрузки страницы."
|
||||||
|
},
|
||||||
|
"checkModelUpdates": {
|
||||||
|
"label": "Проверить обновления",
|
||||||
|
"loading": "Проверка обновлений для {type}...",
|
||||||
|
"success": "Найдено {count} обновлений для {type}",
|
||||||
|
"none": "Все {type} актуальны",
|
||||||
|
"error": "Не удалось проверить обновления для {type}: {message}"
|
||||||
|
},
|
||||||
|
"cleanupExampleImages": {
|
||||||
|
"label": "Очистить папки с примерами изображений",
|
||||||
|
"success": "Перемещено {count} папок в папку удалённых",
|
||||||
|
"none": "Нет папок с примерами изображений, требующих очистки",
|
||||||
|
"partial": "Очистка завершена, пропущено {failures} папок",
|
||||||
|
"error": "Не удалось очистить папки с примерами изображений: {message}"
|
||||||
|
},
|
||||||
|
"fetchMissingLicenses": {
|
||||||
|
"label": "Refresh license metadata",
|
||||||
|
"loading": "Refreshing license metadata for {typePlural}...",
|
||||||
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
|
"none": "All {typePlural} already have license metadata",
|
||||||
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -154,6 +195,10 @@
|
|||||||
"title": "Фильтр моделей",
|
"title": "Фильтр моделей",
|
||||||
"baseModel": "Базовая модель",
|
"baseModel": "Базовая модель",
|
||||||
"modelTags": "Теги (Топ 20)",
|
"modelTags": "Теги (Топ 20)",
|
||||||
|
"modelTypes": "Model Types",
|
||||||
|
"license": "Лицензия",
|
||||||
|
"noCreditRequired": "Без указания авторства",
|
||||||
|
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||||
"clearAll": "Очистить все фильтры"
|
"clearAll": "Очистить все фильтры"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -164,6 +209,7 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"checkUpdates": "Проверить обновления",
|
"checkUpdates": "Проверить обновления",
|
||||||
|
"notifications": "Уведомления",
|
||||||
"support": "Поддержка"
|
"support": "Поддержка"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -171,14 +217,30 @@
|
|||||||
"civitaiApiKey": "Ключ API Civitai",
|
"civitaiApiKey": "Ключ API Civitai",
|
||||||
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
||||||
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
||||||
|
"openSettingsFileLocation": {
|
||||||
|
"label": "Открыть папку настроек",
|
||||||
|
"tooltip": "Открыть папку, содержащую settings.json",
|
||||||
|
"success": "Папка settings.json открыта",
|
||||||
|
"failed": "Не удалось открыть папку settings.json"
|
||||||
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "Фильтрация контента",
|
"contentFiltering": "Фильтрация контента",
|
||||||
"videoSettings": "Настройки видео",
|
"videoSettings": "Настройки видео",
|
||||||
"layoutSettings": "Настройки макета",
|
"layoutSettings": "Настройки макета",
|
||||||
"folderSettings": "Настройки папок",
|
"folderSettings": "Настройки папок",
|
||||||
|
"priorityTags": "Приоритетные теги",
|
||||||
"downloadPathTemplates": "Шаблоны путей загрузки",
|
"downloadPathTemplates": "Шаблоны путей загрузки",
|
||||||
"exampleImages": "Примеры изображений",
|
"exampleImages": "Примеры изображений",
|
||||||
"misc": "Разное"
|
"updateFlags": "Метки обновлений",
|
||||||
|
"autoOrganize": "Auto-organize",
|
||||||
|
"misc": "Разное",
|
||||||
|
"metadataArchive": "Архив метаданных",
|
||||||
|
"storageLocation": "Расположение настроек",
|
||||||
|
"proxySettings": "Настройки прокси"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"locationLabel": "Портативный режим",
|
||||||
|
"locationHelp": "Включите, чтобы хранить settings.json в репозитории; выключите, чтобы сохранить его в папке конфигурации пользователя."
|
||||||
},
|
},
|
||||||
"contentFiltering": {
|
"contentFiltering": {
|
||||||
"blurNsfwContent": "Размывать NSFW контент",
|
"blurNsfwContent": "Размывать NSFW контент",
|
||||||
@@ -190,6 +252,15 @@
|
|||||||
"autoplayOnHover": "Автовоспроизведение видео при наведении",
|
"autoplayOnHover": "Автовоспроизведение видео при наведении",
|
||||||
"autoplayOnHoverHelp": "Воспроизводить превью видео только при наведении курсора"
|
"autoplayOnHoverHelp": "Воспроизводить превью видео только при наведении курсора"
|
||||||
},
|
},
|
||||||
|
"autoOrganizeExclusions": {
|
||||||
|
"label": "Исключения автосортировки",
|
||||||
|
"placeholder": "Пример: curated/*, */backups/*; *_temp.safetensors",
|
||||||
|
"help": "Пропускать перемещение файлов, соответствующих этим шаблонам. Разделяйте несколько шаблонов запятыми или точками с запятой.",
|
||||||
|
"validation": {
|
||||||
|
"noPatterns": "Введите хотя бы один шаблон, разделенный запятыми или точками с запятой.",
|
||||||
|
"saveFailed": "Не удалось сохранить исключения: {message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "Плотность отображения",
|
"displayDensity": "Плотность отображения",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -199,23 +270,37 @@
|
|||||||
},
|
},
|
||||||
"displayDensityHelp": "Выберите количество карточек для отображения в ряду:",
|
"displayDensityHelp": "Выберите количество карточек для отображения в ряду:",
|
||||||
"displayDensityDetails": {
|
"displayDensityDetails": {
|
||||||
"default": "По умолчанию: 5 (1080p), 6 (2K), 8 (4K)",
|
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||||
"medium": "Средняя: 6 (1080p), 7 (2K), 9 (4K)",
|
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||||
"compact": "Компактная: 7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.",
|
"displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.",
|
||||||
|
"showFolderSidebar": "Показывать боковую панель папок",
|
||||||
|
"showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.",
|
||||||
"cardInfoDisplay": "Отображение информации карточки",
|
"cardInfoDisplay": "Отображение информации карточки",
|
||||||
"cardInfoDisplayOptions": {
|
"cardInfoDisplayOptions": {
|
||||||
"always": "Всегда видимо",
|
"always": "Всегда видимо",
|
||||||
"hover": "Показать при наведении"
|
"hover": "Показать при наведении"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий:",
|
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
||||||
"cardInfoDisplayDetails": {
|
"modelCardFooterAction": "Действие кнопки карточки модели",
|
||||||
"always": "Всегда видимо: Заголовки и подписи всегда видны",
|
"modelCardFooterActionOptions": {
|
||||||
"hover": "Показать при наведении: Заголовки и подписи появляются только при наведении на карточку"
|
"exampleImages": "Открыть примеры изображений",
|
||||||
}
|
"replacePreview": "Заменить превью"
|
||||||
|
},
|
||||||
|
"modelCardFooterActionHelp": "Выберите, что делает кнопка в правом нижнем углу карточки",
|
||||||
|
"modelNameDisplay": "Отображение названия модели",
|
||||||
|
"modelNameDisplayOptions": {
|
||||||
|
"modelName": "Название модели",
|
||||||
|
"fileName": "Имя файла"
|
||||||
|
},
|
||||||
|
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели"
|
||||||
},
|
},
|
||||||
"folderSettings": {
|
"folderSettings": {
|
||||||
|
"activeLibrary": "Активная библиотека",
|
||||||
|
"activeLibraryHelp": "Переключайтесь между настроенными библиотеками, чтобы обновить папки по умолчанию. Изменение выбора перезагружает страницу.",
|
||||||
|
"loadingLibraries": "Загрузка библиотек...",
|
||||||
|
"noLibraries": "Библиотеки не настроены",
|
||||||
"defaultLoraRoot": "Корневая папка LoRA по умолчанию",
|
"defaultLoraRoot": "Корневая папка LoRA по умолчанию",
|
||||||
"defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений",
|
"defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений",
|
||||||
"defaultCheckpointRoot": "Корневая папка Checkpoint по умолчанию",
|
"defaultCheckpointRoot": "Корневая папка Checkpoint по умолчанию",
|
||||||
@@ -224,6 +309,26 @@
|
|||||||
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
||||||
"noDefault": "Не задано"
|
"noDefault": "Не задано"
|
||||||
},
|
},
|
||||||
|
"priorityTags": {
|
||||||
|
"title": "Приоритетные теги",
|
||||||
|
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
|
||||||
|
"placeholder": "character, concept, style(toon|toon_style)",
|
||||||
|
"helpLinkLabel": "Открыть справку по приоритетным тегам",
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"checkpoint": "Чекпойнт",
|
||||||
|
"embedding": "Эмбеддинг"
|
||||||
|
},
|
||||||
|
"saveSuccess": "Приоритетные теги обновлены.",
|
||||||
|
"saveError": "Не удалось обновить приоритетные теги.",
|
||||||
|
"loadingSuggestions": "Загрузка подсказок...",
|
||||||
|
"validation": {
|
||||||
|
"missingClosingParen": "В записи {index} отсутствует закрывающая скобка.",
|
||||||
|
"missingCanonical": "Запись {index} должна содержать каноническое имя тега.",
|
||||||
|
"duplicateCanonical": "Канонический тег \"{tag}\" встречается более одного раза.",
|
||||||
|
"unknown": "Недопустимая конфигурация приоритетных тегов."
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadPathTemplates": {
|
"downloadPathTemplates": {
|
||||||
"title": "Шаблоны путей загрузки",
|
"title": "Шаблоны путей загрузки",
|
||||||
"help": "Настройте структуру папок для разных типов моделей при загрузке с Civitai.",
|
"help": "Настройте структуру папок для разных типов моделей при загрузке с Civitai.",
|
||||||
@@ -236,6 +341,7 @@
|
|||||||
"baseModelFirstTag": "Базовая модель + Первый тег",
|
"baseModelFirstTag": "Базовая модель + Первый тег",
|
||||||
"baseModelAuthor": "Базовая модель + Автор",
|
"baseModelAuthor": "Базовая модель + Автор",
|
||||||
"authorFirstTag": "Автор + Первый тег",
|
"authorFirstTag": "Автор + Первый тег",
|
||||||
|
"baseModelAuthorFirstTag": "Базовая модель + Автор + Первый тег",
|
||||||
"customTemplate": "Пользовательский шаблон"
|
"customTemplate": "Пользовательский шаблон"
|
||||||
},
|
},
|
||||||
"customTemplatePlaceholder": "Введите пользовательский шаблон (например, {base_model}/{author}/{first_tag})",
|
"customTemplatePlaceholder": "Введите пользовательский шаблон (например, {base_model}/{author}/{first_tag})",
|
||||||
@@ -270,9 +376,59 @@
|
|||||||
"download": "Загрузить",
|
"download": "Загрузить",
|
||||||
"restartRequired": "Требует перезапуска"
|
"restartRequired": "Требует перезапуска"
|
||||||
},
|
},
|
||||||
|
"updateFlagStrategy": {
|
||||||
|
"label": "Стратегия меток обновлений",
|
||||||
|
"help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.",
|
||||||
|
"options": {
|
||||||
|
"sameBase": "Совпадение обновлений по базовой модели",
|
||||||
|
"any": "Отмечать любые доступные обновления"
|
||||||
|
}
|
||||||
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
|
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
|
||||||
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена"
|
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена"
|
||||||
|
},
|
||||||
|
"metadataArchive": {
|
||||||
|
"enableArchiveDb": "Включить архив метаданных",
|
||||||
|
"enableArchiveDbHelp": "Использовать локальную базу данных для доступа к метаданным моделей, удалённых с Civitai.",
|
||||||
|
"status": "Статус",
|
||||||
|
"statusAvailable": "Доступно",
|
||||||
|
"statusUnavailable": "Недоступно",
|
||||||
|
"enabled": "Включено",
|
||||||
|
"management": "Управление базой данных",
|
||||||
|
"managementHelp": "Скачать или удалить базу данных архива метаданных",
|
||||||
|
"downloadButton": "Скачать базу данных",
|
||||||
|
"downloadingButton": "Скачивание...",
|
||||||
|
"downloadedButton": "Скачано",
|
||||||
|
"removeButton": "Удалить базу данных",
|
||||||
|
"removingButton": "Удаление...",
|
||||||
|
"downloadSuccess": "База данных архива метаданных успешно загружена",
|
||||||
|
"downloadError": "Не удалось загрузить базу данных архива метаданных",
|
||||||
|
"removeSuccess": "База данных архива метаданных успешно удалена",
|
||||||
|
"removeError": "Не удалось удалить базу данных архива метаданных",
|
||||||
|
"removeConfirm": "Вы уверены, что хотите удалить базу данных архива метаданных? Это удалит локальный файл базы данных, и для использования этой функции потребуется повторная загрузка.",
|
||||||
|
"preparing": "Подготовка к загрузке...",
|
||||||
|
"connecting": "Подключение к серверу загрузки...",
|
||||||
|
"completed": "Завершено",
|
||||||
|
"downloadComplete": "Загрузка успешно завершена"
|
||||||
|
},
|
||||||
|
"proxySettings": {
|
||||||
|
"enableProxy": "Включить прокси на уровне приложения",
|
||||||
|
"enableProxyHelp": "Включить пользовательские настройки прокси для этого приложения, переопределяя системные настройки прокси",
|
||||||
|
"proxyType": "Тип прокси",
|
||||||
|
"proxyTypeHelp": "Выберите тип прокси-сервера (HTTP, HTTPS, SOCKS4, SOCKS5)",
|
||||||
|
"proxyHost": "Хост прокси",
|
||||||
|
"proxyHostPlaceholder": "proxy.example.com",
|
||||||
|
"proxyHostHelp": "Имя хоста или IP-адрес вашего прокси-сервера",
|
||||||
|
"proxyPort": "Порт прокси",
|
||||||
|
"proxyPortPlaceholder": "8080",
|
||||||
|
"proxyPortHelp": "Номер порта вашего прокси-сервера",
|
||||||
|
"proxyUsername": "Имя пользователя (необязательно)",
|
||||||
|
"proxyUsernamePlaceholder": "имя пользователя",
|
||||||
|
"proxyUsernameHelp": "Имя пользователя для аутентификации на прокси (если требуется)",
|
||||||
|
"proxyPassword": "Пароль (необязательно)",
|
||||||
|
"proxyPasswordPlaceholder": "пароль",
|
||||||
|
"proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"loras": {
|
"loras": {
|
||||||
@@ -291,8 +447,10 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список моделей",
|
"title": "Обновить список моделей",
|
||||||
"quick": "Быстрое обновление (инкрементальное)",
|
"quick": "Синхронизировать изменения",
|
||||||
"full": "Полная перестройка (полное)"
|
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
|
||||||
|
"full": "Перестроить кэш",
|
||||||
|
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
||||||
},
|
},
|
||||||
"fetch": {
|
"fetch": {
|
||||||
"title": "Получить метаданные с Civitai",
|
"title": "Получить метаданные с Civitai",
|
||||||
@@ -313,6 +471,13 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "Показать только избранное",
|
"title": "Показать только избранное",
|
||||||
"action": "Избранное"
|
"action": "Избранное"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"title": "Показывать только модели с доступными обновлениями",
|
||||||
|
"action": "Обновления",
|
||||||
|
"menuLabel": "Показать параметры обновления",
|
||||||
|
"check": "Проверить обновления",
|
||||||
|
"checkTooltip": "Проверка может занять время."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bulkOperations": {
|
"bulkOperations": {
|
||||||
@@ -321,8 +486,10 @@
|
|||||||
"viewSelected": "Просмотреть выбранные",
|
"viewSelected": "Просмотреть выбранные",
|
||||||
"addTags": "Добавить теги ко всем",
|
"addTags": "Добавить теги ко всем",
|
||||||
"setBaseModel": "Установить базовую модель для всех",
|
"setBaseModel": "Установить базовую модель для всех",
|
||||||
|
"setContentRating": "Установить рейтинг контента для всех",
|
||||||
"copyAll": "Копировать весь синтаксис",
|
"copyAll": "Копировать весь синтаксис",
|
||||||
"refreshAll": "Обновить все метаданные",
|
"refreshAll": "Обновить все метаданные",
|
||||||
|
"checkUpdates": "Проверить обновления для выбранных",
|
||||||
"moveAll": "Переместить все в папку",
|
"moveAll": "Переместить все в папку",
|
||||||
"autoOrganize": "Автоматически организовать выбранные",
|
"autoOrganize": "Автоматически организовать выбранные",
|
||||||
"deleteAll": "Удалить все модели",
|
"deleteAll": "Удалить все модели",
|
||||||
@@ -339,6 +506,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "Обновить данные Civitai",
|
"refreshMetadata": "Обновить данные Civitai",
|
||||||
|
"checkUpdates": "Проверить обновления",
|
||||||
"relinkCivitai": "Пересвязать с Civitai",
|
"relinkCivitai": "Пересвязать с Civitai",
|
||||||
"copySyntax": "Копировать синтаксис LoRA",
|
"copySyntax": "Копировать синтаксис LoRA",
|
||||||
"copyFilename": "Копировать имя файла модели",
|
"copyFilename": "Копировать имя файла модели",
|
||||||
@@ -360,6 +528,9 @@
|
|||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Рецепты LoRA",
|
"title": "Рецепты LoRA",
|
||||||
|
"actions": {
|
||||||
|
"sendCheckpoint": "Отправить в ComfyUI"
|
||||||
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "Импортировать",
|
"action": "Импортировать",
|
||||||
@@ -456,13 +627,19 @@
|
|||||||
"title": "Модели Embedding"
|
"title": "Модели Embedding"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Корень моделей",
|
"modelRoot": "Корень",
|
||||||
"collapseAll": "Свернуть все папки",
|
"collapseAll": "Свернуть все папки",
|
||||||
"pinSidebar": "Закрепить боковую панель",
|
"pinSidebar": "Закрепить боковую панель",
|
||||||
"unpinSidebar": "Открепить боковую панель",
|
"unpinSidebar": "Открепить боковую панель",
|
||||||
"switchToListView": "Переключить на вид списка",
|
"switchToListView": "Переключить на вид списка",
|
||||||
"switchToTreeView": "Переключить на древовидный вид",
|
"switchToTreeView": "Переключить на древовидный вид",
|
||||||
"collapseAllDisabled": "Недоступно в виде списка"
|
"recursiveOn": "Искать во вложенных папках",
|
||||||
|
"recursiveOff": "Искать только в текущей папке",
|
||||||
|
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
||||||
|
"collapseAllDisabled": "Недоступно в виде списка",
|
||||||
|
"dragDrop": {
|
||||||
|
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"title": "Статистика",
|
"title": "Статистика",
|
||||||
@@ -537,6 +714,14 @@
|
|||||||
"downloadedPreview": "Превью изображение загружено",
|
"downloadedPreview": "Превью изображение загружено",
|
||||||
"downloadingFile": "Загрузка файла {type}",
|
"downloadingFile": "Загрузка файла {type}",
|
||||||
"finalizing": "Завершение загрузки..."
|
"finalizing": "Завершение загрузки..."
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"currentFile": "Текущий файл:",
|
||||||
|
"downloading": "Скачивается: {name}",
|
||||||
|
"transferred": "Скачано: {downloaded} / {total}",
|
||||||
|
"transferredSimple": "Скачано: {downloaded}",
|
||||||
|
"transferredUnknown": "Скачано: --",
|
||||||
|
"speed": "Скорость: {speed}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
@@ -545,6 +730,7 @@
|
|||||||
"contentRating": {
|
"contentRating": {
|
||||||
"title": "Установить рейтинг контента",
|
"title": "Установить рейтинг контента",
|
||||||
"current": "Текущий",
|
"current": "Текущий",
|
||||||
|
"multiple": "Несколько значений",
|
||||||
"levels": {
|
"levels": {
|
||||||
"pg": "PG",
|
"pg": "PG",
|
||||||
"pg13": "PG13",
|
"pg13": "PG13",
|
||||||
@@ -583,6 +769,12 @@
|
|||||||
"countMessage": "моделей будут удалены навсегда.",
|
"countMessage": "моделей будут удалены навсегда.",
|
||||||
"action": "Удалить все"
|
"action": "Удалить все"
|
||||||
},
|
},
|
||||||
|
"checkUpdates": {
|
||||||
|
"title": "Проверить обновления для всех {typePlural}?",
|
||||||
|
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
||||||
|
"tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".",
|
||||||
|
"action": "Проверить всё"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "Добавить теги к нескольким моделям",
|
"title": "Добавить теги к нескольким моделям",
|
||||||
"description": "Добавить теги к",
|
"description": "Добавить теги к",
|
||||||
@@ -651,7 +843,12 @@
|
|||||||
"editBaseModel": "Редактировать базовую модель",
|
"editBaseModel": "Редактировать базовую модель",
|
||||||
"viewOnCivitai": "Посмотреть на Civitai",
|
"viewOnCivitai": "Посмотреть на Civitai",
|
||||||
"viewOnCivitaiText": "Посмотреть на Civitai",
|
"viewOnCivitaiText": "Посмотреть на Civitai",
|
||||||
"viewCreatorProfile": "Посмотреть профиль создателя"
|
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||||
|
"openFileLocation": "Открыть расположение файла"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "Расположение файла успешно открыто",
|
||||||
|
"failed": "Не удалось открыть расположение файла"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "Версия",
|
"version": "Версия",
|
||||||
@@ -675,6 +872,7 @@
|
|||||||
"strengthMin": "Мин. сила",
|
"strengthMin": "Мин. сила",
|
||||||
"strengthMax": "Макс. сила",
|
"strengthMax": "Макс. сила",
|
||||||
"strength": "Сила",
|
"strength": "Сила",
|
||||||
|
"clipStrength": "Сила клипа",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Значение",
|
"valuePlaceholder": "Значение",
|
||||||
"add": "Добавить"
|
"add": "Добавить"
|
||||||
@@ -713,13 +911,77 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"examples": "Примеры",
|
"examples": "Примеры",
|
||||||
"description": "Описание модели",
|
"description": "Описание модели",
|
||||||
"recipes": "Рецепты"
|
"recipes": "Рецепты",
|
||||||
|
"versions": "Версии"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"noImageSell": "No selling generated content",
|
||||||
|
"noRentCivit": "No Civitai generation",
|
||||||
|
"noRent": "No generation services",
|
||||||
|
"noSell": "No selling models",
|
||||||
|
"creditRequired": "Требуется указание авторства",
|
||||||
|
"noDerivatives": "Запрет на совместное использование производных работ",
|
||||||
|
"noReLicense": "Требуются те же права",
|
||||||
|
"restrictionsLabel": "Лицензионные ограничения"
|
||||||
},
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"exampleImages": "Загрузка примеров изображений...",
|
"exampleImages": "Загрузка примеров изображений...",
|
||||||
"description": "Загрузка описания модели...",
|
"description": "Загрузка описания модели...",
|
||||||
"recipes": "Загрузка рецептов...",
|
"recipes": "Загрузка рецептов...",
|
||||||
"examples": "Загрузка примеров..."
|
"examples": "Загрузка примеров...",
|
||||||
|
"versions": "Загрузка версий..."
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"heading": "Версии модели",
|
||||||
|
"copy": "Управляйте всеми версиями этой модели в одном месте.",
|
||||||
|
"media": {
|
||||||
|
"placeholder": "Нет превью"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"unnamed": "Версия без названия",
|
||||||
|
"noDetails": "Дополнительная информация отсутствует"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "Текущая версия",
|
||||||
|
"inLibrary": "В библиотеке",
|
||||||
|
"newer": "Более новая версия",
|
||||||
|
"ignored": "Игнорируется"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"download": "Скачать",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"ignore": "Игнорировать",
|
||||||
|
"unignore": "Перестать игнорировать",
|
||||||
|
"resumeModelUpdates": "Возобновить обновления для этой модели",
|
||||||
|
"ignoreModelUpdates": "Игнорировать обновления для этой модели",
|
||||||
|
"viewLocalVersions": "Показать все локальные версии",
|
||||||
|
"viewLocalTooltip": "Скоро появится"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"label": "Фильтр по базе",
|
||||||
|
"state": {
|
||||||
|
"showAll": "Все версии",
|
||||||
|
"showSameBase": "Тот же базовый"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"showAllVersions": "Переключиться на отображение всех версий",
|
||||||
|
"showSameBaseVersions": "Переключиться на отображение только версий с тем же базовым"
|
||||||
|
},
|
||||||
|
"empty": "Нет версий, соответствующих текущему фильтру базовой модели."
|
||||||
|
},
|
||||||
|
"empty": "Для этой модели пока нет истории версий.",
|
||||||
|
"error": "Не удалось загрузить версии.",
|
||||||
|
"missingModelId": "У этой модели отсутствует идентификатор модели Civitai.",
|
||||||
|
"confirm": {
|
||||||
|
"delete": "Удалить эту версию из библиотеки?"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"modelIgnored": "Обновления для этой модели игнорируются",
|
||||||
|
"modelResumed": "Отслеживание обновлений возобновлено",
|
||||||
|
"versionIgnored": "Обновления для этой версии игнорируются",
|
||||||
|
"versionUnignored": "Версия снова активна",
|
||||||
|
"versionDeleted": "Версия удалена"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -826,7 +1088,9 @@
|
|||||||
"loraFailedToSend": "Не удалось отправить LoRA в workflow",
|
"loraFailedToSend": "Не удалось отправить LoRA в workflow",
|
||||||
"recipeAdded": "Рецепт добавлен в workflow",
|
"recipeAdded": "Рецепт добавлен в workflow",
|
||||||
"recipeReplaced": "Рецепт заменён в workflow",
|
"recipeReplaced": "Рецепт заменён в workflow",
|
||||||
"recipeFailedToSend": "Не удалось отправить рецепт в workflow"
|
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
|
||||||
|
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
||||||
|
"noTargetNodeSelected": "Целевой узел не выбран"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Рецепт",
|
"recipe": "Рецепт",
|
||||||
@@ -871,6 +1135,11 @@
|
|||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "Проверить обновления",
|
"title": "Проверить обновления",
|
||||||
|
"notificationsTitle": "Центр уведомлений",
|
||||||
|
"tabs": {
|
||||||
|
"updates": "Обновления",
|
||||||
|
"messages": "Сообщения"
|
||||||
|
},
|
||||||
"updateAvailable": "Доступно обновление",
|
"updateAvailable": "Доступно обновление",
|
||||||
"noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.",
|
"noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.",
|
||||||
"currentVersion": "Текущая версия",
|
"currentVersion": "Текущая версия",
|
||||||
@@ -902,6 +1171,13 @@
|
|||||||
"nightly": {
|
"nightly": {
|
||||||
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
|
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
|
||||||
"enable": "Включить ночные обновления"
|
"enable": "Включить ночные обновления"
|
||||||
|
},
|
||||||
|
"banners": {
|
||||||
|
"recent": "Недавние уведомления",
|
||||||
|
"empty": "Недавних баннеров нет.",
|
||||||
|
"shown": "Показано {time}",
|
||||||
|
"dismissed": "Закрыто {time}",
|
||||||
|
"active": "Активно"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
@@ -981,6 +1257,9 @@
|
|||||||
"cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта",
|
"cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта",
|
||||||
"sendFailed": "Не удалось отправить рецепт в workflow",
|
"sendFailed": "Не удалось отправить рецепт в workflow",
|
||||||
"sendError": "Ошибка отправки рецепта в workflow",
|
"sendError": "Ошибка отправки рецепта в workflow",
|
||||||
|
"missingCheckpointPath": "Путь к чекпойнту недоступен",
|
||||||
|
"missingCheckpointInfo": "Отсутствуют данные о чекпойнте",
|
||||||
|
"downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}",
|
||||||
"cannotDelete": "Невозможно удалить рецепт: отсутствует ID рецепта",
|
"cannotDelete": "Невозможно удалить рецепт: отсутствует ID рецепта",
|
||||||
"deleteConfirmationError": "Ошибка отображения подтверждения удаления",
|
"deleteConfirmationError": "Ошибка отображения подтверждения удаления",
|
||||||
"deletedSuccessfully": "Рецепт успешно удален",
|
"deletedSuccessfully": "Рецепт успешно удален",
|
||||||
@@ -1017,6 +1296,16 @@
|
|||||||
"bulkBaseModelUpdateSuccess": "Базовая модель успешно обновлена для {count} моделей",
|
"bulkBaseModelUpdateSuccess": "Базовая модель успешно обновлена для {count} моделей",
|
||||||
"bulkBaseModelUpdatePartial": "Обновлено {success} моделей, не удалось обновить {failed} моделей",
|
"bulkBaseModelUpdatePartial": "Обновлено {success} моделей, не удалось обновить {failed} моделей",
|
||||||
"bulkBaseModelUpdateFailed": "Не удалось обновить базовую модель для выбранных моделей",
|
"bulkBaseModelUpdateFailed": "Не удалось обновить базовую модель для выбранных моделей",
|
||||||
|
"bulkContentRatingUpdating": "Обновление рейтинга контента для {count} модель(ей)...",
|
||||||
|
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
|
||||||
|
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
|
||||||
|
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
|
||||||
|
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
|
||||||
|
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
|
||||||
|
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
|
||||||
|
"bulkUpdatesMissing": "Выбранные {type} не привязаны к обновлениям Civitai",
|
||||||
|
"bulkUpdatesPartialMissing": "Пропущено {missing} выбранных {type} без привязки Civitai",
|
||||||
|
"bulkUpdatesFailed": "Не удалось проверить обновления для выбранных {type}: {message}",
|
||||||
"invalidCharactersRemoved": "Недопустимые символы удалены из имени файла",
|
"invalidCharactersRemoved": "Недопустимые символы удалены из имени файла",
|
||||||
"filenameCannotBeEmpty": "Имя файла не может быть пустым",
|
"filenameCannotBeEmpty": "Имя файла не может быть пустым",
|
||||||
"renameFailed": "Не удалось переименовать файл: {message}",
|
"renameFailed": "Не удалось переименовать файл: {message}",
|
||||||
@@ -1051,6 +1340,8 @@
|
|||||||
"compactModeToggled": "Компактный режим {state}",
|
"compactModeToggled": "Компактный режим {state}",
|
||||||
"settingSaveFailed": "Не удалось сохранить настройку: {message}",
|
"settingSaveFailed": "Не удалось сохранить настройку: {message}",
|
||||||
"displayDensitySet": "Плотность отображения установлена на {density}",
|
"displayDensitySet": "Плотность отображения установлена на {density}",
|
||||||
|
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||||
|
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||||
"languageChangeFailed": "Не удалось изменить язык: {message}",
|
"languageChangeFailed": "Не удалось изменить язык: {message}",
|
||||||
"cacheCleared": "Файлы кэша успешно очищены. Кэш будет пересобран при следующем действии.",
|
"cacheCleared": "Файлы кэша успешно очищены. Кэш будет пересобран при следующем действии.",
|
||||||
"cacheClearFailed": "Не удалось очистить кэш: {error}",
|
"cacheClearFailed": "Не удалось очистить кэш: {error}",
|
||||||
@@ -1075,7 +1366,7 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "Не удалось загрузить обученные слова",
|
"loadFailed": "Не удалось загрузить обученные слова",
|
||||||
"tooLong": "Триггерное слово не должно превышать 30 слов",
|
"tooLong": "Триггерное слово не должно превышать 100 слов",
|
||||||
"tooMany": "Максимум 30 триггерных слов разрешено",
|
"tooMany": "Максимум 30 триггерных слов разрешено",
|
||||||
"alreadyExists": "Это триггерное слово уже существует",
|
"alreadyExists": "Это триггерное слово уже существует",
|
||||||
"updateSuccess": "Триггерные слова успешно обновлены",
|
"updateSuccess": "Триггерные слова успешно обновлены",
|
||||||
@@ -1115,6 +1406,7 @@
|
|||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"pathUpdated": "Путь к примерам изображений успешно обновлен",
|
"pathUpdated": "Путь к примерам изображений успешно обновлен",
|
||||||
|
"pathUpdateFailed": "Не удалось обновить путь к примерам изображений: {message}",
|
||||||
"downloadInProgress": "Загрузка уже в процессе",
|
"downloadInProgress": "Загрузка уже в процессе",
|
||||||
"enterLocationFirst": "Пожалуйста, сначала введите место загрузки",
|
"enterLocationFirst": "Пожалуйста, сначала введите место загрузки",
|
||||||
"downloadStarted": "Загрузка примеров изображений начата",
|
"downloadStarted": "Загрузка примеров изображений начата",
|
||||||
@@ -1123,6 +1415,8 @@
|
|||||||
"pauseFailed": "Не удалось приостановить загрузку: {error}",
|
"pauseFailed": "Не удалось приостановить загрузку: {error}",
|
||||||
"downloadResumed": "Загрузка возобновлена",
|
"downloadResumed": "Загрузка возобновлена",
|
||||||
"resumeFailed": "Не удалось возобновить загрузку: {error}",
|
"resumeFailed": "Не удалось возобновить загрузку: {error}",
|
||||||
|
"downloadStopped": "Загрузка отменена",
|
||||||
|
"stopFailed": "Не удалось отменить загрузку: {error}",
|
||||||
"deleted": "Пример изображения удален",
|
"deleted": "Пример изображения удален",
|
||||||
"deleteFailed": "Не удалось удалить пример изображения",
|
"deleteFailed": "Не удалось удалить пример изображения",
|
||||||
"setPreviewFailed": "Не удалось установить превью изображение"
|
"setPreviewFailed": "Не удалось установить превью изображение"
|
||||||
@@ -1169,6 +1463,12 @@
|
|||||||
"refreshNow": "Обновить сейчас",
|
"refreshNow": "Обновить сейчас",
|
||||||
"refreshingIn": "Обновление через",
|
"refreshingIn": "Обновление через",
|
||||||
"seconds": "секунд"
|
"seconds": "секунд"
|
||||||
|
},
|
||||||
|
"communitySupport": {
|
||||||
|
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||||
|
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||||
|
"supportCta": "Support on Ko-fi",
|
||||||
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,20 +16,23 @@
|
|||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
"date": "日期",
|
"date": "日期",
|
||||||
"version": "版本"
|
"version": "版本",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"disabled": "已禁用"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"select": "语言",
|
"select": "选择语言",
|
||||||
"select_help": "选择你喜欢的界面语言",
|
"select_help": "选择你喜欢的界面语言",
|
||||||
"english": "English",
|
"english": "English",
|
||||||
"chinese_simplified": "中文(简体)",
|
"chinese_simplified": "中文(简体)",
|
||||||
"chinese_traditional": "中文(繁体)",
|
"chinese_traditional": "中文(繁体)",
|
||||||
"russian": "俄语",
|
"russian": "Русский",
|
||||||
"german": "德语",
|
"german": "Deutsch",
|
||||||
"japanese": "日语",
|
"japanese": "日本語",
|
||||||
"korean": "韩语",
|
"korean": "한국어",
|
||||||
"french": "法语",
|
"french": "Français",
|
||||||
"spanish": "西班牙语"
|
"spanish": "Español",
|
||||||
|
"Hebrew": "עברית"
|
||||||
},
|
},
|
||||||
"fileSize": {
|
"fileSize": {
|
||||||
"zero": "0 字节",
|
"zero": "0 字节",
|
||||||
@@ -98,7 +101,12 @@
|
|||||||
"checkpointNameCopied": "检查点名称已复制",
|
"checkpointNameCopied": "检查点名称已复制",
|
||||||
"toggleBlur": "切换模糊",
|
"toggleBlur": "切换模糊",
|
||||||
"show": "显示",
|
"show": "显示",
|
||||||
"openExampleImages": "打开示例图片文件夹"
|
"openExampleImages": "打开示例图片文件夹",
|
||||||
|
"replacePreview": "替换预览",
|
||||||
|
"copyCheckpointName": "复制 Checkpoint 名称",
|
||||||
|
"copyEmbeddingName": "复制 Embedding 名称",
|
||||||
|
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
||||||
|
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
||||||
},
|
},
|
||||||
"nsfw": {
|
"nsfw": {
|
||||||
"matureContent": "成熟内容",
|
"matureContent": "成熟内容",
|
||||||
@@ -112,12 +120,45 @@
|
|||||||
"updateFailed": "收藏状态更新失败"
|
"updateFailed": "收藏状态更新失败"
|
||||||
},
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"checkpointNotImplemented": "发送检查点到工作流 - 功能待实现"
|
"checkpointNotImplemented": "发送检查点到工作流 - 功能待实现",
|
||||||
|
"missingPath": "无法确定此卡片的模型路径"
|
||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"checkError": "检查示例图片时出错",
|
"checkError": "检查示例图片时出错",
|
||||||
"missingHash": "缺少模型哈希信息。",
|
"missingHash": "缺少模型哈希信息。",
|
||||||
"noRemoteImagesAvailable": "此模型在 Civitai 上没有远程示例图片"
|
"noRemoteImagesAvailable": "此模型在 Civitai 上没有远程示例图片"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"update": "更新",
|
||||||
|
"updateAvailable": "有可用更新"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"globalContextMenu": {
|
||||||
|
"downloadExampleImages": {
|
||||||
|
"label": "下载示例图片",
|
||||||
|
"missingPath": "请先设置下载位置后再下载示例图片。",
|
||||||
|
"unavailable": "示例图片下载当前不可用。请在页面加载完成后重试。"
|
||||||
|
},
|
||||||
|
"checkModelUpdates": {
|
||||||
|
"label": "检查更新",
|
||||||
|
"loading": "正在检查 {type} 更新...",
|
||||||
|
"success": "找到 {count} 条 {type} 更新",
|
||||||
|
"none": "所有 {type} 均已是最新版本",
|
||||||
|
"error": "检查 {type} 更新失败:{message}"
|
||||||
|
},
|
||||||
|
"cleanupExampleImages": {
|
||||||
|
"label": "清理示例图片文件夹",
|
||||||
|
"success": "已将 {count} 个文件夹移动到已删除文件夹",
|
||||||
|
"none": "没有需要清理的示例图片文件夹",
|
||||||
|
"partial": "清理完成,有 {failures} 个文件夹跳过",
|
||||||
|
"error": "清理示例图片文件夹失败:{message}"
|
||||||
|
},
|
||||||
|
"fetchMissingLicenses": {
|
||||||
|
"label": "Refresh license metadata",
|
||||||
|
"loading": "Refreshing license metadata for {typePlural}...",
|
||||||
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
|
"none": "All {typePlural} already have license metadata",
|
||||||
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -154,6 +195,10 @@
|
|||||||
"title": "筛选模型",
|
"title": "筛选模型",
|
||||||
"baseModel": "基础模型",
|
"baseModel": "基础模型",
|
||||||
"modelTags": "标签(前20)",
|
"modelTags": "标签(前20)",
|
||||||
|
"modelTypes": "Model Types",
|
||||||
|
"license": "许可证",
|
||||||
|
"noCreditRequired": "无需署名",
|
||||||
|
"allowSellingGeneratedContent": "允许销售",
|
||||||
"clearAll": "清除所有筛选"
|
"clearAll": "清除所有筛选"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -164,6 +209,7 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"checkUpdates": "检查更新",
|
"checkUpdates": "检查更新",
|
||||||
|
"notifications": "通知",
|
||||||
"support": "支持"
|
"support": "支持"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -171,14 +217,30 @@
|
|||||||
"civitaiApiKey": "Civitai API 密钥",
|
"civitaiApiKey": "Civitai API 密钥",
|
||||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||||
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
||||||
|
"openSettingsFileLocation": {
|
||||||
|
"label": "打开设置文件夹",
|
||||||
|
"tooltip": "打开包含 settings.json 的文件夹",
|
||||||
|
"success": "已打开 settings.json 文件夹",
|
||||||
|
"failed": "无法打开 settings.json 文件夹"
|
||||||
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "内容过滤",
|
"contentFiltering": "内容过滤",
|
||||||
"videoSettings": "视频设置",
|
"videoSettings": "视频设置",
|
||||||
"layoutSettings": "布局设置",
|
"layoutSettings": "布局设置",
|
||||||
"folderSettings": "文件夹设置",
|
"folderSettings": "文件夹设置",
|
||||||
|
"priorityTags": "优先标签",
|
||||||
"downloadPathTemplates": "下载路径模板",
|
"downloadPathTemplates": "下载路径模板",
|
||||||
"exampleImages": "示例图片",
|
"exampleImages": "示例图片",
|
||||||
"misc": "其他"
|
"updateFlags": "更新标记",
|
||||||
|
"autoOrganize": "Auto-organize",
|
||||||
|
"misc": "其他",
|
||||||
|
"metadataArchive": "元数据归档数据库",
|
||||||
|
"storageLocation": "设置位置",
|
||||||
|
"proxySettings": "代理设置"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"locationLabel": "便携模式",
|
||||||
|
"locationHelp": "开启可将 settings.json 保存在仓库中;关闭则保存在用户配置目录。"
|
||||||
},
|
},
|
||||||
"contentFiltering": {
|
"contentFiltering": {
|
||||||
"blurNsfwContent": "模糊 NSFW 内容",
|
"blurNsfwContent": "模糊 NSFW 内容",
|
||||||
@@ -190,6 +252,15 @@
|
|||||||
"autoplayOnHover": "悬停时自动播放视频",
|
"autoplayOnHover": "悬停时自动播放视频",
|
||||||
"autoplayOnHoverHelp": "仅在悬停时播放视频预览"
|
"autoplayOnHoverHelp": "仅在悬停时播放视频预览"
|
||||||
},
|
},
|
||||||
|
"autoOrganizeExclusions": {
|
||||||
|
"label": "自动整理排除项",
|
||||||
|
"placeholder": "示例: curated/*, */backups/*; *_temp.safetensors",
|
||||||
|
"help": "跳过与这些通配符模式匹配的文件。多个模式用逗号或分号分隔。",
|
||||||
|
"validation": {
|
||||||
|
"noPatterns": "请输入至少一个用逗号或分号分隔的模式。",
|
||||||
|
"saveFailed": "无法保存排除项:{message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "显示密度",
|
"displayDensity": "显示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -199,23 +270,37 @@
|
|||||||
},
|
},
|
||||||
"displayDensityHelp": "选择每行显示卡片数量:",
|
"displayDensityHelp": "选择每行显示卡片数量:",
|
||||||
"displayDensityDetails": {
|
"displayDensityDetails": {
|
||||||
"default": "默认:5(1080p),6(2K),8(4K)",
|
"default": "5(1080p),6(2K),8(4K)",
|
||||||
"medium": "中等:6(1080p),7(2K),9(4K)",
|
"medium": "6(1080p),7(2K),9(4K)",
|
||||||
"compact": "紧凑:7(1080p),8(2K),10(4K)"
|
"compact": "7(1080p),8(2K),10(4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。",
|
"displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。",
|
||||||
|
"showFolderSidebar": "显示文件夹侧边栏",
|
||||||
|
"showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。",
|
||||||
"cardInfoDisplay": "卡片信息显示",
|
"cardInfoDisplay": "卡片信息显示",
|
||||||
"cardInfoDisplayOptions": {
|
"cardInfoDisplayOptions": {
|
||||||
"always": "始终可见",
|
"always": "始终可见",
|
||||||
"hover": "悬停时显示"
|
"hover": "悬停时显示"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮:",
|
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
||||||
"cardInfoDisplayDetails": {
|
"modelCardFooterAction": "模型卡片按钮操作",
|
||||||
"always": "始终可见:标题和底部始终显示",
|
"modelCardFooterActionOptions": {
|
||||||
"hover": "悬停时显示:仅在悬停卡片时显示标题和底部"
|
"exampleImages": "打开示例图片",
|
||||||
}
|
"replacePreview": "替换预览"
|
||||||
|
},
|
||||||
|
"modelCardFooterActionHelp": "选择右下角卡片按钮的功能",
|
||||||
|
"modelNameDisplay": "模型名称显示",
|
||||||
|
"modelNameDisplayOptions": {
|
||||||
|
"modelName": "模型名称",
|
||||||
|
"fileName": "文件名"
|
||||||
|
},
|
||||||
|
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容"
|
||||||
},
|
},
|
||||||
"folderSettings": {
|
"folderSettings": {
|
||||||
|
"activeLibrary": "活动库",
|
||||||
|
"activeLibraryHelp": "在已配置的库之间切换以更新默认文件夹。更改选择将重新加载页面。",
|
||||||
|
"loadingLibraries": "正在加载库...",
|
||||||
|
"noLibraries": "尚未配置库",
|
||||||
"defaultLoraRoot": "默认 LoRA 根目录",
|
"defaultLoraRoot": "默认 LoRA 根目录",
|
||||||
"defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录",
|
"defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录",
|
||||||
"defaultCheckpointRoot": "默认 Checkpoint 根目录",
|
"defaultCheckpointRoot": "默认 Checkpoint 根目录",
|
||||||
@@ -224,6 +309,26 @@
|
|||||||
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
||||||
"noDefault": "无默认"
|
"noDefault": "无默认"
|
||||||
},
|
},
|
||||||
|
"priorityTags": {
|
||||||
|
"title": "优先标签",
|
||||||
|
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
|
||||||
|
"placeholder": "character, concept, style(toon|toon_style)",
|
||||||
|
"helpLinkLabel": "打开优先标签帮助",
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"embedding": "Embedding"
|
||||||
|
},
|
||||||
|
"saveSuccess": "优先标签已更新。",
|
||||||
|
"saveError": "优先标签更新失败。",
|
||||||
|
"loadingSuggestions": "正在加载建议...",
|
||||||
|
"validation": {
|
||||||
|
"missingClosingParen": "条目 {index} 缺少右括号。",
|
||||||
|
"missingCanonical": "条目 {index} 必须包含规范标签名称。",
|
||||||
|
"duplicateCanonical": "规范标签 \"{tag}\" 出现多次。",
|
||||||
|
"unknown": "优先标签配置无效。"
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadPathTemplates": {
|
"downloadPathTemplates": {
|
||||||
"title": "下载路径模板",
|
"title": "下载路径模板",
|
||||||
"help": "配置从 Civitai 下载不同模型类型的文件夹结构。",
|
"help": "配置从 Civitai 下载不同模型类型的文件夹结构。",
|
||||||
@@ -236,6 +341,7 @@
|
|||||||
"baseModelFirstTag": "基础模型 + 首标签",
|
"baseModelFirstTag": "基础模型 + 首标签",
|
||||||
"baseModelAuthor": "基础模型 + 作者",
|
"baseModelAuthor": "基础模型 + 作者",
|
||||||
"authorFirstTag": "作者 + 首标签",
|
"authorFirstTag": "作者 + 首标签",
|
||||||
|
"baseModelAuthorFirstTag": "基础模型 + 作者 + 首标签",
|
||||||
"customTemplate": "自定义模板"
|
"customTemplate": "自定义模板"
|
||||||
},
|
},
|
||||||
"customTemplatePlaceholder": "输入自定义模板(如:{base_model}/{author}/{first_tag})",
|
"customTemplatePlaceholder": "输入自定义模板(如:{base_model}/{author}/{first_tag})",
|
||||||
@@ -270,9 +376,59 @@
|
|||||||
"download": "下载",
|
"download": "下载",
|
||||||
"restartRequired": "需要重启"
|
"restartRequired": "需要重启"
|
||||||
},
|
},
|
||||||
|
"updateFlagStrategy": {
|
||||||
|
"label": "更新标记策略",
|
||||||
|
"help": "决定更新徽章是否仅在新版本与本地文件共享相同基础模型时显示,或只要该模型有任何更新版本就显示。",
|
||||||
|
"options": {
|
||||||
|
"sameBase": "按基础模型匹配更新",
|
||||||
|
"any": "显示任何可用更新"
|
||||||
|
}
|
||||||
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
|
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
|
||||||
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词"
|
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词"
|
||||||
|
},
|
||||||
|
"metadataArchive": {
|
||||||
|
"enableArchiveDb": "启用元数据归档数据库",
|
||||||
|
"enableArchiveDbHelp": "使用本地数据库访问已从 Civitai 删除的模型元数据。",
|
||||||
|
"status": "状态",
|
||||||
|
"statusAvailable": "可用",
|
||||||
|
"statusUnavailable": "不可用",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"management": "数据库管理",
|
||||||
|
"managementHelp": "下载或移除元数据归档数据库",
|
||||||
|
"downloadButton": "下载数据库",
|
||||||
|
"downloadingButton": "正在下载...",
|
||||||
|
"downloadedButton": "已下载",
|
||||||
|
"removeButton": "移除数据库",
|
||||||
|
"removingButton": "正在移除...",
|
||||||
|
"downloadSuccess": "元数据归档数据库下载成功",
|
||||||
|
"downloadError": "元数据归档数据库下载失败",
|
||||||
|
"removeSuccess": "元数据归档数据库移除成功",
|
||||||
|
"removeError": "元数据归档数据库移除失败",
|
||||||
|
"removeConfirm": "你确定要移除元数据归档数据库吗?这将删除本地数据库文件,如需使用此功能需重新下载。",
|
||||||
|
"preparing": "正在准备下载...",
|
||||||
|
"connecting": "正在连接下载服务器...",
|
||||||
|
"completed": "已完成",
|
||||||
|
"downloadComplete": "下载成功完成"
|
||||||
|
},
|
||||||
|
"proxySettings": {
|
||||||
|
"enableProxy": "启用应用级代理",
|
||||||
|
"enableProxyHelp": "为此应用启用自定义代理设置,覆盖系统代理设置",
|
||||||
|
"proxyType": "代理类型",
|
||||||
|
"proxyTypeHelp": "选择代理服务器类型 (HTTP, HTTPS, SOCKS4, SOCKS5)",
|
||||||
|
"proxyHost": "代理主机",
|
||||||
|
"proxyHostPlaceholder": "proxy.example.com",
|
||||||
|
"proxyHostHelp": "代理服务器的主机名或IP地址",
|
||||||
|
"proxyPort": "代理端口",
|
||||||
|
"proxyPortPlaceholder": "8080",
|
||||||
|
"proxyPortHelp": "代理服务器的端口号",
|
||||||
|
"proxyUsername": "用户名 (可选)",
|
||||||
|
"proxyUsernamePlaceholder": "用户名",
|
||||||
|
"proxyUsernameHelp": "代理认证的用户名 (如果需要)",
|
||||||
|
"proxyPassword": "密码 (可选)",
|
||||||
|
"proxyPasswordPlaceholder": "密码",
|
||||||
|
"proxyPasswordHelp": "代理认证的密码 (如果需要)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"loras": {
|
"loras": {
|
||||||
@@ -291,8 +447,10 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新模型列表",
|
"title": "刷新模型列表",
|
||||||
"quick": "快速刷新(增量)",
|
"quick": "同步变更",
|
||||||
"full": "完全重建(完整)"
|
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
|
||||||
|
"full": "重建缓存",
|
||||||
|
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
||||||
},
|
},
|
||||||
"fetch": {
|
"fetch": {
|
||||||
"title": "从 Civitai 获取元数据",
|
"title": "从 Civitai 获取元数据",
|
||||||
@@ -313,19 +471,28 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "仅显示收藏",
|
"title": "仅显示收藏",
|
||||||
"action": "收藏"
|
"action": "收藏"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"title": "仅显示可用更新的模型",
|
||||||
|
"action": "更新",
|
||||||
|
"menuLabel": "显示更新选项",
|
||||||
|
"check": "检查更新",
|
||||||
|
"checkTooltip": "检查更新可能耗时。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bulkOperations": {
|
"bulkOperations": {
|
||||||
"selected": "已选中 {count} 项",
|
"selected": "已选中 {count} 项",
|
||||||
"selectedSuffix": "已选中",
|
"selectedSuffix": "已选中",
|
||||||
"viewSelected": "查看已选中",
|
"viewSelected": "查看已选中",
|
||||||
"addTags": "为所有添加标签",
|
"addTags": "为所选中添加标签",
|
||||||
"setBaseModel": "为所有设置基础模型",
|
"setBaseModel": "为所选中设置基础模型",
|
||||||
"copyAll": "复制全部语法",
|
"setContentRating": "为所选中设置内容评级",
|
||||||
"refreshAll": "刷新全部元数据",
|
"copyAll": "复制所选中语法",
|
||||||
"moveAll": "全部移动到文件夹",
|
"refreshAll": "刷新所选中元数据",
|
||||||
|
"checkUpdates": "检查所选更新",
|
||||||
|
"moveAll": "移动所选中到文件夹",
|
||||||
"autoOrganize": "自动整理所选模型",
|
"autoOrganize": "自动整理所选模型",
|
||||||
"deleteAll": "删除所有模型",
|
"deleteAll": "删除选中模型",
|
||||||
"clear": "清除选择",
|
"clear": "清除选择",
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "正在初始化自动整理...",
|
"initializing": "正在初始化自动整理...",
|
||||||
@@ -339,6 +506,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "刷新 Civitai 数据",
|
"refreshMetadata": "刷新 Civitai 数据",
|
||||||
|
"checkUpdates": "检查更新",
|
||||||
"relinkCivitai": "重新关联到 Civitai",
|
"relinkCivitai": "重新关联到 Civitai",
|
||||||
"copySyntax": "复制 LoRA 语法",
|
"copySyntax": "复制 LoRA 语法",
|
||||||
"copyFilename": "复制模型文件名",
|
"copyFilename": "复制模型文件名",
|
||||||
@@ -360,6 +528,9 @@
|
|||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRA 配方",
|
"title": "LoRA 配方",
|
||||||
|
"actions": {
|
||||||
|
"sendCheckpoint": "发送到 ComfyUI"
|
||||||
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "导入",
|
"action": "导入",
|
||||||
@@ -456,13 +627,19 @@
|
|||||||
"title": "Embedding 模型"
|
"title": "Embedding 模型"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "模型根目录",
|
"modelRoot": "根目录",
|
||||||
"collapseAll": "折叠所有文件夹",
|
"collapseAll": "折叠所有文件夹",
|
||||||
"pinSidebar": "固定侧边栏",
|
"pinSidebar": "固定侧边栏",
|
||||||
"unpinSidebar": "取消固定侧边栏",
|
"unpinSidebar": "取消固定侧边栏",
|
||||||
"switchToListView": "切换到列表视图",
|
"switchToListView": "切换到列表视图",
|
||||||
"switchToTreeView": "切换到树状视图",
|
"switchToTreeView": "切换到树状视图",
|
||||||
"collapseAllDisabled": "列表视图下不可用"
|
"recursiveOn": "搜索子文件夹",
|
||||||
|
"recursiveOff": "仅搜索当前文件夹",
|
||||||
|
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
||||||
|
"collapseAllDisabled": "列表视图下不可用",
|
||||||
|
"dragDrop": {
|
||||||
|
"unableToResolveRoot": "无法确定移动的目标路径。"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"title": "统计",
|
"title": "统计",
|
||||||
@@ -537,6 +714,14 @@
|
|||||||
"downloadedPreview": "预览图片已下载",
|
"downloadedPreview": "预览图片已下载",
|
||||||
"downloadingFile": "正在下载 {type} 文件",
|
"downloadingFile": "正在下载 {type} 文件",
|
||||||
"finalizing": "正在完成下载..."
|
"finalizing": "正在完成下载..."
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"currentFile": "当前文件:",
|
||||||
|
"downloading": "下载中:{name}",
|
||||||
|
"transferred": "已下载:{downloaded} / {total}",
|
||||||
|
"transferredSimple": "已下载:{downloaded}",
|
||||||
|
"transferredUnknown": "已下载:--",
|
||||||
|
"speed": "速度:{speed}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
@@ -545,6 +730,7 @@
|
|||||||
"contentRating": {
|
"contentRating": {
|
||||||
"title": "设置内容评级",
|
"title": "设置内容评级",
|
||||||
"current": "当前",
|
"current": "当前",
|
||||||
|
"multiple": "多个值",
|
||||||
"levels": {
|
"levels": {
|
||||||
"pg": "PG",
|
"pg": "PG",
|
||||||
"pg13": "PG13",
|
"pg13": "PG13",
|
||||||
@@ -583,6 +769,12 @@
|
|||||||
"countMessage": "模型将被永久删除。",
|
"countMessage": "模型将被永久删除。",
|
||||||
"action": "全部删除"
|
"action": "全部删除"
|
||||||
},
|
},
|
||||||
|
"checkUpdates": {
|
||||||
|
"title": "检查所有 {type} 的更新?",
|
||||||
|
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
||||||
|
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
|
||||||
|
"action": "检查全部"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "批量添加标签",
|
"title": "批量添加标签",
|
||||||
"description": "为多个模型添加标签",
|
"description": "为多个模型添加标签",
|
||||||
@@ -651,7 +843,12 @@
|
|||||||
"editBaseModel": "编辑基础模型",
|
"editBaseModel": "编辑基础模型",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"viewOnCivitaiText": "在 Civitai 查看",
|
"viewOnCivitaiText": "在 Civitai 查看",
|
||||||
"viewCreatorProfile": "查看创作者主页"
|
"viewCreatorProfile": "查看创作者主页",
|
||||||
|
"openFileLocation": "打开文件位置"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "文件位置已成功打开",
|
||||||
|
"failed": "打开文件位置失败"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
@@ -675,6 +872,7 @@
|
|||||||
"strengthMin": "最小强度",
|
"strengthMin": "最小强度",
|
||||||
"strengthMax": "最大强度",
|
"strengthMax": "最大强度",
|
||||||
"strength": "强度",
|
"strength": "强度",
|
||||||
|
"clipStrength": "Clip 强度",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "数值",
|
"valuePlaceholder": "数值",
|
||||||
"add": "添加"
|
"add": "添加"
|
||||||
@@ -713,13 +911,77 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"examples": "示例",
|
"examples": "示例",
|
||||||
"description": "模型描述",
|
"description": "模型描述",
|
||||||
"recipes": "配方"
|
"recipes": "配方",
|
||||||
|
"versions": "版本"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"noImageSell": "No selling generated content",
|
||||||
|
"noRentCivit": "No Civitai generation",
|
||||||
|
"noRent": "No generation services",
|
||||||
|
"noSell": "No selling models",
|
||||||
|
"creditRequired": "需要创作者署名",
|
||||||
|
"noDerivatives": "禁止分享合并作品",
|
||||||
|
"noReLicense": "需要相同权限",
|
||||||
|
"restrictionsLabel": "许可证限制"
|
||||||
},
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"exampleImages": "正在加载示例图片...",
|
"exampleImages": "正在加载示例图片...",
|
||||||
"description": "正在加载模型描述...",
|
"description": "正在加载模型描述...",
|
||||||
"recipes": "正在加载配方...",
|
"recipes": "正在加载配方...",
|
||||||
"examples": "正在加载示例..."
|
"examples": "正在加载示例...",
|
||||||
|
"versions": "正在加载版本..."
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"heading": "模型版本",
|
||||||
|
"copy": "在一个位置管理该模型的所有版本。",
|
||||||
|
"media": {
|
||||||
|
"placeholder": "无预览"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"unnamed": "未命名版本",
|
||||||
|
"noDetails": "暂无更多信息"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "当前版本",
|
||||||
|
"inLibrary": "已在库中",
|
||||||
|
"newer": "较新的版本",
|
||||||
|
"ignored": "已忽略"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"download": "下载",
|
||||||
|
"delete": "删除",
|
||||||
|
"ignore": "忽略",
|
||||||
|
"unignore": "取消忽略",
|
||||||
|
"resumeModelUpdates": "继续跟踪该模型的更新",
|
||||||
|
"ignoreModelUpdates": "忽略该模型的更新",
|
||||||
|
"viewLocalVersions": "查看所有本地版本",
|
||||||
|
"viewLocalTooltip": "敬请期待"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"label": "基础筛选",
|
||||||
|
"state": {
|
||||||
|
"showAll": "全部版本",
|
||||||
|
"showSameBase": "相同基模型"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"showAllVersions": "切换为显示所有版本",
|
||||||
|
"showSameBaseVersions": "仅显示与当前基模型匹配的版本"
|
||||||
|
},
|
||||||
|
"empty": "没有与当前基模型筛选匹配的版本。"
|
||||||
|
},
|
||||||
|
"empty": "该模型还没有版本历史。",
|
||||||
|
"error": "加载版本失败。",
|
||||||
|
"missingModelId": "该模型缺少 Civitai 模型 ID。",
|
||||||
|
"confirm": {
|
||||||
|
"delete": "从库中删除此版本?"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"modelIgnored": "已忽略该模型的更新",
|
||||||
|
"modelResumed": "已恢复更新跟踪",
|
||||||
|
"versionIgnored": "已忽略该版本的更新",
|
||||||
|
"versionUnignored": "已重新启用该版本",
|
||||||
|
"versionDeleted": "版本已删除"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -826,7 +1088,9 @@
|
|||||||
"loraFailedToSend": "发送 LoRA 到工作流失败",
|
"loraFailedToSend": "发送 LoRA 到工作流失败",
|
||||||
"recipeAdded": "配方已追加到工作流",
|
"recipeAdded": "配方已追加到工作流",
|
||||||
"recipeReplaced": "配方已替换到工作流",
|
"recipeReplaced": "配方已替换到工作流",
|
||||||
"recipeFailedToSend": "发送配方到工作流失败"
|
"recipeFailedToSend": "发送配方到工作流失败",
|
||||||
|
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
||||||
|
"noTargetNodeSelected": "未选择目标节点"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "配方",
|
"recipe": "配方",
|
||||||
@@ -871,6 +1135,11 @@
|
|||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "检查更新",
|
"title": "检查更新",
|
||||||
|
"notificationsTitle": "通知中心",
|
||||||
|
"tabs": {
|
||||||
|
"updates": "更新",
|
||||||
|
"messages": "消息"
|
||||||
|
},
|
||||||
"updateAvailable": "更新可用",
|
"updateAvailable": "更新可用",
|
||||||
"noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。",
|
"noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。",
|
||||||
"currentVersion": "当前版本",
|
"currentVersion": "当前版本",
|
||||||
@@ -902,6 +1171,13 @@
|
|||||||
"nightly": {
|
"nightly": {
|
||||||
"warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。",
|
"warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。",
|
||||||
"enable": "启用 Nightly 更新"
|
"enable": "启用 Nightly 更新"
|
||||||
|
},
|
||||||
|
"banners": {
|
||||||
|
"recent": "最近的通知",
|
||||||
|
"empty": "暂无最近的横幅通知。",
|
||||||
|
"shown": "{time} 显示",
|
||||||
|
"dismissed": "{time} 关闭",
|
||||||
|
"active": "仍在显示"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
@@ -981,6 +1257,9 @@
|
|||||||
"cannotSend": "无法发送配方:缺少配方 ID",
|
"cannotSend": "无法发送配方:缺少配方 ID",
|
||||||
"sendFailed": "发送配方到工作流失败",
|
"sendFailed": "发送配方到工作流失败",
|
||||||
"sendError": "发送配方到工作流出错",
|
"sendError": "发送配方到工作流出错",
|
||||||
|
"missingCheckpointPath": "缺少检查点路径",
|
||||||
|
"missingCheckpointInfo": "缺少检查点信息",
|
||||||
|
"downloadCheckpointFailed": "下载检查点失败:{message}",
|
||||||
"cannotDelete": "无法删除配方:缺少配方 ID",
|
"cannotDelete": "无法删除配方:缺少配方 ID",
|
||||||
"deleteConfirmationError": "显示删除确认出错",
|
"deleteConfirmationError": "显示删除确认出错",
|
||||||
"deletedSuccessfully": "配方删除成功",
|
"deletedSuccessfully": "配方删除成功",
|
||||||
@@ -1017,6 +1296,16 @@
|
|||||||
"bulkBaseModelUpdateSuccess": "成功为 {count} 个模型更新基础模型",
|
"bulkBaseModelUpdateSuccess": "成功为 {count} 个模型更新基础模型",
|
||||||
"bulkBaseModelUpdatePartial": "更新了 {success} 个模型,{failed} 个失败",
|
"bulkBaseModelUpdatePartial": "更新了 {success} 个模型,{failed} 个失败",
|
||||||
"bulkBaseModelUpdateFailed": "为选中模型更新基础模型失败",
|
"bulkBaseModelUpdateFailed": "为选中模型更新基础模型失败",
|
||||||
|
"bulkContentRatingUpdating": "正在为 {count} 个模型更新内容评级...",
|
||||||
|
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
|
||||||
|
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败",
|
||||||
|
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
|
||||||
|
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
|
||||||
|
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
|
||||||
|
"bulkUpdatesNone": "所选 {type} 未发现更新",
|
||||||
|
"bulkUpdatesMissing": "所选 {type} 未关联 Civitai 更新",
|
||||||
|
"bulkUpdatesPartialMissing": "已跳过 {missing} 个未关联 Civitai 的所选 {type}",
|
||||||
|
"bulkUpdatesFailed": "检查所选 {type} 的更新失败:{message}",
|
||||||
"invalidCharactersRemoved": "文件名中的无效字符已移除",
|
"invalidCharactersRemoved": "文件名中的无效字符已移除",
|
||||||
"filenameCannotBeEmpty": "文件名不能为空",
|
"filenameCannotBeEmpty": "文件名不能为空",
|
||||||
"renameFailed": "重命名文件失败:{message}",
|
"renameFailed": "重命名文件失败:{message}",
|
||||||
@@ -1051,6 +1340,8 @@
|
|||||||
"compactModeToggled": "紧凑模式 {state}",
|
"compactModeToggled": "紧凑模式 {state}",
|
||||||
"settingSaveFailed": "保存设置失败:{message}",
|
"settingSaveFailed": "保存设置失败:{message}",
|
||||||
"displayDensitySet": "显示密度已设置为 {density}",
|
"displayDensitySet": "显示密度已设置为 {density}",
|
||||||
|
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||||
|
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||||
"languageChangeFailed": "切换语言失败:{message}",
|
"languageChangeFailed": "切换语言失败:{message}",
|
||||||
"cacheCleared": "缓存文件已成功清除。下次操作将重建缓存。",
|
"cacheCleared": "缓存文件已成功清除。下次操作将重建缓存。",
|
||||||
"cacheClearFailed": "清除缓存失败:{error}",
|
"cacheClearFailed": "清除缓存失败:{error}",
|
||||||
@@ -1075,7 +1366,7 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "无法加载训练词",
|
"loadFailed": "无法加载训练词",
|
||||||
"tooLong": "触发词不能超过30个词",
|
"tooLong": "触发词不能超过100个词",
|
||||||
"tooMany": "最多允许30个触发词",
|
"tooMany": "最多允许30个触发词",
|
||||||
"alreadyExists": "该触发词已存在",
|
"alreadyExists": "该触发词已存在",
|
||||||
"updateSuccess": "触发词更新成功",
|
"updateSuccess": "触发词更新成功",
|
||||||
@@ -1115,6 +1406,7 @@
|
|||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"pathUpdated": "示例图片路径更新成功",
|
"pathUpdated": "示例图片路径更新成功",
|
||||||
|
"pathUpdateFailed": "更新示例图片路径失败:{message}",
|
||||||
"downloadInProgress": "下载已在进行中",
|
"downloadInProgress": "下载已在进行中",
|
||||||
"enterLocationFirst": "请先输入下载位置",
|
"enterLocationFirst": "请先输入下载位置",
|
||||||
"downloadStarted": "示例图片下载已开始",
|
"downloadStarted": "示例图片下载已开始",
|
||||||
@@ -1123,6 +1415,8 @@
|
|||||||
"pauseFailed": "暂停下载失败:{error}",
|
"pauseFailed": "暂停下载失败:{error}",
|
||||||
"downloadResumed": "下载已恢复",
|
"downloadResumed": "下载已恢复",
|
||||||
"resumeFailed": "恢复下载失败:{error}",
|
"resumeFailed": "恢复下载失败:{error}",
|
||||||
|
"downloadStopped": "下载已取消",
|
||||||
|
"stopFailed": "取消下载失败:{error}",
|
||||||
"deleted": "示例图片已删除",
|
"deleted": "示例图片已删除",
|
||||||
"deleteFailed": "删除示例图片失败",
|
"deleteFailed": "删除示例图片失败",
|
||||||
"setPreviewFailed": "设置预览图片失败"
|
"setPreviewFailed": "设置预览图片失败"
|
||||||
@@ -1169,6 +1463,12 @@
|
|||||||
"refreshNow": "立即刷新",
|
"refreshNow": "立即刷新",
|
||||||
"refreshingIn": "将在",
|
"refreshingIn": "将在",
|
||||||
"seconds": "秒后刷新"
|
"seconds": "秒后刷新"
|
||||||
|
},
|
||||||
|
"communitySupport": {
|
||||||
|
"title": "LM 浏览器插件限时优惠 ⚡",
|
||||||
|
"content": "来爱发电为Lora Manager项目发电,支持项目持续开发的同时,获取浏览器插件验证码,按季支付更优惠!支付宝/微信方便支付。感谢支持!🚀",
|
||||||
|
"supportCta": "为LM发电",
|
||||||
|
"learnMore": "浏览器插件教程"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"loading": "載入中...",
|
"loading": "載入中...",
|
||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
"date": "日期",
|
"date": "日期",
|
||||||
"version": "版本"
|
"version": "版本",
|
||||||
|
"enabled": "已啟用",
|
||||||
|
"disabled": "已停用"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"select": "語言",
|
"select": "語言",
|
||||||
@@ -29,7 +31,8 @@
|
|||||||
"japanese": "日本語",
|
"japanese": "日本語",
|
||||||
"korean": "한국어",
|
"korean": "한국어",
|
||||||
"french": "Français",
|
"french": "Français",
|
||||||
"spanish": "Español"
|
"spanish": "Español",
|
||||||
|
"Hebrew": "עברית"
|
||||||
},
|
},
|
||||||
"fileSize": {
|
"fileSize": {
|
||||||
"zero": "0 位元組",
|
"zero": "0 位元組",
|
||||||
@@ -98,7 +101,12 @@
|
|||||||
"checkpointNameCopied": "Checkpoint 名稱已複製",
|
"checkpointNameCopied": "Checkpoint 名稱已複製",
|
||||||
"toggleBlur": "切換模糊",
|
"toggleBlur": "切換模糊",
|
||||||
"show": "顯示",
|
"show": "顯示",
|
||||||
"openExampleImages": "開啟範例圖片資料夾"
|
"openExampleImages": "開啟範例圖片資料夾",
|
||||||
|
"replacePreview": "更換預覽圖",
|
||||||
|
"copyCheckpointName": "複製檢查點名稱",
|
||||||
|
"copyEmbeddingName": "複製嵌入名稱",
|
||||||
|
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
||||||
|
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
||||||
},
|
},
|
||||||
"nsfw": {
|
"nsfw": {
|
||||||
"matureContent": "成熟內容",
|
"matureContent": "成熟內容",
|
||||||
@@ -112,12 +120,45 @@
|
|||||||
"updateFailed": "更新收藏狀態失敗"
|
"updateFailed": "更新收藏狀態失敗"
|
||||||
},
|
},
|
||||||
"sendToWorkflow": {
|
"sendToWorkflow": {
|
||||||
"checkpointNotImplemented": "傳送 checkpoint 到工作流 - 功能尚未實現"
|
"checkpointNotImplemented": "傳送 checkpoint 到工作流 - 功能尚未實現",
|
||||||
|
"missingPath": "無法確定此卡片的模型路徑"
|
||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"checkError": "檢查範例圖片時發生錯誤",
|
"checkError": "檢查範例圖片時發生錯誤",
|
||||||
"missingHash": "缺少模型雜湊資訊。",
|
"missingHash": "缺少模型雜湊資訊。",
|
||||||
"noRemoteImagesAvailable": "此模型在 Civitai 上無遠端範例圖片"
|
"noRemoteImagesAvailable": "此模型在 Civitai 上無遠端範例圖片"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"update": "更新",
|
||||||
|
"updateAvailable": "有可用更新"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"globalContextMenu": {
|
||||||
|
"downloadExampleImages": {
|
||||||
|
"label": "下載範例圖片",
|
||||||
|
"missingPath": "請先設定下載位置再下載範例圖片。",
|
||||||
|
"unavailable": "範例圖片下載目前尚不可用。請在頁面載入完成後再試一次。"
|
||||||
|
},
|
||||||
|
"checkModelUpdates": {
|
||||||
|
"label": "檢查更新",
|
||||||
|
"loading": "正在檢查 {type} 更新...",
|
||||||
|
"success": "找到 {count} 個 {type} 更新",
|
||||||
|
"none": "所有 {type} 都是最新版本",
|
||||||
|
"error": "檢查 {type} 更新失敗:{message}"
|
||||||
|
},
|
||||||
|
"cleanupExampleImages": {
|
||||||
|
"label": "清理範例圖片資料夾",
|
||||||
|
"success": "已將 {count} 個資料夾移至已刪除資料夾",
|
||||||
|
"none": "沒有需要清理的範例圖片資料夾",
|
||||||
|
"partial": "清理完成,有 {failures} 個資料夾略過",
|
||||||
|
"error": "清理範例圖片資料夾失敗:{message}"
|
||||||
|
},
|
||||||
|
"fetchMissingLicenses": {
|
||||||
|
"label": "Refresh license metadata",
|
||||||
|
"loading": "Refreshing license metadata for {typePlural}...",
|
||||||
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
|
"none": "All {typePlural} already have license metadata",
|
||||||
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -154,6 +195,10 @@
|
|||||||
"title": "篩選模型",
|
"title": "篩選模型",
|
||||||
"baseModel": "基礎模型",
|
"baseModel": "基礎模型",
|
||||||
"modelTags": "標籤(前 20)",
|
"modelTags": "標籤(前 20)",
|
||||||
|
"modelTypes": "Model Types",
|
||||||
|
"license": "授權",
|
||||||
|
"noCreditRequired": "無需署名",
|
||||||
|
"allowSellingGeneratedContent": "允許銷售",
|
||||||
"clearAll": "清除所有篩選"
|
"clearAll": "清除所有篩選"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -164,6 +209,7 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"checkUpdates": "檢查更新",
|
"checkUpdates": "檢查更新",
|
||||||
|
"notifications": "通知",
|
||||||
"support": "支援"
|
"support": "支援"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -171,14 +217,30 @@
|
|||||||
"civitaiApiKey": "Civitai API 金鑰",
|
"civitaiApiKey": "Civitai API 金鑰",
|
||||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||||
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
||||||
|
"openSettingsFileLocation": {
|
||||||
|
"label": "開啟設定資料夾",
|
||||||
|
"tooltip": "開啟包含 settings.json 的資料夾",
|
||||||
|
"success": "已開啟 settings.json 資料夾",
|
||||||
|
"failed": "無法開啟 settings.json 資料夾"
|
||||||
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"contentFiltering": "內容過濾",
|
"contentFiltering": "內容過濾",
|
||||||
"videoSettings": "影片設定",
|
"videoSettings": "影片設定",
|
||||||
"layoutSettings": "版面設定",
|
"layoutSettings": "版面設定",
|
||||||
"folderSettings": "資料夾設定",
|
"folderSettings": "資料夾設定",
|
||||||
|
"priorityTags": "優先標籤",
|
||||||
"downloadPathTemplates": "下載路徑範本",
|
"downloadPathTemplates": "下載路徑範本",
|
||||||
"exampleImages": "範例圖片",
|
"exampleImages": "範例圖片",
|
||||||
"misc": "其他"
|
"updateFlags": "更新標記",
|
||||||
|
"autoOrganize": "Auto-organize",
|
||||||
|
"misc": "其他",
|
||||||
|
"metadataArchive": "中繼資料封存資料庫",
|
||||||
|
"storageLocation": "設定位置",
|
||||||
|
"proxySettings": "代理設定"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"locationLabel": "可攜式模式",
|
||||||
|
"locationHelp": "啟用可將 settings.json 保存在儲存庫中;停用則保存在使用者設定目錄。"
|
||||||
},
|
},
|
||||||
"contentFiltering": {
|
"contentFiltering": {
|
||||||
"blurNsfwContent": "模糊 NSFW 內容",
|
"blurNsfwContent": "模糊 NSFW 內容",
|
||||||
@@ -190,6 +252,15 @@
|
|||||||
"autoplayOnHover": "滑鼠懸停自動播放影片",
|
"autoplayOnHover": "滑鼠懸停自動播放影片",
|
||||||
"autoplayOnHoverHelp": "僅在滑鼠懸停時播放影片預覽"
|
"autoplayOnHoverHelp": "僅在滑鼠懸停時播放影片預覽"
|
||||||
},
|
},
|
||||||
|
"autoOrganizeExclusions": {
|
||||||
|
"label": "自動整理排除項目",
|
||||||
|
"placeholder": "範例: curated/*, */backups/*; *_temp.safetensors",
|
||||||
|
"help": "跳過符合這些萬用字元模式的檔案。多個模式請用逗號或分號分隔。",
|
||||||
|
"validation": {
|
||||||
|
"noPatterns": "請輸入至少一個以逗號或分號分隔的模式。",
|
||||||
|
"saveFailed": "無法儲存排除項目:{message}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "顯示密度",
|
"displayDensity": "顯示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -199,23 +270,37 @@
|
|||||||
},
|
},
|
||||||
"displayDensityHelp": "選擇每行顯示卡片數量:",
|
"displayDensityHelp": "選擇每行顯示卡片數量:",
|
||||||
"displayDensityDetails": {
|
"displayDensityDetails": {
|
||||||
"default": "預設:5(1080p)、6(2K)、8(4K)",
|
"default": "5(1080p)、6(2K)、8(4K)",
|
||||||
"medium": "中等:6(1080p)、7(2K)、9(4K)",
|
"medium": "6(1080p)、7(2K)、9(4K)",
|
||||||
"compact": "緊湊:7(1080p)、8(2K)、10(4K)"
|
"compact": "7(1080p)、8(2K)、10(4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。",
|
"displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。",
|
||||||
|
"showFolderSidebar": "顯示資料夾側邊欄",
|
||||||
|
"showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。",
|
||||||
"cardInfoDisplay": "卡片資訊顯示",
|
"cardInfoDisplay": "卡片資訊顯示",
|
||||||
"cardInfoDisplayOptions": {
|
"cardInfoDisplayOptions": {
|
||||||
"always": "永遠顯示",
|
"always": "永遠顯示",
|
||||||
"hover": "滑鼠懸停顯示"
|
"hover": "滑鼠懸停顯示"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕:",
|
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
||||||
"cardInfoDisplayDetails": {
|
"modelCardFooterAction": "模型卡片按鈕操作",
|
||||||
"always": "永遠顯示:標題與頁腳始終可見",
|
"modelCardFooterActionOptions": {
|
||||||
"hover": "滑鼠懸停顯示:標題與頁腳僅在滑鼠懸停時顯示"
|
"exampleImages": "開啟範例圖片",
|
||||||
}
|
"replacePreview": "更換預覽圖"
|
||||||
|
},
|
||||||
|
"modelCardFooterActionHelp": "選擇右下角卡片按鈕的功能",
|
||||||
|
"modelNameDisplay": "模型名稱顯示",
|
||||||
|
"modelNameDisplayOptions": {
|
||||||
|
"modelName": "模型名稱",
|
||||||
|
"fileName": "檔案名稱"
|
||||||
|
},
|
||||||
|
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容"
|
||||||
},
|
},
|
||||||
"folderSettings": {
|
"folderSettings": {
|
||||||
|
"activeLibrary": "使用中的資料庫",
|
||||||
|
"activeLibraryHelp": "在已設定的資料庫之間切換以更新預設資料夾。變更選項會重新載入頁面。",
|
||||||
|
"loadingLibraries": "正在載入資料庫...",
|
||||||
|
"noLibraries": "尚未設定任何資料庫",
|
||||||
"defaultLoraRoot": "預設 LoRA 根目錄",
|
"defaultLoraRoot": "預設 LoRA 根目錄",
|
||||||
"defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄",
|
"defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄",
|
||||||
"defaultCheckpointRoot": "預設 Checkpoint 根目錄",
|
"defaultCheckpointRoot": "預設 Checkpoint 根目錄",
|
||||||
@@ -224,6 +309,26 @@
|
|||||||
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
||||||
"noDefault": "未設定預設"
|
"noDefault": "未設定預設"
|
||||||
},
|
},
|
||||||
|
"priorityTags": {
|
||||||
|
"title": "優先標籤",
|
||||||
|
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
|
||||||
|
"placeholder": "character, concept, style(toon|toon_style)",
|
||||||
|
"helpLinkLabel": "開啟優先標籤說明",
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"embedding": "Embedding"
|
||||||
|
},
|
||||||
|
"saveSuccess": "優先標籤已更新。",
|
||||||
|
"saveError": "更新優先標籤失敗。",
|
||||||
|
"loadingSuggestions": "正在載入建議...",
|
||||||
|
"validation": {
|
||||||
|
"missingClosingParen": "項目 {index} 缺少右括號。",
|
||||||
|
"missingCanonical": "項目 {index} 必須包含正規標籤名稱。",
|
||||||
|
"duplicateCanonical": "正規標籤 \"{tag}\" 出現多於一次。",
|
||||||
|
"unknown": "優先標籤設定無效。"
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadPathTemplates": {
|
"downloadPathTemplates": {
|
||||||
"title": "下載路徑範本",
|
"title": "下載路徑範本",
|
||||||
"help": "設定從 Civitai 下載時不同模型類型的資料夾結構。",
|
"help": "設定從 Civitai 下載時不同模型類型的資料夾結構。",
|
||||||
@@ -236,6 +341,7 @@
|
|||||||
"baseModelFirstTag": "基礎模型 + 第一標籤",
|
"baseModelFirstTag": "基礎模型 + 第一標籤",
|
||||||
"baseModelAuthor": "基礎模型 + 作者",
|
"baseModelAuthor": "基礎模型 + 作者",
|
||||||
"authorFirstTag": "作者 + 第一標籤",
|
"authorFirstTag": "作者 + 第一標籤",
|
||||||
|
"baseModelAuthorFirstTag": "基礎模型 + 作者 + 第一標籤",
|
||||||
"customTemplate": "自訂範本"
|
"customTemplate": "自訂範本"
|
||||||
},
|
},
|
||||||
"customTemplatePlaceholder": "輸入自訂範本(例如:{base_model}/{author}/{first_tag})",
|
"customTemplatePlaceholder": "輸入自訂範本(例如:{base_model}/{author}/{first_tag})",
|
||||||
@@ -270,9 +376,59 @@
|
|||||||
"download": "下載",
|
"download": "下載",
|
||||||
"restartRequired": "需要重新啟動"
|
"restartRequired": "需要重新啟動"
|
||||||
},
|
},
|
||||||
|
"updateFlagStrategy": {
|
||||||
|
"label": "更新標記策略",
|
||||||
|
"help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。",
|
||||||
|
"options": {
|
||||||
|
"sameBase": "依基礎模型匹配更新",
|
||||||
|
"any": "顯示任何可用更新"
|
||||||
|
}
|
||||||
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
|
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
|
||||||
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞"
|
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞"
|
||||||
|
},
|
||||||
|
"metadataArchive": {
|
||||||
|
"enableArchiveDb": "啟用中繼資料封存資料庫",
|
||||||
|
"enableArchiveDbHelp": "使用本機資料庫以存取已從 Civitai 刪除模型的中繼資料。",
|
||||||
|
"status": "狀態",
|
||||||
|
"statusAvailable": "可用",
|
||||||
|
"statusUnavailable": "不可用",
|
||||||
|
"enabled": "已啟用",
|
||||||
|
"management": "資料庫管理",
|
||||||
|
"managementHelp": "下載或移除中繼資料封存資料庫",
|
||||||
|
"downloadButton": "下載資料庫",
|
||||||
|
"downloadingButton": "下載中...",
|
||||||
|
"downloadedButton": "已下載",
|
||||||
|
"removeButton": "移除資料庫",
|
||||||
|
"removingButton": "移除中...",
|
||||||
|
"downloadSuccess": "中繼資料封存資料庫下載成功",
|
||||||
|
"downloadError": "下載中繼資料封存資料庫失敗",
|
||||||
|
"removeSuccess": "中繼資料封存資料庫移除成功",
|
||||||
|
"removeError": "移除中繼資料封存資料庫失敗",
|
||||||
|
"removeConfirm": "您確定要移除中繼資料封存資料庫嗎?這將刪除本機資料庫檔案,若要再次使用此功能需重新下載。",
|
||||||
|
"preparing": "準備下載中...",
|
||||||
|
"connecting": "正在連接下載伺服器...",
|
||||||
|
"completed": "已完成",
|
||||||
|
"downloadComplete": "下載成功完成"
|
||||||
|
},
|
||||||
|
"proxySettings": {
|
||||||
|
"enableProxy": "啟用應用程式代理",
|
||||||
|
"enableProxyHelp": "啟用此應用程式的自訂代理設定,將覆蓋系統代理設定",
|
||||||
|
"proxyType": "代理類型",
|
||||||
|
"proxyTypeHelp": "選擇代理伺服器類型(HTTP、HTTPS、SOCKS4、SOCKS5)",
|
||||||
|
"proxyHost": "代理主機",
|
||||||
|
"proxyHostPlaceholder": "proxy.example.com",
|
||||||
|
"proxyHostHelp": "您的代理伺服器主機名稱或 IP 位址",
|
||||||
|
"proxyPort": "代理埠號",
|
||||||
|
"proxyPortPlaceholder": "8080",
|
||||||
|
"proxyPortHelp": "您的代理伺服器埠號",
|
||||||
|
"proxyUsername": "使用者名稱(選填)",
|
||||||
|
"proxyUsernamePlaceholder": "username",
|
||||||
|
"proxyUsernameHelp": "代理驗證所需的使用者名稱(如有需要)",
|
||||||
|
"proxyPassword": "密碼(選填)",
|
||||||
|
"proxyPasswordPlaceholder": "password",
|
||||||
|
"proxyPasswordHelp": "代理驗證所需的密碼(如有需要)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"loras": {
|
"loras": {
|
||||||
@@ -291,8 +447,10 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理模型列表",
|
"title": "重新整理模型列表",
|
||||||
"quick": "快速刷新(增量)",
|
"quick": "同步變更",
|
||||||
"full": "完整重建(全部)"
|
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
|
||||||
|
"full": "重建快取",
|
||||||
|
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
||||||
},
|
},
|
||||||
"fetch": {
|
"fetch": {
|
||||||
"title": "從 Civitai 取得 metadata",
|
"title": "從 Civitai 取得 metadata",
|
||||||
@@ -313,6 +471,13 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "僅顯示收藏",
|
"title": "僅顯示收藏",
|
||||||
"action": "收藏"
|
"action": "收藏"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"title": "僅顯示可用更新的模型",
|
||||||
|
"action": "更新",
|
||||||
|
"menuLabel": "顯示更新選項",
|
||||||
|
"check": "檢查更新",
|
||||||
|
"checkTooltip": "檢查更新可能耗時。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bulkOperations": {
|
"bulkOperations": {
|
||||||
@@ -321,8 +486,10 @@
|
|||||||
"viewSelected": "檢視已選取",
|
"viewSelected": "檢視已選取",
|
||||||
"addTags": "新增標籤到全部",
|
"addTags": "新增標籤到全部",
|
||||||
"setBaseModel": "設定全部基礎模型",
|
"setBaseModel": "設定全部基礎模型",
|
||||||
|
"setContentRating": "為全部設定內容分級",
|
||||||
"copyAll": "複製全部語法",
|
"copyAll": "複製全部語法",
|
||||||
"refreshAll": "刷新全部 metadata",
|
"refreshAll": "刷新全部 metadata",
|
||||||
|
"checkUpdates": "檢查所選更新",
|
||||||
"moveAll": "全部移動到資料夾",
|
"moveAll": "全部移動到資料夾",
|
||||||
"autoOrganize": "自動整理所選模型",
|
"autoOrganize": "自動整理所選模型",
|
||||||
"deleteAll": "刪除全部模型",
|
"deleteAll": "刪除全部模型",
|
||||||
@@ -339,6 +506,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"refreshMetadata": "刷新 Civitai 資料",
|
"refreshMetadata": "刷新 Civitai 資料",
|
||||||
|
"checkUpdates": "檢查更新",
|
||||||
"relinkCivitai": "重新連結 Civitai",
|
"relinkCivitai": "重新連結 Civitai",
|
||||||
"copySyntax": "複製 LoRA 語法",
|
"copySyntax": "複製 LoRA 語法",
|
||||||
"copyFilename": "複製模型檔名",
|
"copyFilename": "複製模型檔名",
|
||||||
@@ -360,6 +528,9 @@
|
|||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "LoRA 配方",
|
"title": "LoRA 配方",
|
||||||
|
"actions": {
|
||||||
|
"sendCheckpoint": "傳送到 ComfyUI"
|
||||||
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "匯入",
|
"action": "匯入",
|
||||||
@@ -456,13 +627,19 @@
|
|||||||
"title": "Embedding 模型"
|
"title": "Embedding 模型"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "模型根目錄",
|
"modelRoot": "根目錄",
|
||||||
"collapseAll": "全部摺疊資料夾",
|
"collapseAll": "全部摺疊資料夾",
|
||||||
"pinSidebar": "固定側邊欄",
|
"pinSidebar": "固定側邊欄",
|
||||||
"unpinSidebar": "取消固定側邊欄",
|
"unpinSidebar": "取消固定側邊欄",
|
||||||
"switchToListView": "切換至列表檢視",
|
"switchToListView": "切換至列表檢視",
|
||||||
"switchToTreeView": "切換至樹狀檢視",
|
"switchToTreeView": "切換到樹狀檢視",
|
||||||
"collapseAllDisabled": "列表檢視下不可用"
|
"recursiveOn": "搜尋子資料夾",
|
||||||
|
"recursiveOff": "僅搜尋目前資料夾",
|
||||||
|
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
||||||
|
"collapseAllDisabled": "列表檢視下不可用",
|
||||||
|
"dragDrop": {
|
||||||
|
"unableToResolveRoot": "無法確定移動的目標路徑。"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"title": "統計",
|
"title": "統計",
|
||||||
@@ -537,6 +714,14 @@
|
|||||||
"downloadedPreview": "已下載預覽圖片",
|
"downloadedPreview": "已下載預覽圖片",
|
||||||
"downloadingFile": "正在下載 {type} 檔案",
|
"downloadingFile": "正在下載 {type} 檔案",
|
||||||
"finalizing": "完成下載中..."
|
"finalizing": "完成下載中..."
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"currentFile": "目前檔案:",
|
||||||
|
"downloading": "下載中:{name}",
|
||||||
|
"transferred": "已下載:{downloaded} / {total}",
|
||||||
|
"transferredSimple": "已下載:{downloaded}",
|
||||||
|
"transferredUnknown": "已下載:--",
|
||||||
|
"speed": "速度:{speed}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
@@ -545,6 +730,7 @@
|
|||||||
"contentRating": {
|
"contentRating": {
|
||||||
"title": "設定內容分級",
|
"title": "設定內容分級",
|
||||||
"current": "目前",
|
"current": "目前",
|
||||||
|
"multiple": "多個值",
|
||||||
"levels": {
|
"levels": {
|
||||||
"pg": "PG",
|
"pg": "PG",
|
||||||
"pg13": "PG13",
|
"pg13": "PG13",
|
||||||
@@ -583,6 +769,12 @@
|
|||||||
"countMessage": "模型將被永久刪除。",
|
"countMessage": "模型將被永久刪除。",
|
||||||
"action": "全部刪除"
|
"action": "全部刪除"
|
||||||
},
|
},
|
||||||
|
"checkUpdates": {
|
||||||
|
"title": "要檢查所有 {type} 的更新嗎?",
|
||||||
|
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
||||||
|
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
|
||||||
|
"action": "全部檢查"
|
||||||
|
},
|
||||||
"bulkAddTags": {
|
"bulkAddTags": {
|
||||||
"title": "新增標籤到多個模型",
|
"title": "新增標籤到多個模型",
|
||||||
"description": "新增標籤到",
|
"description": "新增標籤到",
|
||||||
@@ -651,7 +843,12 @@
|
|||||||
"editBaseModel": "編輯基礎模型",
|
"editBaseModel": "編輯基礎模型",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"viewOnCivitaiText": "在 Civitai 查看",
|
"viewOnCivitaiText": "在 Civitai 查看",
|
||||||
"viewCreatorProfile": "查看創作者個人檔案"
|
"viewCreatorProfile": "查看創作者個人檔案",
|
||||||
|
"openFileLocation": "開啟檔案位置"
|
||||||
|
},
|
||||||
|
"openFileLocation": {
|
||||||
|
"success": "檔案位置已成功開啟",
|
||||||
|
"failed": "開啟檔案位置失敗"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
@@ -675,6 +872,7 @@
|
|||||||
"strengthMin": "最小強度",
|
"strengthMin": "最小強度",
|
||||||
"strengthMax": "最大強度",
|
"strengthMax": "最大強度",
|
||||||
"strength": "強度",
|
"strength": "強度",
|
||||||
|
"clipStrength": "Clip 強度",
|
||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "數值",
|
"valuePlaceholder": "數值",
|
||||||
"add": "新增"
|
"add": "新增"
|
||||||
@@ -713,13 +911,77 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"examples": "範例圖片",
|
"examples": "範例圖片",
|
||||||
"description": "模型描述",
|
"description": "模型描述",
|
||||||
"recipes": "配方"
|
"recipes": "配方",
|
||||||
|
"versions": "版本"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"noImageSell": "No selling generated content",
|
||||||
|
"noRentCivit": "No Civitai generation",
|
||||||
|
"noRent": "No generation services",
|
||||||
|
"noSell": "No selling models",
|
||||||
|
"creditRequired": "需要創作者標示",
|
||||||
|
"noDerivatives": "禁止分享合併作品",
|
||||||
|
"noReLicense": "需要相同授權",
|
||||||
|
"restrictionsLabel": "授權限制"
|
||||||
},
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"exampleImages": "載入範例圖片中...",
|
"exampleImages": "載入範例圖片中...",
|
||||||
"description": "載入模型描述中...",
|
"description": "載入模型描述中...",
|
||||||
"recipes": "載入配方中...",
|
"recipes": "載入配方中...",
|
||||||
"examples": "載入範例中..."
|
"examples": "載入範例中...",
|
||||||
|
"versions": "載入版本中..."
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"heading": "模型版本",
|
||||||
|
"copy": "在同一位置追蹤並管理此模型的所有版本。",
|
||||||
|
"media": {
|
||||||
|
"placeholder": "無預覽"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"unnamed": "未命名版本",
|
||||||
|
"noDetails": "沒有其他資訊"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"current": "目前版本",
|
||||||
|
"inLibrary": "已在庫中",
|
||||||
|
"newer": "較新版本",
|
||||||
|
"ignored": "已忽略"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"download": "下載",
|
||||||
|
"delete": "刪除",
|
||||||
|
"ignore": "忽略",
|
||||||
|
"unignore": "取消忽略",
|
||||||
|
"resumeModelUpdates": "恢復追蹤此模型的更新",
|
||||||
|
"ignoreModelUpdates": "忽略此模型的更新",
|
||||||
|
"viewLocalVersions": "檢視所有本地版本",
|
||||||
|
"viewLocalTooltip": "敬請期待"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"label": "基礎篩選",
|
||||||
|
"state": {
|
||||||
|
"showAll": "所有版本",
|
||||||
|
"showSameBase": "相同基礎模型"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"showAllVersions": "切換為顯示所有版本",
|
||||||
|
"showSameBaseVersions": "僅顯示與目前基礎模型相符的版本"
|
||||||
|
},
|
||||||
|
"empty": "沒有符合目前基礎模型篩選的版本。"
|
||||||
|
},
|
||||||
|
"empty": "此模型尚無版本歷史。",
|
||||||
|
"error": "載入版本失敗。",
|
||||||
|
"missingModelId": "此模型缺少 Civitai 模型 ID。",
|
||||||
|
"confirm": {
|
||||||
|
"delete": "要從庫中刪除此版本嗎?"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"modelIgnored": "已忽略此模型的更新",
|
||||||
|
"modelResumed": "已恢復更新追蹤",
|
||||||
|
"versionIgnored": "已忽略此版本的更新",
|
||||||
|
"versionUnignored": "已重新啟用此版本",
|
||||||
|
"versionDeleted": "已刪除此版本"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -826,7 +1088,9 @@
|
|||||||
"loraFailedToSend": "傳送 LoRA 到工作流失敗",
|
"loraFailedToSend": "傳送 LoRA 到工作流失敗",
|
||||||
"recipeAdded": "配方已附加到工作流",
|
"recipeAdded": "配方已附加到工作流",
|
||||||
"recipeReplaced": "配方已取代於工作流",
|
"recipeReplaced": "配方已取代於工作流",
|
||||||
"recipeFailedToSend": "傳送配方到工作流失敗"
|
"recipeFailedToSend": "傳送配方到工作流失敗",
|
||||||
|
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
||||||
|
"noTargetNodeSelected": "未選擇目標節點"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "配方",
|
"recipe": "配方",
|
||||||
@@ -871,6 +1135,11 @@
|
|||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "檢查更新",
|
"title": "檢查更新",
|
||||||
|
"notificationsTitle": "通知中心",
|
||||||
|
"tabs": {
|
||||||
|
"updates": "更新",
|
||||||
|
"messages": "訊息"
|
||||||
|
},
|
||||||
"updateAvailable": "有新版本可用",
|
"updateAvailable": "有新版本可用",
|
||||||
"noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。",
|
"noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。",
|
||||||
"currentVersion": "目前版本",
|
"currentVersion": "目前版本",
|
||||||
@@ -902,6 +1171,13 @@
|
|||||||
"nightly": {
|
"nightly": {
|
||||||
"warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。",
|
"warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。",
|
||||||
"enable": "啟用 Nightly 更新"
|
"enable": "啟用 Nightly 更新"
|
||||||
|
},
|
||||||
|
"banners": {
|
||||||
|
"recent": "最新通知",
|
||||||
|
"empty": "目前沒有最近的橫幅通知。",
|
||||||
|
"shown": "{time} 顯示",
|
||||||
|
"dismissed": "{time} 關閉",
|
||||||
|
"active": "仍在顯示"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
@@ -981,6 +1257,9 @@
|
|||||||
"cannotSend": "無法傳送配方:缺少配方 ID",
|
"cannotSend": "無法傳送配方:缺少配方 ID",
|
||||||
"sendFailed": "傳送配方到工作流失敗",
|
"sendFailed": "傳送配方到工作流失敗",
|
||||||
"sendError": "傳送配方到工作流錯誤",
|
"sendError": "傳送配方到工作流錯誤",
|
||||||
|
"missingCheckpointPath": "缺少檢查點路徑",
|
||||||
|
"missingCheckpointInfo": "缺少檢查點資訊",
|
||||||
|
"downloadCheckpointFailed": "下載檢查點失敗:{message}",
|
||||||
"cannotDelete": "無法刪除配方:缺少配方 ID",
|
"cannotDelete": "無法刪除配方:缺少配方 ID",
|
||||||
"deleteConfirmationError": "顯示刪除確認時發生錯誤",
|
"deleteConfirmationError": "顯示刪除確認時發生錯誤",
|
||||||
"deletedSuccessfully": "配方已成功刪除",
|
"deletedSuccessfully": "配方已成功刪除",
|
||||||
@@ -1017,6 +1296,16 @@
|
|||||||
"bulkBaseModelUpdateSuccess": "已成功為 {count} 個模型更新基礎模型",
|
"bulkBaseModelUpdateSuccess": "已成功為 {count} 個模型更新基礎模型",
|
||||||
"bulkBaseModelUpdatePartial": "已更新 {success} 個模型,{failed} 個模型失敗",
|
"bulkBaseModelUpdatePartial": "已更新 {success} 個模型,{failed} 個模型失敗",
|
||||||
"bulkBaseModelUpdateFailed": "更新所選模型的基礎模型失敗",
|
"bulkBaseModelUpdateFailed": "更新所選模型的基礎模型失敗",
|
||||||
|
"bulkContentRatingUpdating": "正在為 {count} 個模型更新內容分級...",
|
||||||
|
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
|
||||||
|
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗",
|
||||||
|
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
|
||||||
|
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
|
||||||
|
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
|
||||||
|
"bulkUpdatesNone": "所選 {type} 未找到更新",
|
||||||
|
"bulkUpdatesMissing": "所選 {type} 未連結 Civitai 更新",
|
||||||
|
"bulkUpdatesPartialMissing": "已略過 {missing} 個未連結 Civitai 的所選 {type}",
|
||||||
|
"bulkUpdatesFailed": "檢查所選 {type} 更新失敗:{message}",
|
||||||
"invalidCharactersRemoved": "已移除檔名中的無效字元",
|
"invalidCharactersRemoved": "已移除檔名中的無效字元",
|
||||||
"filenameCannotBeEmpty": "檔案名稱不可為空",
|
"filenameCannotBeEmpty": "檔案名稱不可為空",
|
||||||
"renameFailed": "重新命名檔案失敗:{message}",
|
"renameFailed": "重新命名檔案失敗:{message}",
|
||||||
@@ -1051,6 +1340,8 @@
|
|||||||
"compactModeToggled": "緊湊模式已{state}",
|
"compactModeToggled": "緊湊模式已{state}",
|
||||||
"settingSaveFailed": "儲存設定失敗:{message}",
|
"settingSaveFailed": "儲存設定失敗:{message}",
|
||||||
"displayDensitySet": "顯示密度已設為 {density}",
|
"displayDensitySet": "顯示密度已設為 {density}",
|
||||||
|
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||||
|
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||||
"languageChangeFailed": "切換語言失敗:{message}",
|
"languageChangeFailed": "切換語言失敗:{message}",
|
||||||
"cacheCleared": "快取檔案已成功清除。快取將於下次操作時重建。",
|
"cacheCleared": "快取檔案已成功清除。快取將於下次操作時重建。",
|
||||||
"cacheClearFailed": "清除快取失敗:{error}",
|
"cacheClearFailed": "清除快取失敗:{error}",
|
||||||
@@ -1075,7 +1366,7 @@
|
|||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"loadFailed": "無法載入訓練詞",
|
"loadFailed": "無法載入訓練詞",
|
||||||
"tooLong": "觸發詞不可超過 30 個字",
|
"tooLong": "觸發詞不可超過 100 個字",
|
||||||
"tooMany": "最多允許 30 個觸發詞",
|
"tooMany": "最多允許 30 個觸發詞",
|
||||||
"alreadyExists": "此觸發詞已存在",
|
"alreadyExists": "此觸發詞已存在",
|
||||||
"updateSuccess": "觸發詞已更新",
|
"updateSuccess": "觸發詞已更新",
|
||||||
@@ -1115,6 +1406,7 @@
|
|||||||
},
|
},
|
||||||
"exampleImages": {
|
"exampleImages": {
|
||||||
"pathUpdated": "範例圖片路徑已更新",
|
"pathUpdated": "範例圖片路徑已更新",
|
||||||
|
"pathUpdateFailed": "更新範例圖片路徑失敗:{message}",
|
||||||
"downloadInProgress": "下載已在進行中",
|
"downloadInProgress": "下載已在進行中",
|
||||||
"enterLocationFirst": "請先輸入下載位置",
|
"enterLocationFirst": "請先輸入下載位置",
|
||||||
"downloadStarted": "範例圖片下載已開始",
|
"downloadStarted": "範例圖片下載已開始",
|
||||||
@@ -1123,6 +1415,8 @@
|
|||||||
"pauseFailed": "暫停下載失敗:{error}",
|
"pauseFailed": "暫停下載失敗:{error}",
|
||||||
"downloadResumed": "下載已恢復",
|
"downloadResumed": "下載已恢復",
|
||||||
"resumeFailed": "恢復下載失敗:{error}",
|
"resumeFailed": "恢復下載失敗:{error}",
|
||||||
|
"downloadStopped": "下載已取消",
|
||||||
|
"stopFailed": "取消下載失敗:{error}",
|
||||||
"deleted": "範例圖片已刪除",
|
"deleted": "範例圖片已刪除",
|
||||||
"deleteFailed": "刪除範例圖片失敗",
|
"deleteFailed": "刪除範例圖片失敗",
|
||||||
"setPreviewFailed": "設定預覽圖片失敗"
|
"setPreviewFailed": "設定預覽圖片失敗"
|
||||||
@@ -1169,6 +1463,12 @@
|
|||||||
"refreshNow": "立即重新整理",
|
"refreshNow": "立即重新整理",
|
||||||
"refreshingIn": "將於",
|
"refreshingIn": "將於",
|
||||||
"seconds": "秒後重新整理"
|
"seconds": "秒後重新整理"
|
||||||
|
},
|
||||||
|
"communitySupport": {
|
||||||
|
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||||
|
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||||
|
"supportCta": "Support on Ko-fi",
|
||||||
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2575
package-lock.json
generated
Normal file
2575
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "comfyui-lora-manager-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "node scripts/run_frontend_coverage.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jsdom": "^24.0.0",
|
||||||
|
"vitest": "^1.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Project namespace package."""
|
||||||
|
|
||||||
|
# pytest's internal compatibility layer still imports ``py.path.local`` from the
|
||||||
|
# historical ``py`` dependency. Because this project reuses the ``py`` package
|
||||||
|
# name, we expose a minimal shim so ``py.path.local`` resolves to ``pathlib.Path``
|
||||||
|
# during test runs without pulling in the external dependency.
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
path = SimpleNamespace(local=Path)
|
||||||
|
|
||||||
|
__all__ = ["path"]
|
||||||
|
|||||||
507
py/config.py
507
py/config.py
@@ -1,17 +1,74 @@
|
|||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
from pathlib import Path
|
||||||
import folder_paths # type: ignore
|
import folder_paths # type: ignore
|
||||||
from typing import List
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set
|
||||||
import logging
|
import logging
|
||||||
import sys
|
|
||||||
import json
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
# Check if running in standalone mode
|
from .utils.settings_paths import ensure_settings_file, load_settings_template
|
||||||
standalone_mode = 'nodes' not in sys.modules
|
|
||||||
|
# Use an environment variable to control standalone mode
|
||||||
|
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_folder_paths_for_comparison(
|
||||||
|
folder_paths: Mapping[str, Iterable[str]]
|
||||||
|
) -> Dict[str, Set[str]]:
|
||||||
|
"""Normalize folder paths for comparison across libraries."""
|
||||||
|
|
||||||
|
normalized: Dict[str, Set[str]] = {}
|
||||||
|
for key, values in folder_paths.items():
|
||||||
|
if isinstance(values, str):
|
||||||
|
candidate_values: Iterable[str] = [values]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
candidate_values = iter(values)
|
||||||
|
except TypeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_values: Set[str] = set()
|
||||||
|
for value in candidate_values:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
continue
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
normalized_values.add(os.path.normcase(os.path.normpath(stripped)))
|
||||||
|
|
||||||
|
if normalized_values:
|
||||||
|
normalized[key] = normalized_values
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_library_folder_paths(
|
||||||
|
library_payload: Mapping[str, Any]
|
||||||
|
) -> Dict[str, Set[str]]:
|
||||||
|
"""Return normalized folder paths extracted from a library payload."""
|
||||||
|
|
||||||
|
folder_paths = library_payload.get("folder_paths")
|
||||||
|
if isinstance(folder_paths, Mapping):
|
||||||
|
return _normalize_folder_paths_for_comparison(folder_paths)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_template_folder_paths() -> Dict[str, Set[str]]:
|
||||||
|
"""Return normalized folder paths defined in the bundled template."""
|
||||||
|
|
||||||
|
template_payload = load_settings_template()
|
||||||
|
if not template_payload:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
folder_paths = template_payload.get("folder_paths")
|
||||||
|
if isinstance(folder_paths, Mapping):
|
||||||
|
return _normalize_folder_paths_for_comparison(folder_paths)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""Global configuration for LoRA Manager"""
|
"""Global configuration for LoRA Manager"""
|
||||||
|
|
||||||
@@ -20,9 +77,9 @@ class Config:
|
|||||||
self.static_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static')
|
self.static_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static')
|
||||||
self.i18n_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'locales')
|
self.i18n_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'locales')
|
||||||
# Path mapping dictionary, target to link mapping
|
# Path mapping dictionary, target to link mapping
|
||||||
self._path_mappings = {}
|
self._path_mappings: Dict[str, str] = {}
|
||||||
# Static route mapping dictionary, target to route mapping
|
# Normalized preview root directories used to validate preview access
|
||||||
self._route_mappings = {}
|
self._preview_root_paths: Set[Path] = set()
|
||||||
self.loras_roots = self._init_lora_paths()
|
self.loras_roots = self._init_lora_paths()
|
||||||
self.checkpoints_roots = None
|
self.checkpoints_roots = None
|
||||||
self.unet_roots = None
|
self.unet_roots = None
|
||||||
@@ -31,45 +88,118 @@ class Config:
|
|||||||
self.embeddings_roots = self._init_embedding_paths()
|
self.embeddings_roots = self._init_embedding_paths()
|
||||||
# Scan symbolic links during initialization
|
# Scan symbolic links during initialization
|
||||||
self._scan_symbolic_links()
|
self._scan_symbolic_links()
|
||||||
|
self._rebuild_preview_roots()
|
||||||
|
|
||||||
if not standalone_mode:
|
if not standalone_mode:
|
||||||
# Save the paths to settings.json when running in ComfyUI mode
|
# Save the paths to settings.json when running in ComfyUI mode
|
||||||
self.save_folder_paths_to_settings()
|
self.save_folder_paths_to_settings()
|
||||||
|
|
||||||
def save_folder_paths_to_settings(self):
|
def save_folder_paths_to_settings(self):
|
||||||
"""Save folder paths to settings.json for standalone mode to use later"""
|
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
||||||
try:
|
try:
|
||||||
# Check if we're running in ComfyUI mode (not standalone)
|
ensure_settings_file(logger)
|
||||||
# Load existing settings
|
from .services.settings_manager import get_settings_manager
|
||||||
settings_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'settings.json')
|
|
||||||
settings = {}
|
|
||||||
if os.path.exists(settings_path):
|
|
||||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
|
||||||
settings = json.load(f)
|
|
||||||
|
|
||||||
# Update settings with paths
|
|
||||||
settings['folder_paths'] = {
|
|
||||||
'loras': self.loras_roots,
|
|
||||||
'checkpoints': self.checkpoints_roots,
|
|
||||||
'unet': self.unet_roots,
|
|
||||||
'embeddings': self.embeddings_roots,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add default roots if there's only one item and key doesn't exist
|
|
||||||
if len(self.loras_roots) == 1 and "default_lora_root" not in settings:
|
|
||||||
settings["default_lora_root"] = self.loras_roots[0]
|
|
||||||
|
|
||||||
if self.checkpoints_roots and len(self.checkpoints_roots) == 1 and "default_checkpoint_root" not in settings:
|
|
||||||
settings["default_checkpoint_root"] = self.checkpoints_roots[0]
|
|
||||||
|
|
||||||
if self.embeddings_roots and len(self.embeddings_roots) == 1 and "default_embedding_root" not in settings:
|
settings_service = get_settings_manager()
|
||||||
settings["default_embedding_root"] = self.embeddings_roots[0]
|
libraries = settings_service.get_libraries()
|
||||||
|
comfy_library = libraries.get("comfyui", {})
|
||||||
# Save settings
|
default_library = libraries.get("default", {})
|
||||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(settings, f, indent=2)
|
template_folder_paths = _get_template_folder_paths()
|
||||||
|
default_library_paths: Dict[str, Set[str]] = {}
|
||||||
logger.info("Saved folder paths to settings.json")
|
if isinstance(default_library, Mapping):
|
||||||
|
default_library_paths = _normalize_library_folder_paths(default_library)
|
||||||
|
|
||||||
|
libraries_changed = False
|
||||||
|
if (
|
||||||
|
isinstance(default_library, Mapping)
|
||||||
|
and template_folder_paths
|
||||||
|
and default_library_paths == template_folder_paths
|
||||||
|
):
|
||||||
|
if "comfyui" in libraries:
|
||||||
|
try:
|
||||||
|
settings_service.delete_library("default")
|
||||||
|
libraries_changed = True
|
||||||
|
logger.info("Removed template 'default' library entry")
|
||||||
|
except Exception as delete_error:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to delete template 'default' library: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
settings_service.rename_library("default", "comfyui")
|
||||||
|
libraries_changed = True
|
||||||
|
logger.info("Renamed template 'default' library to 'comfyui'")
|
||||||
|
except Exception as rename_error:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to rename template 'default' library: %s",
|
||||||
|
rename_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
if libraries_changed:
|
||||||
|
libraries = settings_service.get_libraries()
|
||||||
|
comfy_library = libraries.get("comfyui", {})
|
||||||
|
default_library = libraries.get("default", {})
|
||||||
|
|
||||||
|
target_folder_paths = {
|
||||||
|
'loras': list(self.loras_roots),
|
||||||
|
'checkpoints': list(self.checkpoints_roots or []),
|
||||||
|
'unet': list(self.unet_roots or []),
|
||||||
|
'embeddings': list(self.embeddings_roots or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized_target_paths = _normalize_folder_paths_for_comparison(target_folder_paths)
|
||||||
|
|
||||||
|
normalized_default_paths: Optional[Dict[str, Set[str]]] = None
|
||||||
|
if isinstance(default_library, Mapping):
|
||||||
|
normalized_default_paths = _normalize_library_folder_paths(default_library)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not comfy_library
|
||||||
|
and default_library
|
||||||
|
and normalized_target_paths
|
||||||
|
and normalized_default_paths == normalized_target_paths
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
settings_service.rename_library("default", "comfyui")
|
||||||
|
logger.info("Renamed legacy 'default' library to 'comfyui'")
|
||||||
|
libraries = settings_service.get_libraries()
|
||||||
|
comfy_library = libraries.get("comfyui", {})
|
||||||
|
except Exception as rename_error:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to rename legacy 'default' library: %s", rename_error
|
||||||
|
)
|
||||||
|
|
||||||
|
default_lora_root = comfy_library.get("default_lora_root", "")
|
||||||
|
if not default_lora_root and len(self.loras_roots) == 1:
|
||||||
|
default_lora_root = self.loras_roots[0]
|
||||||
|
|
||||||
|
default_checkpoint_root = comfy_library.get("default_checkpoint_root", "")
|
||||||
|
if (not default_checkpoint_root and self.checkpoints_roots and
|
||||||
|
len(self.checkpoints_roots) == 1):
|
||||||
|
default_checkpoint_root = self.checkpoints_roots[0]
|
||||||
|
|
||||||
|
default_embedding_root = comfy_library.get("default_embedding_root", "")
|
||||||
|
if (not default_embedding_root and self.embeddings_roots and
|
||||||
|
len(self.embeddings_roots) == 1):
|
||||||
|
default_embedding_root = self.embeddings_roots[0]
|
||||||
|
|
||||||
|
metadata = dict(comfy_library.get("metadata", {}))
|
||||||
|
metadata.setdefault("display_name", "ComfyUI")
|
||||||
|
metadata["source"] = "comfyui"
|
||||||
|
|
||||||
|
settings_service.upsert_library(
|
||||||
|
"comfyui",
|
||||||
|
folder_paths=target_folder_paths,
|
||||||
|
default_lora_root=default_lora_root,
|
||||||
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
|
default_embedding_root=default_embedding_root,
|
||||||
|
metadata=metadata,
|
||||||
|
activate=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Updated 'comfyui' library with current folder paths")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to save folder paths: {e}")
|
logger.warning(f"Failed to save folder paths: {e}")
|
||||||
|
|
||||||
@@ -126,12 +256,65 @@ class Config:
|
|||||||
# Keep the original mapping: target path -> link path
|
# Keep the original mapping: target path -> link path
|
||||||
self._path_mappings[normalized_target] = normalized_link
|
self._path_mappings[normalized_target] = normalized_link
|
||||||
logger.info(f"Added path mapping: {normalized_target} -> {normalized_link}")
|
logger.info(f"Added path mapping: {normalized_target} -> {normalized_link}")
|
||||||
|
self._preview_root_paths.update(self._expand_preview_root(normalized_target))
|
||||||
|
self._preview_root_paths.update(self._expand_preview_root(normalized_link))
|
||||||
|
|
||||||
def add_route_mapping(self, path: str, route: str):
|
def _expand_preview_root(self, path: str) -> Set[Path]:
|
||||||
"""Add a static route mapping"""
|
"""Return normalized ``Path`` objects representing a preview root."""
|
||||||
normalized_path = os.path.normpath(path).replace(os.sep, '/')
|
|
||||||
self._route_mappings[normalized_path] = route
|
roots: Set[Path] = set()
|
||||||
# logger.info(f"Added route mapping: {normalized_path} -> {route}")
|
if not path:
|
||||||
|
return roots
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_path = Path(path).expanduser()
|
||||||
|
except Exception:
|
||||||
|
return roots
|
||||||
|
|
||||||
|
if raw_path.is_absolute():
|
||||||
|
roots.add(raw_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = raw_path.resolve(strict=False)
|
||||||
|
except RuntimeError:
|
||||||
|
resolved = raw_path.absolute()
|
||||||
|
roots.add(resolved)
|
||||||
|
|
||||||
|
try:
|
||||||
|
real_path = raw_path.resolve()
|
||||||
|
except (FileNotFoundError, RuntimeError):
|
||||||
|
real_path = resolved
|
||||||
|
roots.add(real_path)
|
||||||
|
|
||||||
|
normalized: Set[Path] = set()
|
||||||
|
for candidate in roots:
|
||||||
|
if candidate.is_absolute():
|
||||||
|
normalized.add(candidate)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
normalized.add(candidate.resolve(strict=False))
|
||||||
|
except RuntimeError:
|
||||||
|
normalized.add(candidate.absolute())
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _rebuild_preview_roots(self) -> None:
|
||||||
|
"""Recompute the cache of directories permitted for previews."""
|
||||||
|
|
||||||
|
preview_roots: Set[Path] = set()
|
||||||
|
|
||||||
|
for root in self.loras_roots or []:
|
||||||
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
|
for root in self.base_models_roots or []:
|
||||||
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
|
for root in self.embeddings_roots or []:
|
||||||
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
|
|
||||||
|
for target, link in self._path_mappings.items():
|
||||||
|
preview_roots.update(self._expand_preview_root(target))
|
||||||
|
preview_roots.update(self._expand_preview_root(link))
|
||||||
|
|
||||||
|
self._preview_root_paths = {path for path in preview_roots if path.is_absolute()}
|
||||||
|
|
||||||
def map_path_to_link(self, path: str) -> str:
|
def map_path_to_link(self, path: str) -> str:
|
||||||
"""Map a target path back to its symbolic link path"""
|
"""Map a target path back to its symbolic link path"""
|
||||||
@@ -155,31 +338,93 @@ class Config:
|
|||||||
return mapped_path
|
return mapped_path
|
||||||
return link_path
|
return link_path
|
||||||
|
|
||||||
|
def _dedupe_existing_paths(self, raw_paths: Iterable[str]) -> Dict[str, str]:
|
||||||
|
dedup: Dict[str, str] = {}
|
||||||
|
for path in raw_paths:
|
||||||
|
if not isinstance(path, str):
|
||||||
|
continue
|
||||||
|
if not os.path.exists(path):
|
||||||
|
continue
|
||||||
|
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
||||||
|
normalized = os.path.normpath(path).replace(os.sep, '/')
|
||||||
|
if real_path not in dedup:
|
||||||
|
dedup[real_path] = normalized
|
||||||
|
return dedup
|
||||||
|
|
||||||
|
def _prepare_lora_paths(self, raw_paths: Iterable[str]) -> List[str]:
|
||||||
|
path_map = self._dedupe_existing_paths(raw_paths)
|
||||||
|
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
|
||||||
|
|
||||||
|
for original_path in unique_paths:
|
||||||
|
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
|
||||||
|
if real_path != original_path:
|
||||||
|
self.add_path_mapping(original_path, real_path)
|
||||||
|
|
||||||
|
return unique_paths
|
||||||
|
|
||||||
|
def _prepare_checkpoint_paths(
|
||||||
|
self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str]
|
||||||
|
) -> List[str]:
|
||||||
|
checkpoint_map = self._dedupe_existing_paths(checkpoint_paths)
|
||||||
|
unet_map = self._dedupe_existing_paths(unet_paths)
|
||||||
|
|
||||||
|
merged_map: Dict[str, str] = {}
|
||||||
|
for real_path, original in {**checkpoint_map, **unet_map}.items():
|
||||||
|
if real_path not in merged_map:
|
||||||
|
merged_map[real_path] = original
|
||||||
|
|
||||||
|
unique_paths = sorted(merged_map.values(), key=lambda p: p.lower())
|
||||||
|
|
||||||
|
checkpoint_values = set(checkpoint_map.values())
|
||||||
|
unet_values = set(unet_map.values())
|
||||||
|
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_values]
|
||||||
|
self.unet_roots = [p for p in unique_paths if p in unet_values]
|
||||||
|
|
||||||
|
for original_path in unique_paths:
|
||||||
|
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
|
||||||
|
if real_path != original_path:
|
||||||
|
self.add_path_mapping(original_path, real_path)
|
||||||
|
|
||||||
|
return unique_paths
|
||||||
|
|
||||||
|
def _prepare_embedding_paths(self, raw_paths: Iterable[str]) -> List[str]:
|
||||||
|
path_map = self._dedupe_existing_paths(raw_paths)
|
||||||
|
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
|
||||||
|
|
||||||
|
for original_path in unique_paths:
|
||||||
|
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
|
||||||
|
if real_path != original_path:
|
||||||
|
self.add_path_mapping(original_path, real_path)
|
||||||
|
|
||||||
|
return unique_paths
|
||||||
|
|
||||||
|
def _apply_library_paths(self, folder_paths: Mapping[str, Iterable[str]]) -> None:
|
||||||
|
self._path_mappings.clear()
|
||||||
|
self._preview_root_paths = set()
|
||||||
|
|
||||||
|
lora_paths = folder_paths.get('loras', []) or []
|
||||||
|
checkpoint_paths = folder_paths.get('checkpoints', []) or []
|
||||||
|
unet_paths = folder_paths.get('unet', []) or []
|
||||||
|
embedding_paths = folder_paths.get('embeddings', []) or []
|
||||||
|
|
||||||
|
self.loras_roots = self._prepare_lora_paths(lora_paths)
|
||||||
|
self.base_models_roots = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
||||||
|
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
|
||||||
|
|
||||||
|
self._scan_symbolic_links()
|
||||||
|
self._rebuild_preview_roots()
|
||||||
|
|
||||||
def _init_lora_paths(self) -> List[str]:
|
def _init_lora_paths(self) -> List[str]:
|
||||||
"""Initialize and validate LoRA paths from ComfyUI settings"""
|
"""Initialize and validate LoRA paths from ComfyUI settings"""
|
||||||
try:
|
try:
|
||||||
raw_paths = folder_paths.get_folder_paths("loras")
|
raw_paths = folder_paths.get_folder_paths("loras")
|
||||||
|
unique_paths = self._prepare_lora_paths(raw_paths)
|
||||||
# Normalize and resolve symlinks, store mapping from resolved -> original
|
|
||||||
path_map = {}
|
|
||||||
for path in raw_paths:
|
|
||||||
if os.path.exists(path):
|
|
||||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
|
||||||
path_map[real_path] = path_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
|
|
||||||
|
|
||||||
# Now sort and use only the deduplicated real paths
|
|
||||||
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
|
|
||||||
logger.info("Found LoRA roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
|
logger.info("Found LoRA roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
|
||||||
|
|
||||||
if not unique_paths:
|
if not unique_paths:
|
||||||
logger.warning("No valid loras folders found in ComfyUI configuration")
|
logger.warning("No valid loras folders found in ComfyUI configuration")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
for original_path in unique_paths:
|
|
||||||
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
|
|
||||||
if real_path != original_path:
|
|
||||||
self.add_path_mapping(original_path, real_path)
|
|
||||||
|
|
||||||
return unique_paths
|
return unique_paths
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error initializing LoRA paths: {e}")
|
logger.warning(f"Error initializing LoRA paths: {e}")
|
||||||
@@ -188,52 +433,17 @@ class Config:
|
|||||||
def _init_checkpoint_paths(self) -> List[str]:
|
def _init_checkpoint_paths(self) -> List[str]:
|
||||||
"""Initialize and validate checkpoint paths from ComfyUI settings"""
|
"""Initialize and validate checkpoint paths from ComfyUI settings"""
|
||||||
try:
|
try:
|
||||||
# Get checkpoint paths from folder_paths
|
|
||||||
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
|
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
|
||||||
raw_unet_paths = folder_paths.get_folder_paths("unet")
|
raw_unet_paths = folder_paths.get_folder_paths("unet")
|
||||||
|
unique_paths = self._prepare_checkpoint_paths(raw_checkpoint_paths, raw_unet_paths)
|
||||||
# Normalize and resolve symlinks for checkpoints, store mapping from resolved -> original
|
|
||||||
checkpoint_map = {}
|
|
||||||
for path in raw_checkpoint_paths:
|
|
||||||
if os.path.exists(path):
|
|
||||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
|
||||||
checkpoint_map[real_path] = checkpoint_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
|
|
||||||
|
|
||||||
# Normalize and resolve symlinks for unet, store mapping from resolved -> original
|
|
||||||
unet_map = {}
|
|
||||||
for path in raw_unet_paths:
|
|
||||||
if os.path.exists(path):
|
|
||||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
|
||||||
unet_map[real_path] = unet_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
|
|
||||||
|
|
||||||
# Merge both maps and deduplicate by real path
|
|
||||||
merged_map = {}
|
|
||||||
for real_path, orig_path in {**checkpoint_map, **unet_map}.items():
|
|
||||||
if real_path not in merged_map:
|
|
||||||
merged_map[real_path] = orig_path
|
|
||||||
|
|
||||||
# Now sort and use only the deduplicated real paths
|
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
|
||||||
unique_paths = sorted(merged_map.values(), key=lambda p: p.lower())
|
|
||||||
|
if not unique_paths:
|
||||||
# Split back into checkpoints and unet roots for class properties
|
|
||||||
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_map.values()]
|
|
||||||
self.unet_roots = [p for p in unique_paths if p in unet_map.values()]
|
|
||||||
|
|
||||||
all_paths = unique_paths
|
|
||||||
|
|
||||||
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(all_paths) if all_paths else "[]"))
|
|
||||||
|
|
||||||
if not all_paths:
|
|
||||||
logger.warning("No valid checkpoint folders found in ComfyUI configuration")
|
logger.warning("No valid checkpoint folders found in ComfyUI configuration")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Initialize path mappings
|
return unique_paths
|
||||||
for original_path in all_paths:
|
|
||||||
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
|
|
||||||
if real_path != original_path:
|
|
||||||
self.add_path_mapping(original_path, real_path)
|
|
||||||
|
|
||||||
return all_paths
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error initializing checkpoint paths: {e}")
|
logger.warning(f"Error initializing checkpoint paths: {e}")
|
||||||
return []
|
return []
|
||||||
@@ -242,27 +452,13 @@ class Config:
|
|||||||
"""Initialize and validate embedding paths from ComfyUI settings"""
|
"""Initialize and validate embedding paths from ComfyUI settings"""
|
||||||
try:
|
try:
|
||||||
raw_paths = folder_paths.get_folder_paths("embeddings")
|
raw_paths = folder_paths.get_folder_paths("embeddings")
|
||||||
|
unique_paths = self._prepare_embedding_paths(raw_paths)
|
||||||
# Normalize and resolve symlinks, store mapping from resolved -> original
|
|
||||||
path_map = {}
|
|
||||||
for path in raw_paths:
|
|
||||||
if os.path.exists(path):
|
|
||||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
|
||||||
path_map[real_path] = path_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
|
|
||||||
|
|
||||||
# Now sort and use only the deduplicated real paths
|
|
||||||
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
|
|
||||||
logger.info("Found embedding roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
|
logger.info("Found embedding roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
|
||||||
|
|
||||||
if not unique_paths:
|
if not unique_paths:
|
||||||
logger.warning("No valid embeddings folders found in ComfyUI configuration")
|
logger.warning("No valid embeddings folders found in ComfyUI configuration")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
for original_path in unique_paths:
|
|
||||||
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
|
|
||||||
if real_path != original_path:
|
|
||||||
self.add_path_mapping(original_path, real_path)
|
|
||||||
|
|
||||||
return unique_paths
|
return unique_paths
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error initializing embedding paths: {e}")
|
logger.warning(f"Error initializing embedding paths: {e}")
|
||||||
@@ -271,25 +467,62 @@ class Config:
|
|||||||
def get_preview_static_url(self, preview_path: str) -> str:
|
def get_preview_static_url(self, preview_path: str) -> str:
|
||||||
if not preview_path:
|
if not preview_path:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
real_path = os.path.realpath(preview_path).replace(os.sep, '/')
|
normalized = os.path.normpath(preview_path).replace(os.sep, '/')
|
||||||
|
encoded_path = urllib.parse.quote(normalized, safe='')
|
||||||
# Find longest matching path (most specific match)
|
return f'/api/lm/previews?path={encoded_path}'
|
||||||
best_match = ""
|
|
||||||
best_route = ""
|
def is_preview_path_allowed(self, preview_path: str) -> bool:
|
||||||
|
"""Return ``True`` if ``preview_path`` is within an allowed directory."""
|
||||||
for path, route in self._route_mappings.items():
|
|
||||||
if real_path.startswith(path) and len(path) > len(best_match):
|
if not preview_path:
|
||||||
best_match = path
|
return False
|
||||||
best_route = route
|
|
||||||
|
try:
|
||||||
if best_match:
|
candidate = Path(preview_path).expanduser().resolve(strict=False)
|
||||||
relative_path = os.path.relpath(real_path, best_match).replace(os.sep, '/')
|
except Exception:
|
||||||
safe_parts = [urllib.parse.quote(part) for part in relative_path.split('/')]
|
return False
|
||||||
safe_path = '/'.join(safe_parts)
|
|
||||||
return f'{best_route}/{safe_path}'
|
for root in self._preview_root_paths:
|
||||||
|
try:
|
||||||
return ""
|
candidate.relative_to(root)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def apply_library_settings(self, library_config: Mapping[str, object]) -> None:
|
||||||
|
"""Update runtime paths to match the provided library configuration."""
|
||||||
|
folder_paths = library_config.get('folder_paths') if isinstance(library_config, Mapping) else {}
|
||||||
|
if not isinstance(folder_paths, Mapping):
|
||||||
|
folder_paths = {}
|
||||||
|
|
||||||
|
self._apply_library_paths(folder_paths)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Applied library settings with %d lora roots, %d checkpoint roots, and %d embedding roots",
|
||||||
|
len(self.loras_roots or []),
|
||||||
|
len(self.base_models_roots or []),
|
||||||
|
len(self.embeddings_roots or []),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_library_registry_snapshot(self) -> Dict[str, object]:
|
||||||
|
"""Return the current library registry and active library name."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
settings_service = get_settings_manager()
|
||||||
|
libraries = settings_service.get_libraries()
|
||||||
|
active_library = settings_service.get_active_library_name()
|
||||||
|
return {
|
||||||
|
"active_library": active_library,
|
||||||
|
"libraries": libraries,
|
||||||
|
}
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.debug("Failed to collect library registry snapshot: %s", exc)
|
||||||
|
return {"active_library": "", "libraries": {}}
|
||||||
|
|
||||||
# Global config instance
|
# Global config instance
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import asyncio
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
from server import PromptServer # type: ignore
|
from server import PromptServer # type: ignore
|
||||||
|
|
||||||
from .config import config
|
from .config import config
|
||||||
@@ -11,17 +10,50 @@ from .routes.recipe_routes import RecipeRoutes
|
|||||||
from .routes.stats_routes import StatsRoutes
|
from .routes.stats_routes import StatsRoutes
|
||||||
from .routes.update_routes import UpdateRoutes
|
from .routes.update_routes import UpdateRoutes
|
||||||
from .routes.misc_routes import MiscRoutes
|
from .routes.misc_routes import MiscRoutes
|
||||||
|
from .routes.preview_routes import PreviewRoutes
|
||||||
from .routes.example_images_routes import ExampleImagesRoutes
|
from .routes.example_images_routes import ExampleImagesRoutes
|
||||||
from .services.service_registry import ServiceRegistry
|
from .services.service_registry import ServiceRegistry
|
||||||
from .services.settings_manager import settings
|
from .services.settings_manager import get_settings_manager
|
||||||
from .utils.example_images_migration import ExampleImagesMigration
|
from .utils.example_images_migration import ExampleImagesMigration
|
||||||
from .services.websocket_manager import ws_manager
|
from .services.websocket_manager import ws_manager
|
||||||
|
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Check if we're in standalone mode
|
# Check if we're in standalone mode
|
||||||
STANDALONE_MODE = 'nodes' not in sys.modules
|
STANDALONE_MODE = 'nodes' not in sys.modules
|
||||||
|
|
||||||
|
HEADER_SIZE_LIMIT = 16384
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_size_limit(value):
|
||||||
|
"""Return a non-negative integer size for ``handler_args`` comparisons."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
coerced = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
return coerced if coerced >= 0 else 0
|
||||||
|
|
||||||
|
|
||||||
|
class _SettingsProxy:
|
||||||
|
def __init__(self):
|
||||||
|
self._manager = None
|
||||||
|
|
||||||
|
def _resolve(self):
|
||||||
|
if self._manager is None:
|
||||||
|
self._manager = get_settings_manager()
|
||||||
|
return self._manager
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
return self._resolve().get(*args, **kwargs)
|
||||||
|
|
||||||
|
def __getattr__(self, item):
|
||||||
|
return getattr(self._resolve(), item)
|
||||||
|
|
||||||
|
|
||||||
|
settings = _SettingsProxy()
|
||||||
|
|
||||||
class LoraManager:
|
class LoraManager:
|
||||||
"""Main entry point for LoRA Manager plugin"""
|
"""Main entry point for LoRA Manager plugin"""
|
||||||
|
|
||||||
@@ -30,6 +62,24 @@ class LoraManager:
|
|||||||
"""Initialize and register all routes using the new refactored architecture"""
|
"""Initialize and register all routes using the new refactored architecture"""
|
||||||
app = PromptServer.instance.app
|
app = PromptServer.instance.app
|
||||||
|
|
||||||
|
# Increase allowed header sizes so browsers with large localhost cookie
|
||||||
|
# jars (multiple UIs on 127.0.0.1) don't trip aiohttp's 8KB default
|
||||||
|
# limits. Cookies for unrelated apps are still sent to the plugin and
|
||||||
|
# may otherwise raise LineTooLong errors when the request parser reads
|
||||||
|
# them. Preserve any previously configured handler arguments while
|
||||||
|
# ensuring our minimum sizes are applied.
|
||||||
|
handler_args = getattr(app, "_handler_args", {}) or {}
|
||||||
|
updated_handler_args = dict(handler_args)
|
||||||
|
updated_handler_args["max_field_size"] = max(
|
||||||
|
_sanitize_size_limit(handler_args.get("max_field_size", 0)),
|
||||||
|
HEADER_SIZE_LIMIT,
|
||||||
|
)
|
||||||
|
updated_handler_args["max_line_size"] = max(
|
||||||
|
_sanitize_size_limit(handler_args.get("max_line_size", 0)),
|
||||||
|
HEADER_SIZE_LIMIT,
|
||||||
|
)
|
||||||
|
app._handler_args = updated_handler_args
|
||||||
|
|
||||||
# Configure aiohttp access logger to be less verbose
|
# Configure aiohttp access logger to be less verbose
|
||||||
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
|
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
|
||||||
|
|
||||||
@@ -49,102 +99,12 @@ class LoraManager:
|
|||||||
asyncio_logger = logging.getLogger("asyncio")
|
asyncio_logger = logging.getLogger("asyncio")
|
||||||
asyncio_logger.addFilter(ConnectionResetFilter())
|
asyncio_logger.addFilter(ConnectionResetFilter())
|
||||||
|
|
||||||
added_targets = set() # Track already added target paths
|
|
||||||
|
|
||||||
# Add static route for example images if the path exists in settings
|
# Add static route for example images if the path exists in settings
|
||||||
example_images_path = settings.get('example_images_path')
|
example_images_path = settings.get('example_images_path')
|
||||||
logger.info(f"Example images path: {example_images_path}")
|
logger.info(f"Example images path: {example_images_path}")
|
||||||
if example_images_path and os.path.exists(example_images_path):
|
if example_images_path and os.path.exists(example_images_path):
|
||||||
app.router.add_static('/example_images_static', example_images_path)
|
app.router.add_static('/example_images_static', example_images_path)
|
||||||
logger.info(f"Added static route for example images: /example_images_static -> {example_images_path}")
|
logger.info(f"Added static route for example images: /example_images_static -> {example_images_path}")
|
||||||
|
|
||||||
# Add static routes for each lora root
|
|
||||||
for idx, root in enumerate(config.loras_roots, start=1):
|
|
||||||
preview_path = f'/loras_static/root{idx}/preview'
|
|
||||||
|
|
||||||
real_root = root
|
|
||||||
if root in config._path_mappings.values():
|
|
||||||
for target, link in config._path_mappings.items():
|
|
||||||
if link == root:
|
|
||||||
real_root = target
|
|
||||||
break
|
|
||||||
# Add static route for original path
|
|
||||||
app.router.add_static(preview_path, real_root)
|
|
||||||
logger.info(f"Added static route {preview_path} -> {real_root}")
|
|
||||||
|
|
||||||
# Record route mapping
|
|
||||||
config.add_route_mapping(real_root, preview_path)
|
|
||||||
added_targets.add(real_root)
|
|
||||||
|
|
||||||
# Add static routes for each checkpoint root
|
|
||||||
for idx, root in enumerate(config.base_models_roots, start=1):
|
|
||||||
preview_path = f'/checkpoints_static/root{idx}/preview'
|
|
||||||
|
|
||||||
real_root = root
|
|
||||||
if root in config._path_mappings.values():
|
|
||||||
for target, link in config._path_mappings.items():
|
|
||||||
if link == root:
|
|
||||||
real_root = target
|
|
||||||
break
|
|
||||||
# Add static route for original path
|
|
||||||
app.router.add_static(preview_path, real_root)
|
|
||||||
logger.info(f"Added static route {preview_path} -> {real_root}")
|
|
||||||
|
|
||||||
# Record route mapping
|
|
||||||
config.add_route_mapping(real_root, preview_path)
|
|
||||||
added_targets.add(real_root)
|
|
||||||
|
|
||||||
# Add static routes for each embedding root
|
|
||||||
for idx, root in enumerate(config.embeddings_roots, start=1):
|
|
||||||
preview_path = f'/embeddings_static/root{idx}/preview'
|
|
||||||
|
|
||||||
real_root = root
|
|
||||||
if root in config._path_mappings.values():
|
|
||||||
for target, link in config._path_mappings.items():
|
|
||||||
if link == root:
|
|
||||||
real_root = target
|
|
||||||
break
|
|
||||||
# Add static route for original path
|
|
||||||
app.router.add_static(preview_path, real_root)
|
|
||||||
logger.info(f"Added static route {preview_path} -> {real_root}")
|
|
||||||
|
|
||||||
# Record route mapping
|
|
||||||
config.add_route_mapping(real_root, preview_path)
|
|
||||||
added_targets.add(real_root)
|
|
||||||
|
|
||||||
# Add static routes for symlink target paths
|
|
||||||
link_idx = {
|
|
||||||
'lora': 1,
|
|
||||||
'checkpoint': 1,
|
|
||||||
'embedding': 1
|
|
||||||
}
|
|
||||||
|
|
||||||
for target_path, link_path in config._path_mappings.items():
|
|
||||||
if target_path not in added_targets:
|
|
||||||
# Determine if this is a checkpoint, lora, or embedding link based on path
|
|
||||||
is_checkpoint = any(cp_root in link_path for cp_root in config.base_models_roots)
|
|
||||||
is_checkpoint = is_checkpoint or any(cp_root in target_path for cp_root in config.base_models_roots)
|
|
||||||
is_embedding = any(emb_root in link_path for emb_root in config.embeddings_roots)
|
|
||||||
is_embedding = is_embedding or any(emb_root in target_path for emb_root in config.embeddings_roots)
|
|
||||||
|
|
||||||
if is_checkpoint:
|
|
||||||
route_path = f'/checkpoints_static/link_{link_idx["checkpoint"]}/preview'
|
|
||||||
link_idx["checkpoint"] += 1
|
|
||||||
elif is_embedding:
|
|
||||||
route_path = f'/embeddings_static/link_{link_idx["embedding"]}/preview'
|
|
||||||
link_idx["embedding"] += 1
|
|
||||||
else:
|
|
||||||
route_path = f'/loras_static/link_{link_idx["lora"]}/preview'
|
|
||||||
link_idx["lora"] += 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
app.router.add_static(route_path, Path(target_path).resolve(strict=False))
|
|
||||||
logger.info(f"Added static route for link target {route_path} -> {target_path}")
|
|
||||||
config.add_route_mapping(target_path, route_path)
|
|
||||||
added_targets.add(target_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Add static route for locales JSON files
|
# Add static route for locales JSON files
|
||||||
if os.path.exists(config.i18n_path):
|
if os.path.exists(config.i18n_path):
|
||||||
@@ -166,7 +126,8 @@ class LoraManager:
|
|||||||
RecipeRoutes.setup_routes(app)
|
RecipeRoutes.setup_routes(app)
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
MiscRoutes.setup_routes(app)
|
MiscRoutes.setup_routes(app)
|
||||||
ExampleImagesRoutes.setup_routes(app)
|
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
|
||||||
|
PreviewRoutes.setup_routes(app)
|
||||||
|
|
||||||
# Setup WebSocket routes that are shared across all model types
|
# Setup WebSocket routes that are shared across all model types
|
||||||
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)
|
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)
|
||||||
@@ -190,6 +151,9 @@ class LoraManager:
|
|||||||
|
|
||||||
# Register DownloadManager with ServiceRegistry
|
# Register DownloadManager with ServiceRegistry
|
||||||
await ServiceRegistry.get_download_manager()
|
await ServiceRegistry.get_download_manager()
|
||||||
|
|
||||||
|
from .services.metadata_service import initialize_metadata_providers
|
||||||
|
await initialize_metadata_providers()
|
||||||
|
|
||||||
# Initialize WebSocket manager
|
# Initialize WebSocket manager
|
||||||
await ServiceRegistry.get_websocket_manager()
|
await ServiceRegistry.get_websocket_manager()
|
||||||
@@ -218,7 +182,7 @@ class LoraManager:
|
|||||||
name='post_init_tasks'
|
name='post_init_tasks'
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("LoRA Manager: All services initialized and background tasks scheduled")
|
logger.debug("LoRA Manager: All services initialized and background tasks scheduled")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"LoRA Manager: Error initializing services: {e}", exc_info=True)
|
logger.error(f"LoRA Manager: Error initializing services: {e}", exc_info=True)
|
||||||
@@ -237,7 +201,6 @@ class LoraManager:
|
|||||||
# Run post-initialization tasks
|
# Run post-initialization tasks
|
||||||
post_tasks = [
|
post_tasks = [
|
||||||
asyncio.create_task(cls._cleanup_backup_files(), name='cleanup_bak_files'),
|
asyncio.create_task(cls._cleanup_backup_files(), name='cleanup_bak_files'),
|
||||||
asyncio.create_task(cls._cleanup_example_images_folders(), name='cleanup_example_images'),
|
|
||||||
# Add more post-initialization tasks here as needed
|
# Add more post-initialization tasks here as needed
|
||||||
# asyncio.create_task(cls._another_post_task(), name='another_task'),
|
# asyncio.create_task(cls._another_post_task(), name='another_task'),
|
||||||
]
|
]
|
||||||
@@ -349,120 +312,37 @@ class LoraManager:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _cleanup_example_images_folders(cls):
|
async def _cleanup_example_images_folders(cls):
|
||||||
"""Clean up invalid or empty folders in example images directory"""
|
"""Invoke the example images cleanup service for manual execution."""
|
||||||
try:
|
try:
|
||||||
example_images_path = settings.get('example_images_path')
|
service = ExampleImagesCleanupService()
|
||||||
if not example_images_path or not os.path.exists(example_images_path):
|
result = await service.cleanup_example_image_folders()
|
||||||
logger.debug("Example images path not configured or doesn't exist, skipping cleanup")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.debug(f"Starting cleanup of example images folders in: {example_images_path}")
|
|
||||||
|
|
||||||
# Get all scanner instances to check hash validity
|
|
||||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
|
||||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
|
||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
|
||||||
|
|
||||||
total_folders_checked = 0
|
|
||||||
empty_folders_removed = 0
|
|
||||||
invalid_hash_folders_removed = 0
|
|
||||||
|
|
||||||
# Scan the example images directory
|
|
||||||
try:
|
|
||||||
with os.scandir(example_images_path) as it:
|
|
||||||
for entry in it:
|
|
||||||
if not entry.is_dir(follow_symlinks=False):
|
|
||||||
continue
|
|
||||||
|
|
||||||
folder_name = entry.name
|
|
||||||
folder_path = entry.path
|
|
||||||
total_folders_checked += 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if folder is empty
|
|
||||||
is_empty = cls._is_folder_empty(folder_path)
|
|
||||||
if is_empty:
|
|
||||||
logger.debug(f"Removing empty example images folder: {folder_name}")
|
|
||||||
await cls._remove_folder_safely(folder_path)
|
|
||||||
empty_folders_removed += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if folder name is a valid SHA256 hash (64 hex characters)
|
|
||||||
if len(folder_name) != 64 or not all(c in '0123456789abcdefABCDEF' for c in folder_name):
|
|
||||||
logger.debug(f"Removing invalid hash folder: {folder_name}")
|
|
||||||
await cls._remove_folder_safely(folder_path)
|
|
||||||
invalid_hash_folders_removed += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if hash exists in any of the scanners
|
|
||||||
hash_exists = (
|
|
||||||
lora_scanner.has_hash(folder_name) or
|
|
||||||
checkpoint_scanner.has_hash(folder_name) or
|
|
||||||
embedding_scanner.has_hash(folder_name)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not hash_exists:
|
|
||||||
logger.debug(f"Removing example images folder for deleted model: {folder_name}")
|
|
||||||
await cls._remove_folder_safely(folder_path)
|
|
||||||
invalid_hash_folders_removed += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.debug(f"Keeping valid example images folder: {folder_name}")
|
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
if result.get('success'):
|
||||||
logger.error(f"Error processing example images folder {folder_name}: {e}")
|
logger.debug(
|
||||||
|
"Manual example images cleanup completed: moved=%s",
|
||||||
# Yield control periodically
|
result.get('moved_total'),
|
||||||
await asyncio.sleep(0.01)
|
)
|
||||||
|
elif result.get('partial_success'):
|
||||||
except Exception as e:
|
logger.warning(
|
||||||
logger.error(f"Error scanning example images directory: {e}")
|
"Manual example images cleanup partially succeeded: moved=%s failures=%s",
|
||||||
return
|
result.get('moved_total'),
|
||||||
|
result.get('move_failures'),
|
||||||
# Log final cleanup report
|
)
|
||||||
total_removed = empty_folders_removed + invalid_hash_folders_removed
|
|
||||||
if total_removed > 0:
|
|
||||||
logger.info(f"Example images cleanup completed: checked {total_folders_checked} folders, "
|
|
||||||
f"removed {empty_folders_removed} empty folders and {invalid_hash_folders_removed} "
|
|
||||||
f"folders for deleted/invalid models (total: {total_removed} removed)")
|
|
||||||
else:
|
else:
|
||||||
logger.info(f"Example images cleanup completed: checked {total_folders_checked} folders, "
|
logger.debug(
|
||||||
f"no cleanup needed")
|
"Manual example images cleanup skipped or failed: %s",
|
||||||
|
result.get('error', 'no changes'),
|
||||||
except Exception as e:
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e: # pragma: no cover - defensive guard
|
||||||
logger.error(f"Error during example images cleanup: {e}", exc_info=True)
|
logger.error(f"Error during example images cleanup: {e}", exc_info=True)
|
||||||
|
return {
|
||||||
@classmethod
|
'success': False,
|
||||||
def _is_folder_empty(cls, folder_path: str) -> bool:
|
'error': str(e),
|
||||||
"""Check if a folder is empty
|
'error_code': 'unexpected_error',
|
||||||
|
}
|
||||||
Args:
|
|
||||||
folder_path: Path to the folder to check
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if folder is empty, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with os.scandir(folder_path) as it:
|
|
||||||
return not any(it)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error checking if folder is empty {folder_path}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _remove_folder_safely(cls, folder_path: str):
|
|
||||||
"""Safely remove a folder and all its contents
|
|
||||||
|
|
||||||
Args:
|
|
||||||
folder_path: Path to the folder to remove
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import shutil
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
await loop.run_in_executor(None, shutil.rmtree, folder_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to remove folder {folder_path}: {e}")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _cleanup(cls, app):
|
async def _cleanup(cls, app):
|
||||||
@@ -470,11 +350,5 @@ class LoraManager:
|
|||||||
try:
|
try:
|
||||||
logger.info("LoRA Manager: Cleaning up services")
|
logger.info("LoRA Manager: Cleaning up services")
|
||||||
|
|
||||||
# Close CivitaiClient gracefully
|
|
||||||
civitai_client = await ServiceRegistry.get_service("civitai_client")
|
|
||||||
if civitai_client:
|
|
||||||
await civitai_client.close()
|
|
||||||
logger.info("Closed CivitaiClient connection")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import importlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Check if running in standalone mode
|
# Check if running in standalone mode
|
||||||
standalone_mode = 'nodes' not in sys.modules
|
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||||
|
|
||||||
if not standalone_mode:
|
if not standalone_mode:
|
||||||
from .metadata_hook import MetadataHook
|
from .metadata_hook import MetadataHook
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
import sys
|
import os
|
||||||
from .constants import IMAGES
|
from .constants import IMAGES
|
||||||
|
|
||||||
# Check if running in standalone mode
|
# Check if running in standalone mode
|
||||||
standalone_mode = 'nodes' not in sys.modules
|
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||||
|
|
||||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER
|
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER
|
||||||
|
|
||||||
@@ -295,7 +295,7 @@ class MetadataProcessor:
|
|||||||
"seed": None,
|
"seed": None,
|
||||||
"steps": None,
|
"steps": None,
|
||||||
"cfg_scale": None,
|
"cfg_scale": None,
|
||||||
"guidance": None, # Add guidance parameter
|
# "guidance": None, # Add guidance parameter
|
||||||
"sampler": None,
|
"sampler": None,
|
||||||
"scheduler": None,
|
"scheduler": None,
|
||||||
"checkpoint": None,
|
"checkpoint": None,
|
||||||
|
|||||||
@@ -196,9 +196,11 @@ class MetadataRegistry:
|
|||||||
node_metadata[category] = {}
|
node_metadata[category] = {}
|
||||||
node_metadata[category][node_id] = current_metadata[category][node_id]
|
node_metadata[category][node_id] = current_metadata[category][node_id]
|
||||||
|
|
||||||
# Save to cache if we have any metadata for this node
|
# Save new metadata or clear stale cache entries when metadata is empty
|
||||||
if any(node_metadata.values()):
|
if any(node_metadata.values()):
|
||||||
self.node_cache[cache_key] = node_metadata
|
self.node_cache[cache_key] = node_metadata
|
||||||
|
else:
|
||||||
|
self.node_cache.pop(cache_key, None)
|
||||||
|
|
||||||
def clear_unused_cache(self):
|
def clear_unused_cache(self):
|
||||||
"""Clean up node_cache entries that are no longer in use"""
|
"""Clean up node_cache entries that are no longer in use"""
|
||||||
|
|||||||
@@ -3,6 +3,18 @@ import os
|
|||||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
||||||
|
|
||||||
|
|
||||||
|
def _store_checkpoint_metadata(metadata, node_id, model_name):
|
||||||
|
"""Store checkpoint model information when available."""
|
||||||
|
if not model_name:
|
||||||
|
return
|
||||||
|
metadata.setdefault(MODELS, {})
|
||||||
|
metadata[MODELS][node_id] = {
|
||||||
|
"name": model_name,
|
||||||
|
"type": "checkpoint",
|
||||||
|
"node_id": node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class NodeMetadataExtractor:
|
class NodeMetadataExtractor:
|
||||||
"""Base class for node-specific metadata extraction"""
|
"""Base class for node-specific metadata extraction"""
|
||||||
|
|
||||||
@@ -29,12 +41,48 @@ class CheckpointLoaderExtractor(NodeMetadataExtractor):
|
|||||||
return
|
return
|
||||||
|
|
||||||
model_name = inputs.get("ckpt_name")
|
model_name = inputs.get("ckpt_name")
|
||||||
if model_name:
|
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||||
metadata[MODELS][node_id] = {
|
|
||||||
"name": model_name,
|
|
||||||
"type": "checkpoint",
|
class NunchakuFluxDiTLoaderExtractor(NodeMetadataExtractor):
|
||||||
"node_id": node_id
|
@staticmethod
|
||||||
}
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs or "model_path" not in inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
model_name = inputs.get("model_path")
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||||
|
|
||||||
|
|
||||||
|
class NunchakuQwenImageDiTLoaderExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs or "model_name" not in inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
model_name = inputs.get("model_name")
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||||
|
|
||||||
|
class GGUFLoaderExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs or "gguf_name" not in inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
model_name = inputs.get("gguf_name")
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||||
|
|
||||||
|
|
||||||
|
class KJNodesModelLoaderExtractor(NodeMetadataExtractor):
|
||||||
|
"""Extract metadata from KJNodes loaders that expose `model_name`."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs or "model_name" not in inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
model_name = inputs.get("model_name")
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||||
|
|
||||||
class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -43,12 +91,7 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
|||||||
return
|
return
|
||||||
|
|
||||||
model_name = inputs.get("ckpt_name")
|
model_name = inputs.get("ckpt_name")
|
||||||
if model_name:
|
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||||
metadata[MODELS][node_id] = {
|
|
||||||
"name": model_name,
|
|
||||||
"type": "checkpoint",
|
|
||||||
"node_id": node_id
|
|
||||||
}
|
|
||||||
|
|
||||||
# For loader node has lora_stack input, like Efficient Loader from Efficient Nodes
|
# For loader node has lora_stack input, like Efficient Loader from Efficient Nodes
|
||||||
active_loras = []
|
active_loras = []
|
||||||
@@ -651,6 +694,7 @@ NODE_EXTRACTORS = {
|
|||||||
"KSamplerAdvancedBasicPipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-impact-pack
|
"KSamplerAdvancedBasicPipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-impact-pack
|
||||||
"KSampler_inspire_pipe": KSamplerBasicPipeExtractor, # comfyui-inspire-pack
|
"KSampler_inspire_pipe": KSamplerBasicPipeExtractor, # comfyui-inspire-pack
|
||||||
"KSamplerAdvanced_inspire_pipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-inspire-pack
|
"KSamplerAdvanced_inspire_pipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-inspire-pack
|
||||||
|
"KSampler_inspire": SamplerExtractor, # comfyui-inspire-pack
|
||||||
# Sampling Selectors
|
# Sampling Selectors
|
||||||
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
||||||
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
||||||
@@ -660,17 +704,26 @@ NODE_EXTRACTORS = {
|
|||||||
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
|
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
|
||||||
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
||||||
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
||||||
|
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||||
|
"NunchakuQwenImageDiTLoader": NunchakuQwenImageDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||||
|
"LoaderGGUF": GGUFLoaderExtractor, # calcuis gguf
|
||||||
|
"LoaderGGUFAdvanced": GGUFLoaderExtractor, # calcuis gguf
|
||||||
|
"GGUFLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
|
||||||
|
"DiffusionModelLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
|
||||||
|
"CheckpointLoaderKJ": CheckpointLoaderExtractor, # KJNodes
|
||||||
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
|
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||||
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||||
"LoraLoader": LoraLoaderExtractor,
|
"LoraLoader": LoraLoaderExtractor,
|
||||||
"LoraManagerLoader": LoraLoaderManagerExtractor,
|
"LoraManagerLoader": LoraLoaderManagerExtractor,
|
||||||
# Conditioning
|
# Conditioning
|
||||||
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
||||||
|
"PromptLoraManager": CLIPTextEncodeExtractor,
|
||||||
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
||||||
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
||||||
"AdvancedCLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb
|
"AdvancedCLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb
|
||||||
"smZ_CLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/shiimizu/ComfyUI_smZNodes
|
"smZ_CLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/shiimizu/ComfyUI_smZNodes
|
||||||
"CR_ApplyControlNetStack": CR_ApplyControlNetStackExtractor, # Add CR_ApplyControlNetStack
|
"CR_ApplyControlNetStack": CR_ApplyControlNetStackExtractor, # Add CR_ApplyControlNetStack
|
||||||
|
"PCTextEncode": CLIPTextEncodeExtractor, # From https://github.com/asagi4/comfyui-prompt-control
|
||||||
# Latent
|
# Latent
|
||||||
"EmptyLatentImage": ImageSizeExtractor,
|
"EmptyLatentImage": ImageSizeExtractor,
|
||||||
# Flux
|
# Flux
|
||||||
|
|||||||
1
py/middleware/__init__.py
Normal file
1
py/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Server middleware modules"""
|
||||||
53
py/middleware/cache_middleware.py
Normal file
53
py/middleware/cache_middleware.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Cache control middleware for ComfyUI server"""
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from typing import Callable, Awaitable
|
||||||
|
|
||||||
|
# Time in seconds
|
||||||
|
ONE_HOUR: int = 3600
|
||||||
|
ONE_DAY: int = 86400
|
||||||
|
IMG_EXTENSIONS = (
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".png",
|
||||||
|
".ppm",
|
||||||
|
".bmp",
|
||||||
|
".pgm",
|
||||||
|
".tif",
|
||||||
|
".tiff",
|
||||||
|
".webp",
|
||||||
|
".mp4"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def cache_control(
|
||||||
|
request: web.Request, handler: Callable[[web.Request], Awaitable[web.Response]]
|
||||||
|
) -> web.Response:
|
||||||
|
"""Cache control middleware that sets appropriate cache headers based on file type and response status"""
|
||||||
|
response: web.Response = await handler(request)
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.path.endswith(".js")
|
||||||
|
or request.path.endswith(".css")
|
||||||
|
or request.path.endswith("index.json")
|
||||||
|
):
|
||||||
|
response.headers.setdefault("Cache-Control", "no-cache")
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Early return for non-image files - no cache headers needed
|
||||||
|
if not request.path.lower().endswith(IMG_EXTENSIONS):
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Handle image files
|
||||||
|
if response.status == 404:
|
||||||
|
response.headers.setdefault("Cache-Control", f"public, max-age={ONE_HOUR}")
|
||||||
|
elif response.status in (200, 201, 202, 203, 204, 205, 206, 301, 308):
|
||||||
|
# Success responses and permanent redirects - cache for 1 day
|
||||||
|
response.headers.setdefault("Cache-Control", f"public, max-age={ONE_DAY}")
|
||||||
|
elif response.status in (302, 303, 307):
|
||||||
|
# Temporary redirects - no cache
|
||||||
|
response.headers.setdefault("Cache-Control", "no-cache")
|
||||||
|
# Note: 304 Not Modified falls through - no cache headers set
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from nodes import LoraLoader
|
from nodes import LoraLoader
|
||||||
from comfy.comfy_types import IO # type: ignore
|
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info
|
||||||
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list, nunchaku_load_lora
|
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list, nunchaku_load_lora
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ class LoraManagerLoader:
|
|||||||
"required": {
|
"required": {
|
||||||
"model": ("MODEL",),
|
"model": ("MODEL",),
|
||||||
# "clip": ("CLIP",),
|
# "clip": ("CLIP",),
|
||||||
"text": (IO.STRING, {
|
"text": ("STRING", {
|
||||||
"multiline": True,
|
"multiline": True,
|
||||||
"pysssss.autocomplete": False,
|
"pysssss.autocomplete": False,
|
||||||
"dynamicPrompts": True,
|
"dynamicPrompts": True,
|
||||||
@@ -28,7 +27,7 @@ class LoraManagerLoader:
|
|||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
|
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
||||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||||
FUNCTION = "load_loras"
|
FUNCTION = "load_loras"
|
||||||
|
|
||||||
@@ -115,7 +114,7 @@ class LoraManagerLoader:
|
|||||||
formatted_loras = []
|
formatted_loras = []
|
||||||
for item in loaded_loras:
|
for item in loaded_loras:
|
||||||
parts = item.split(":")
|
parts = item.split(":")
|
||||||
lora_name = parts[0].strip()
|
lora_name = parts[0]
|
||||||
strength_parts = parts[1].strip().split(",")
|
strength_parts = parts[1].strip().split(",")
|
||||||
|
|
||||||
if len(strength_parts) > 1:
|
if len(strength_parts) > 1:
|
||||||
@@ -141,8 +140,7 @@ class LoraManagerTextLoader:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"model": ("MODEL",),
|
"model": ("MODEL",),
|
||||||
"lora_syntax": (IO.STRING, {
|
"lora_syntax": ("STRING", {
|
||||||
"defaultInput": True,
|
|
||||||
"forceInput": True,
|
"forceInput": True,
|
||||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation"
|
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation"
|
||||||
}),
|
}),
|
||||||
@@ -153,7 +151,7 @@ class LoraManagerTextLoader:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
|
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
||||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||||
FUNCTION = "load_loras_from_text"
|
FUNCTION = "load_loras_from_text"
|
||||||
|
|
||||||
@@ -165,7 +163,7 @@ class LoraManagerTextLoader:
|
|||||||
|
|
||||||
loras = []
|
loras = []
|
||||||
for match in matches:
|
for match in matches:
|
||||||
lora_name = match[0].strip()
|
lora_name = match[0]
|
||||||
model_strength = float(match[1])
|
model_strength = float(match[1])
|
||||||
clip_strength = float(match[2]) if match[2] else model_strength
|
clip_strength = float(match[2]) if match[2] else model_strength
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from comfy.comfy_types import IO # type: ignore
|
|
||||||
import os
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info
|
||||||
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list
|
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list
|
||||||
@@ -15,7 +14,7 @@ class LoraStacker:
|
|||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"text": (IO.STRING, {
|
"text": ("STRING", {
|
||||||
"multiline": True,
|
"multiline": True,
|
||||||
"pysssss.autocomplete": False,
|
"pysssss.autocomplete": False,
|
||||||
"dynamicPrompts": True,
|
"dynamicPrompts": True,
|
||||||
@@ -26,7 +25,7 @@ class LoraStacker:
|
|||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("LORA_STACK", IO.STRING, IO.STRING)
|
RETURN_TYPES = ("LORA_STACK", "STRING", "STRING")
|
||||||
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
|
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
|
||||||
FUNCTION = "stack_loras"
|
FUNCTION = "stack_loras"
|
||||||
|
|
||||||
|
|||||||
59
py/nodes/prompt.py
Normal file
59
py/nodes/prompt.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
class PromptLoraManager:
|
||||||
|
"""Encodes text (and optional trigger words) into CLIP conditioning."""
|
||||||
|
|
||||||
|
NAME = "Prompt (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/conditioning"
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Encodes a text prompt using a CLIP model into an embedding that can be used "
|
||||||
|
"to guide the diffusion model towards generating specific images."
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"text": (
|
||||||
|
'STRING',
|
||||||
|
{
|
||||||
|
"multiline": True,
|
||||||
|
"pysssss.autocomplete": False,
|
||||||
|
"dynamicPrompts": True,
|
||||||
|
"tooltip": "The text to be encoded.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"clip": (
|
||||||
|
'CLIP',
|
||||||
|
{"tooltip": "The CLIP model used for encoding the text."},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"trigger_words": (
|
||||||
|
'STRING',
|
||||||
|
{
|
||||||
|
"forceInput": True,
|
||||||
|
"tooltip": (
|
||||||
|
"Optional trigger words to prepend to the text before "
|
||||||
|
"encoding."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ('CONDITIONING', 'STRING',)
|
||||||
|
RETURN_NAMES = ('CONDITIONING', 'PROMPT',)
|
||||||
|
OUTPUT_TOOLTIPS = (
|
||||||
|
"A conditioning containing the embedded text used to guide the diffusion model.",
|
||||||
|
)
|
||||||
|
FUNCTION = "encode"
|
||||||
|
|
||||||
|
def encode(self, text: str, clip: Any, trigger_words: Optional[str] = None):
|
||||||
|
prompt = text
|
||||||
|
if trigger_words:
|
||||||
|
prompt = ", ".join([trigger_words, text])
|
||||||
|
|
||||||
|
from nodes import CLIPTextEncode # type: ignore
|
||||||
|
conditioning = CLIPTextEncode().encode(clip, prompt)[0]
|
||||||
|
return (conditioning, prompt,)
|
||||||
@@ -273,9 +273,15 @@ class SaveImage:
|
|||||||
length = int(parts[1])
|
length = int(parts[1])
|
||||||
prompt = prompt[:length]
|
prompt = prompt[:length]
|
||||||
filename = filename.replace(segment, prompt.strip())
|
filename = filename.replace(segment, prompt.strip())
|
||||||
elif key == "model" and 'checkpoint' in metadata_dict:
|
elif key == "model":
|
||||||
model = metadata_dict.get('checkpoint', '')
|
model_value = metadata_dict.get('checkpoint')
|
||||||
model = os.path.splitext(os.path.basename(model))[0]
|
if isinstance(model_value, (bytes, os.PathLike)):
|
||||||
|
model_value = str(model_value)
|
||||||
|
|
||||||
|
if not isinstance(model_value, str) or not model_value:
|
||||||
|
model = "model_unavailable"
|
||||||
|
else:
|
||||||
|
model = os.path.splitext(os.path.basename(model_value))[0]
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
length = int(parts[1])
|
length = int(parts[1])
|
||||||
model = model[:length]
|
model = model[:length]
|
||||||
@@ -442,4 +448,4 @@ class SaveImage:
|
|||||||
add_counter_to_filename
|
add_counter_to_filename
|
||||||
)
|
)
|
||||||
|
|
||||||
return (images,)
|
return (images,)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from server import PromptServer # type: ignore
|
|
||||||
from .utils import FlexibleOptionalInputType, any_type
|
from .utils import FlexibleOptionalInputType, any_type
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -24,6 +23,10 @@ class TriggerWordToggle:
|
|||||||
"default": True,
|
"default": True,
|
||||||
"tooltip": "Sets the default initial state (active or inactive) when trigger words are added."
|
"tooltip": "Sets the default initial state (active or inactive) when trigger words are added."
|
||||||
}),
|
}),
|
||||||
|
"allow_strength_adjustment": ("BOOLEAN", {
|
||||||
|
"default": False,
|
||||||
|
"tooltip": "Enable mouse wheel adjustment of each trigger word's strength."
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
"hidden": {
|
"hidden": {
|
||||||
@@ -48,7 +51,14 @@ class TriggerWordToggle:
|
|||||||
else:
|
else:
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def process_trigger_words(self, id, group_mode, default_active, **kwargs):
|
def process_trigger_words(
|
||||||
|
self,
|
||||||
|
id,
|
||||||
|
group_mode,
|
||||||
|
default_active,
|
||||||
|
allow_strength_adjustment=False,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
# Handle both old and new formats for trigger_words
|
# Handle both old and new formats for trigger_words
|
||||||
trigger_words_data = self._get_toggle_data(kwargs, 'orinalMessage')
|
trigger_words_data = self._get_toggle_data(kwargs, 'orinalMessage')
|
||||||
trigger_words = trigger_words_data if isinstance(trigger_words_data, str) else ""
|
trigger_words = trigger_words_data if isinstance(trigger_words_data, str) else ""
|
||||||
@@ -64,27 +74,89 @@ class TriggerWordToggle:
|
|||||||
trigger_data = json.loads(trigger_data)
|
trigger_data = json.loads(trigger_data)
|
||||||
|
|
||||||
# Create dictionaries to track active state of words or groups
|
# Create dictionaries to track active state of words or groups
|
||||||
active_state = {item['text']: item.get('active', False) for item in trigger_data}
|
# Also track strength values for each trigger word
|
||||||
|
active_state = {}
|
||||||
|
strength_map = {}
|
||||||
|
|
||||||
if group_mode:
|
for item in trigger_data:
|
||||||
# Split by two or more consecutive commas to get groups
|
text = item['text']
|
||||||
groups = re.split(r',{2,}', trigger_words)
|
active = item.get('active', False)
|
||||||
# Remove leading/trailing whitespace from each group
|
# Extract strength if it's in the format "(word:strength)"
|
||||||
groups = [group.strip() for group in groups]
|
strength_match = re.match(r'\((.+):([\d.]+)\)', text)
|
||||||
|
if strength_match:
|
||||||
# Filter groups: keep those not in toggle_trigger_words or those that are active
|
original_word = strength_match.group(1).strip()
|
||||||
filtered_groups = [group for group in groups if group not in active_state or active_state[group]]
|
strength = float(strength_match.group(2))
|
||||||
|
active_state[original_word] = active
|
||||||
if filtered_groups:
|
if allow_strength_adjustment:
|
||||||
filtered_triggers = ', '.join(filtered_groups)
|
strength_map[original_word] = strength
|
||||||
else:
|
else:
|
||||||
filtered_triggers = ""
|
active_state[text.strip()] = active
|
||||||
|
|
||||||
|
if group_mode:
|
||||||
|
if isinstance(trigger_data, list):
|
||||||
|
filtered_groups = []
|
||||||
|
for item in trigger_data:
|
||||||
|
text = (item.get('text') or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if item.get('active', False):
|
||||||
|
filtered_groups.append(text)
|
||||||
|
|
||||||
|
if filtered_groups:
|
||||||
|
filtered_triggers = ', '.join(filtered_groups)
|
||||||
|
else:
|
||||||
|
filtered_triggers = ""
|
||||||
|
else:
|
||||||
|
# Split by two or more consecutive commas to get groups
|
||||||
|
groups = re.split(r',{2,}', trigger_words)
|
||||||
|
# Remove leading/trailing whitespace from each group
|
||||||
|
groups = [group.strip() for group in groups]
|
||||||
|
|
||||||
|
# Process groups: keep those not in toggle_trigger_words or those that are active
|
||||||
|
filtered_groups = []
|
||||||
|
for group in groups:
|
||||||
|
# Check if this group contains any words that are in the active_state
|
||||||
|
group_words = [word.strip() for word in group.split(',')]
|
||||||
|
active_group_words = []
|
||||||
|
|
||||||
|
for word in group_words:
|
||||||
|
word_comparison = re.sub(r'\((.+):([\d.]+)\)', r'\1', word).strip()
|
||||||
|
|
||||||
|
if word_comparison not in active_state or active_state[word_comparison]:
|
||||||
|
active_group_words.append(
|
||||||
|
self._format_word_output(
|
||||||
|
word_comparison,
|
||||||
|
strength_map,
|
||||||
|
allow_strength_adjustment,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if active_group_words:
|
||||||
|
filtered_groups.append(', '.join(active_group_words))
|
||||||
|
|
||||||
|
if filtered_groups:
|
||||||
|
filtered_triggers = ', '.join(filtered_groups)
|
||||||
|
else:
|
||||||
|
filtered_triggers = ""
|
||||||
else:
|
else:
|
||||||
# Original behavior for individual words mode
|
# Normal mode: split by commas and treat each word as a separate tag
|
||||||
original_words = [word.strip() for word in trigger_words.split(',')]
|
original_words = [word.strip() for word in trigger_words.split(',')]
|
||||||
# Filter out empty strings
|
# Filter out empty strings
|
||||||
original_words = [word for word in original_words if word]
|
original_words = [word for word in original_words if word]
|
||||||
filtered_words = [word for word in original_words if word not in active_state or active_state[word]]
|
|
||||||
|
filtered_words = []
|
||||||
|
for word in original_words:
|
||||||
|
# Remove any existing strength formatting for comparison
|
||||||
|
word_comparison = re.sub(r'\((.+):([\d.]+)\)', r'\1', word).strip()
|
||||||
|
|
||||||
|
if word_comparison not in active_state or active_state[word_comparison]:
|
||||||
|
filtered_words.append(
|
||||||
|
self._format_word_output(
|
||||||
|
word_comparison,
|
||||||
|
strength_map,
|
||||||
|
allow_strength_adjustment,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if filtered_words:
|
if filtered_words:
|
||||||
filtered_triggers = ', '.join(filtered_words)
|
filtered_triggers = ', '.join(filtered_words)
|
||||||
@@ -94,4 +166,9 @@ class TriggerWordToggle:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing trigger words: {e}")
|
logger.error(f"Error processing trigger words: {e}")
|
||||||
|
|
||||||
return (filtered_triggers,)
|
return (filtered_triggers,)
|
||||||
|
|
||||||
|
def _format_word_output(self, base_word, strength_map, allow_strength_adjustment):
|
||||||
|
if allow_strength_adjustment and base_word in strength_map:
|
||||||
|
return f"({base_word}:{strength_map[base_word]:.2f})"
|
||||||
|
return base_word
|
||||||
|
|||||||
@@ -110,10 +110,14 @@ def nunchaku_load_lora(model, lora_name, lora_strength):
|
|||||||
model_wrapper.model = transformer
|
model_wrapper.model = transformer
|
||||||
ret_model_wrapper.model = transformer
|
ret_model_wrapper.model = transformer
|
||||||
|
|
||||||
# Get full path to the LoRA file
|
# Get full path to the LoRA file. Allow both direct paths and registered LoRA names.
|
||||||
lora_path = folder_paths.get_full_path("loras", lora_name)
|
lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name)
|
||||||
|
if not lora_path or not os.path.isfile(lora_path):
|
||||||
|
logger.warning("Skipping LoRA '%s' because it could not be found", lora_name)
|
||||||
|
return model
|
||||||
|
|
||||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||||
|
|
||||||
# Convert the LoRA to diffusers format
|
# Convert the LoRA to diffusers format
|
||||||
sd = to_diffusers(lora_path)
|
sd = to_diffusers(lora_path)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from comfy.comfy_types import IO # type: ignore
|
|
||||||
import folder_paths # type: ignore
|
import folder_paths # type: ignore
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info
|
||||||
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
||||||
@@ -16,7 +15,7 @@ class WanVideoLoraSelect:
|
|||||||
"required": {
|
"required": {
|
||||||
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
|
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
|
||||||
"merge_loras": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
|
"merge_loras": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
|
||||||
"text": (IO.STRING, {
|
"text": ("STRING", {
|
||||||
"multiline": True,
|
"multiline": True,
|
||||||
"pysssss.autocomplete": False,
|
"pysssss.autocomplete": False,
|
||||||
"dynamicPrompts": True,
|
"dynamicPrompts": True,
|
||||||
@@ -27,7 +26,7 @@ class WanVideoLoraSelect:
|
|||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("WANVIDLORA", IO.STRING, IO.STRING)
|
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
|
||||||
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||||
FUNCTION = "process_loras"
|
FUNCTION = "process_loras"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from comfy.comfy_types import IO
|
import folder_paths # type: ignore
|
||||||
import folder_paths
|
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info
|
||||||
from .utils import any_type
|
from .utils import any_type
|
||||||
import logging
|
import logging
|
||||||
@@ -20,9 +19,8 @@ class WanVideoLoraSelectFromText:
|
|||||||
"required": {
|
"required": {
|
||||||
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
|
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
|
||||||
"merge_lora": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
|
"merge_lora": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
|
||||||
"lora_syntax": (IO.STRING, {
|
"lora_syntax": ("STRING", {
|
||||||
"multiline": True,
|
"multiline": True,
|
||||||
"defaultInput": True,
|
|
||||||
"forceInput": True,
|
"forceInput": True,
|
||||||
"tooltip": "Connect a TEXT output for LoRA syntax: <lora:name:strength>"
|
"tooltip": "Connect a TEXT output for LoRA syntax: <lora:name:strength>"
|
||||||
}),
|
}),
|
||||||
@@ -34,7 +32,7 @@ class WanVideoLoraSelectFromText:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("WANVIDLORA", IO.STRING, IO.STRING)
|
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
|
||||||
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||||
|
|
||||||
FUNCTION = "process_loras_from_syntax"
|
FUNCTION = "process_loras_from_syntax"
|
||||||
@@ -125,4 +123,3 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
"WanVideoLoraSelectFromText": "WanVideo Lora Select From Text (LoraManager)"
|
"WanVideoLoraSelectFromText": "WanVideo Lora Select From Text (LoraManager)"
|
||||||
}
|
}
|
||||||
,
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Dict, List, Any, Optional, Tuple
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.constants import VALID_LORA_TYPES
|
from ..utils.constants import VALID_LORA_TYPES
|
||||||
|
from ..utils.civitai_utils import rewrite_preview_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ class RecipeMetadataParser(ABC):
|
|||||||
# Unpack the tuple to get the actual data
|
# Unpack the tuple to get the actual data
|
||||||
civitai_info, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
civitai_info, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
||||||
|
|
||||||
if not civitai_info or civitai_info.get("error") == "Model not found":
|
if not civitai_info or error_msg == "Model not found":
|
||||||
# Model not found or deleted
|
# Model not found or deleted
|
||||||
lora_entry['isDeleted'] = True
|
lora_entry['isDeleted'] = True
|
||||||
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||||
@@ -78,7 +79,7 @@ class RecipeMetadataParser(ABC):
|
|||||||
# Update model name if available
|
# Update model name if available
|
||||||
if 'model' in civitai_info and 'name' in civitai_info['model']:
|
if 'model' in civitai_info and 'name' in civitai_info['model']:
|
||||||
lora_entry['name'] = civitai_info['model']['name']
|
lora_entry['name'] = civitai_info['model']['name']
|
||||||
|
|
||||||
lora_entry['id'] = civitai_info.get('id')
|
lora_entry['id'] = civitai_info.get('id')
|
||||||
lora_entry['modelId'] = civitai_info.get('modelId')
|
lora_entry['modelId'] = civitai_info.get('modelId')
|
||||||
|
|
||||||
@@ -88,7 +89,10 @@ class RecipeMetadataParser(ABC):
|
|||||||
|
|
||||||
# Get thumbnail URL from first image
|
# Get thumbnail URL from first image
|
||||||
if 'images' in civitai_info and civitai_info['images']:
|
if 'images' in civitai_info and civitai_info['images']:
|
||||||
lora_entry['thumbnailUrl'] = civitai_info['images'][0].get('url', '')
|
image_url = civitai_info['images'][0].get('url')
|
||||||
|
if image_url:
|
||||||
|
rewritten_image_url, _ = rewrite_preview_url(image_url, media_type='image')
|
||||||
|
lora_entry['thumbnailUrl'] = rewritten_image_url or image_url
|
||||||
|
|
||||||
# Get base model
|
# Get base model
|
||||||
current_base_model = civitai_info.get('baseModel', '')
|
current_base_model = civitai_info.get('baseModel', '')
|
||||||
@@ -151,33 +155,59 @@ class RecipeMetadataParser(ABC):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
checkpoint: The checkpoint entry to populate
|
checkpoint: The checkpoint entry to populate
|
||||||
civitai_info: The response from Civitai API
|
civitai_info: The response from Civitai API or a (data, error_msg) tuple
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The populated checkpoint dict
|
The populated checkpoint dict
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if civitai_info and civitai_info.get("error") != "Model not found":
|
civitai_data, error_msg = (
|
||||||
# Update model name if available
|
(civitai_info, None)
|
||||||
if 'model' in civitai_info and 'name' in civitai_info['model']:
|
if not isinstance(civitai_info, tuple)
|
||||||
checkpoint['name'] = civitai_info['model']['name']
|
else civitai_info
|
||||||
|
)
|
||||||
# Update version if available
|
|
||||||
if 'name' in civitai_info:
|
if not civitai_data or error_msg == "Model not found":
|
||||||
checkpoint['version'] = civitai_info.get('name', '')
|
|
||||||
|
|
||||||
# Get thumbnail URL from first image
|
|
||||||
if 'images' in civitai_info and civitai_info['images']:
|
|
||||||
checkpoint['thumbnailUrl'] = civitai_info['images'][0].get('url', '')
|
|
||||||
|
|
||||||
# Get base model
|
|
||||||
checkpoint['baseModel'] = civitai_info.get('baseModel', '')
|
|
||||||
|
|
||||||
# Get download URL
|
|
||||||
checkpoint['downloadUrl'] = civitai_info.get('downloadUrl', '')
|
|
||||||
else:
|
|
||||||
# Model not found or deleted
|
|
||||||
checkpoint['isDeleted'] = True
|
checkpoint['isDeleted'] = True
|
||||||
|
return checkpoint
|
||||||
|
|
||||||
|
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
||||||
|
checkpoint['name'] = civitai_data['model']['name']
|
||||||
|
|
||||||
|
if 'name' in civitai_data:
|
||||||
|
checkpoint['version'] = civitai_data.get('name', '')
|
||||||
|
|
||||||
|
if 'images' in civitai_data and civitai_data['images']:
|
||||||
|
image_url = civitai_data['images'][0].get('url')
|
||||||
|
if image_url:
|
||||||
|
rewritten_image_url, _ = rewrite_preview_url(image_url, media_type='image')
|
||||||
|
checkpoint['thumbnailUrl'] = rewritten_image_url or image_url
|
||||||
|
|
||||||
|
checkpoint['baseModel'] = civitai_data.get('baseModel', '')
|
||||||
|
checkpoint['downloadUrl'] = civitai_data.get('downloadUrl', '')
|
||||||
|
|
||||||
|
checkpoint['modelId'] = civitai_data.get('modelId', checkpoint.get('modelId', 0))
|
||||||
|
|
||||||
|
if 'files' in civitai_data:
|
||||||
|
model_file = next(
|
||||||
|
(
|
||||||
|
file
|
||||||
|
for file in civitai_data.get('files', [])
|
||||||
|
if file.get('type') == 'Model'
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if model_file:
|
||||||
|
checkpoint['size'] = model_file.get('sizeKB', 0) * 1024
|
||||||
|
|
||||||
|
sha256 = model_file.get('hashes', {}).get('SHA256')
|
||||||
|
if sha256:
|
||||||
|
checkpoint['hash'] = sha256.lower()
|
||||||
|
|
||||||
|
file_name = model_file.get('name', '')
|
||||||
|
if file_name:
|
||||||
|
checkpoint['file_name'] = os.path.splitext(file_name)[0]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error populating checkpoint from Civitai info: {e}")
|
logger.error(f"Error populating checkpoint from Civitai info: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""Parser for Automatic1111 metadata format."""
|
"""Parser for Automatic1111 metadata format."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from ..base import RecipeMetadataParser
|
from ..base import RecipeMetadataParser
|
||||||
from ..constants import GEN_PARAM_KEYS
|
from ..constants import GEN_PARAM_KEYS
|
||||||
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
CIVITAI_METADATA_REGEX = r', Civitai metadata:\s*(\{.*?\})'
|
CIVITAI_METADATA_REGEX = r', Civitai metadata:\s*(\{.*?\})'
|
||||||
EXTRANETS_REGEX = r'<(lora|hypernet):([^:]+):(-?[0-9.]+)>'
|
EXTRANETS_REGEX = r'<(lora|hypernet):([^:]+):(-?[0-9.]+)>'
|
||||||
MODEL_HASH_PATTERN = r'Model hash: ([a-zA-Z0-9]+)'
|
MODEL_HASH_PATTERN = r'Model hash: ([a-zA-Z0-9]+)'
|
||||||
|
MODEL_NAME_PATTERN = r'Model: ([^,]+)'
|
||||||
VAE_HASH_PATTERN = r'VAE hash: ([a-zA-Z0-9]+)'
|
VAE_HASH_PATTERN = r'VAE hash: ([a-zA-Z0-9]+)'
|
||||||
|
|
||||||
def is_metadata_matching(self, user_comment: str) -> bool:
|
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||||
@@ -30,6 +33,9 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||||
"""Parse metadata from Automatic1111 format"""
|
"""Parse metadata from Automatic1111 format"""
|
||||||
try:
|
try:
|
||||||
|
# Get metadata provider instead of using civitai_client directly
|
||||||
|
metadata_provider = await get_default_metadata_provider()
|
||||||
|
|
||||||
# Split on Negative prompt if it exists
|
# Split on Negative prompt if it exists
|
||||||
if "Negative prompt:" in user_comment:
|
if "Negative prompt:" in user_comment:
|
||||||
parts = user_comment.split('Negative prompt:', 1)
|
parts = user_comment.split('Negative prompt:', 1)
|
||||||
@@ -111,6 +117,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.error("Error parsing hashes JSON")
|
logger.error("Error parsing hashes JSON")
|
||||||
|
|
||||||
|
# Pick up model hash from parsed hashes if available
|
||||||
|
if "hashes" in metadata and not metadata.get("model_hash"):
|
||||||
|
model_hash_from_hashes = metadata["hashes"].get("model")
|
||||||
|
if model_hash_from_hashes:
|
||||||
|
metadata["model_hash"] = model_hash_from_hashes
|
||||||
|
|
||||||
# Extract Lora hashes in alternative format
|
# Extract Lora hashes in alternative format
|
||||||
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
||||||
if not hashes_match and lora_hashes_match:
|
if not hashes_match and lora_hashes_match:
|
||||||
@@ -133,6 +145,17 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing Lora hashes: {e}")
|
logger.error(f"Error parsing Lora hashes: {e}")
|
||||||
|
|
||||||
|
# Extract checkpoint model hash/name when provided outside Civitai resources
|
||||||
|
model_hash_match = re.search(self.MODEL_HASH_PATTERN, params_section)
|
||||||
|
if model_hash_match:
|
||||||
|
metadata["model_hash"] = model_hash_match.group(1).strip()
|
||||||
|
params_section = params_section.replace(model_hash_match.group(0), '')
|
||||||
|
|
||||||
|
model_name_match = re.search(self.MODEL_NAME_PATTERN, params_section)
|
||||||
|
if model_name_match:
|
||||||
|
metadata["model_name"] = model_name_match.group(1).strip()
|
||||||
|
params_section = params_section.replace(model_name_match.group(0), '')
|
||||||
|
|
||||||
# Extract basic parameters
|
# Extract basic parameters
|
||||||
param_pattern = r'([A-Za-z\s]+): ([^,]+)'
|
param_pattern = r'([A-Za-z\s]+): ([^,]+)'
|
||||||
@@ -174,9 +197,10 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
metadata["gen_params"] = gen_params
|
metadata["gen_params"] = gen_params
|
||||||
|
|
||||||
# Extract LoRA information
|
# Extract LoRA and checkpoint information
|
||||||
loras = []
|
loras = []
|
||||||
base_model_counts = {}
|
base_model_counts = {}
|
||||||
|
checkpoint = None
|
||||||
|
|
||||||
# First use Civitai resources if available (more reliable source)
|
# First use Civitai resources if available (more reliable source)
|
||||||
if metadata.get("civitai_resources"):
|
if metadata.get("civitai_resources"):
|
||||||
@@ -198,6 +222,50 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
resource["modelVersionId"] = air_modelVersionId
|
resource["modelVersionId"] = air_modelVersionId
|
||||||
# --- End added ---
|
# --- End added ---
|
||||||
|
|
||||||
|
if resource.get("type") == "checkpoint" and resource.get("modelVersionId"):
|
||||||
|
version_id = resource.get("modelVersionId")
|
||||||
|
version_id_str = str(version_id)
|
||||||
|
checkpoint_entry = {
|
||||||
|
'id': version_id,
|
||||||
|
'modelId': resource.get("modelId", 0),
|
||||||
|
'name': resource.get("modelName", "Unknown Checkpoint"),
|
||||||
|
'version': resource.get("modelVersionName", resource.get("versionName", "")),
|
||||||
|
'type': resource.get("type", "checkpoint"),
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': resource.get("modelName", ""),
|
||||||
|
'hash': resource.get("hash", "") or "",
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata_provider:
|
||||||
|
try:
|
||||||
|
civitai_info = await metadata_provider.get_model_version_info(version_id_str)
|
||||||
|
checkpoint_entry = await self.populate_checkpoint_from_civitai(
|
||||||
|
checkpoint_entry,
|
||||||
|
civitai_info
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Error fetching Civitai info for checkpoint version %s: %s",
|
||||||
|
version_id,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prefer the first checkpoint found
|
||||||
|
if checkpoint_entry.get("baseModel"):
|
||||||
|
base_model_value = checkpoint_entry["baseModel"]
|
||||||
|
base_model_counts[base_model_value] = base_model_counts.get(base_model_value, 0) + 1
|
||||||
|
|
||||||
|
if checkpoint is None:
|
||||||
|
checkpoint = checkpoint_entry
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
if resource.get("type") in ["lora", "lycoris", "hypernet"] and resource.get("modelVersionId"):
|
if resource.get("type") in ["lora", "lycoris", "hypernet"] and resource.get("modelVersionId"):
|
||||||
# Initialize lora entry
|
# Initialize lora entry
|
||||||
lora_entry = {
|
lora_entry = {
|
||||||
@@ -216,9 +284,9 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Get additional info from Civitai
|
# Get additional info from Civitai
|
||||||
if civitai_client:
|
if metadata_provider:
|
||||||
try:
|
try:
|
||||||
civitai_info = await civitai_client.get_model_version_info(resource.get("modelVersionId"))
|
civitai_info = await metadata_provider.get_model_version_info(resource.get("modelVersionId"))
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
lora_entry,
|
lora_entry,
|
||||||
civitai_info,
|
civitai_info,
|
||||||
@@ -233,6 +301,52 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
loras.append(lora_entry)
|
loras.append(lora_entry)
|
||||||
|
|
||||||
|
# Fallback checkpoint parsing from generic "Model" and "Model hash" fields
|
||||||
|
if checkpoint is None:
|
||||||
|
model_hash = metadata.get("model_hash")
|
||||||
|
if not model_hash and metadata.get("hashes"):
|
||||||
|
model_hash = metadata["hashes"].get("model")
|
||||||
|
|
||||||
|
model_name = metadata.get("model_name")
|
||||||
|
file_name = ""
|
||||||
|
if model_name:
|
||||||
|
cleaned_name = re.split(r"[\\\\/]", model_name)[-1]
|
||||||
|
file_name = os.path.splitext(cleaned_name)[0]
|
||||||
|
|
||||||
|
if model_hash or model_name:
|
||||||
|
checkpoint_entry = {
|
||||||
|
'id': 0,
|
||||||
|
'modelId': 0,
|
||||||
|
'name': model_name or "Unknown Checkpoint",
|
||||||
|
'version': '',
|
||||||
|
'type': 'checkpoint',
|
||||||
|
'hash': model_hash or "",
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': file_name,
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata_provider and model_hash:
|
||||||
|
try:
|
||||||
|
civitai_info = await metadata_provider.get_model_by_hash(model_hash)
|
||||||
|
checkpoint_entry = await self.populate_checkpoint_from_civitai(
|
||||||
|
checkpoint_entry,
|
||||||
|
civitai_info
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for checkpoint hash {model_hash}: {e}")
|
||||||
|
|
||||||
|
if checkpoint_entry.get("baseModel"):
|
||||||
|
base_model_value = checkpoint_entry["baseModel"]
|
||||||
|
base_model_counts[base_model_value] = base_model_counts.get(base_model_value, 0) + 1
|
||||||
|
|
||||||
|
checkpoint = checkpoint_entry
|
||||||
|
|
||||||
# If no LoRAs from Civitai resources or to supplement, extract from metadata["hashes"]
|
# If no LoRAs from Civitai resources or to supplement, extract from metadata["hashes"]
|
||||||
if not loras or len(loras) == 0:
|
if not loras or len(loras) == 0:
|
||||||
# Extract lora weights from extranet tags in prompt (for later use)
|
# Extract lora weights from extranet tags in prompt (for later use)
|
||||||
@@ -271,11 +385,11 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Try to get info from Civitai
|
# Try to get info from Civitai
|
||||||
if civitai_client:
|
if metadata_provider:
|
||||||
try:
|
try:
|
||||||
if lora_hash:
|
if lora_hash:
|
||||||
# If we have hash, use it for lookup
|
# If we have hash, use it for lookup
|
||||||
civitai_info = await civitai_client.get_model_by_hash(lora_hash)
|
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||||
else:
|
else:
|
||||||
civitai_info = None
|
civitai_info = None
|
||||||
|
|
||||||
@@ -296,7 +410,9 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
# Try to get base model from resources or make educated guess
|
# Try to get base model from resources or make educated guess
|
||||||
base_model = None
|
base_model = None
|
||||||
if base_model_counts:
|
if checkpoint and checkpoint.get("baseModel"):
|
||||||
|
base_model = checkpoint.get("baseModel")
|
||||||
|
elif base_model_counts:
|
||||||
# Use the most common base model from the loras
|
# Use the most common base model from the loras
|
||||||
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||||
|
|
||||||
@@ -313,6 +429,10 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
'gen_params': filtered_gen_params,
|
'gen_params': filtered_gen_params,
|
||||||
'from_automatic_metadata': True
|
'from_automatic_metadata': True
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if checkpoint:
|
||||||
|
result['checkpoint'] = checkpoint
|
||||||
|
result['model'] = checkpoint
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
from typing import Dict, Any, Union
|
from typing import Dict, Any, Union
|
||||||
from ..base import RecipeMetadataParser
|
from ..base import RecipeMetadataParser
|
||||||
from ..constants import GEN_PARAM_KEYS
|
from ..constants import GEN_PARAM_KEYS
|
||||||
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -22,13 +23,48 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
"""
|
"""
|
||||||
if not metadata or not isinstance(metadata, dict):
|
if not metadata or not isinstance(metadata, dict):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check for key markers specific to Civitai image metadata
|
def has_markers(payload: Dict[str, Any]) -> bool:
|
||||||
return any([
|
# Check for common CivitAI image metadata fields
|
||||||
"resources" in metadata,
|
civitai_image_fields = (
|
||||||
"civitaiResources" in metadata,
|
"resources",
|
||||||
"additionalResources" in metadata
|
"civitaiResources",
|
||||||
])
|
"additionalResources",
|
||||||
|
"hashes",
|
||||||
|
"prompt",
|
||||||
|
"negativePrompt",
|
||||||
|
"steps",
|
||||||
|
"sampler",
|
||||||
|
"cfgScale",
|
||||||
|
"seed",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"Model",
|
||||||
|
"Model hash"
|
||||||
|
)
|
||||||
|
return any(key in payload for key in civitai_image_fields)
|
||||||
|
|
||||||
|
# Check the main metadata object
|
||||||
|
if has_markers(metadata):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check for LoRA hash patterns
|
||||||
|
hashes = metadata.get("hashes")
|
||||||
|
if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check nested meta object (common in CivitAI image responses)
|
||||||
|
nested_meta = metadata.get("meta")
|
||||||
|
if isinstance(nested_meta, dict):
|
||||||
|
if has_markers(nested_meta):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Also check for LoRA hash patterns in nested meta
|
||||||
|
hashes = nested_meta.get("hashes")
|
||||||
|
if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
async def parse_metadata(self, metadata, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
async def parse_metadata(self, metadata, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||||
"""Parse metadata from Civitai image format
|
"""Parse metadata from Civitai image format
|
||||||
@@ -36,16 +72,40 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
Args:
|
Args:
|
||||||
metadata: The metadata from the image (dict)
|
metadata: The metadata from the image (dict)
|
||||||
recipe_scanner: Optional recipe scanner service
|
recipe_scanner: Optional recipe scanner service
|
||||||
civitai_client: Optional Civitai API client
|
civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing parsed recipe data
|
Dict containing parsed recipe data
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Get metadata provider instead of using civitai_client directly
|
||||||
|
metadata_provider = await get_default_metadata_provider()
|
||||||
|
|
||||||
|
# Civitai image responses may wrap the actual metadata inside a "meta" key
|
||||||
|
if (
|
||||||
|
isinstance(metadata, dict)
|
||||||
|
and "meta" in metadata
|
||||||
|
and isinstance(metadata["meta"], dict)
|
||||||
|
):
|
||||||
|
inner_meta = metadata["meta"]
|
||||||
|
if any(
|
||||||
|
key in inner_meta
|
||||||
|
for key in (
|
||||||
|
"resources",
|
||||||
|
"civitaiResources",
|
||||||
|
"additionalResources",
|
||||||
|
"hashes",
|
||||||
|
"prompt",
|
||||||
|
"negativePrompt",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
metadata = inner_meta
|
||||||
|
|
||||||
# Initialize result structure
|
# Initialize result structure
|
||||||
result = {
|
result = {
|
||||||
'base_model': None,
|
'base_model': None,
|
||||||
'loras': [],
|
'loras': [],
|
||||||
|
'model': None,
|
||||||
'gen_params': {},
|
'gen_params': {},
|
||||||
'from_civitai_image': True
|
'from_civitai_image': True
|
||||||
}
|
}
|
||||||
@@ -53,6 +113,15 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
# Track already added LoRAs to prevent duplicates
|
# Track already added LoRAs to prevent duplicates
|
||||||
added_loras = {} # key: model_version_id or hash, value: index in result["loras"]
|
added_loras = {} # key: model_version_id or hash, value: index in result["loras"]
|
||||||
|
|
||||||
|
# Extract hash information from hashes field for LoRA matching
|
||||||
|
lora_hashes = {}
|
||||||
|
if "hashes" in metadata and isinstance(metadata["hashes"], dict):
|
||||||
|
for key, hash_value in metadata["hashes"].items():
|
||||||
|
key_str = str(key)
|
||||||
|
if key_str.lower().startswith("lora:"):
|
||||||
|
lora_name = key_str.split(":", 1)[1]
|
||||||
|
lora_hashes[lora_name] = hash_value
|
||||||
|
|
||||||
# Extract prompt and negative prompt
|
# Extract prompt and negative prompt
|
||||||
if "prompt" in metadata:
|
if "prompt" in metadata:
|
||||||
result["gen_params"]["prompt"] = metadata["prompt"]
|
result["gen_params"]["prompt"] = metadata["prompt"]
|
||||||
@@ -77,9 +146,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
# Extract base model information - directly if available
|
# Extract base model information - directly if available
|
||||||
if "baseModel" in metadata:
|
if "baseModel" in metadata:
|
||||||
result["base_model"] = metadata["baseModel"]
|
result["base_model"] = metadata["baseModel"]
|
||||||
elif "Model hash" in metadata and civitai_client:
|
elif "Model hash" in metadata and metadata_provider:
|
||||||
model_hash = metadata["Model hash"]
|
model_hash = metadata["Model hash"]
|
||||||
model_info = await civitai_client.get_model_by_hash(model_hash)
|
model_info, error = await metadata_provider.get_model_by_hash(model_hash)
|
||||||
if model_info:
|
if model_info:
|
||||||
result["base_model"] = model_info.get("baseModel", "")
|
result["base_model"] = model_info.get("baseModel", "")
|
||||||
elif "Model" in metadata and isinstance(metadata.get("resources"), list):
|
elif "Model" in metadata and isinstance(metadata.get("resources"), list):
|
||||||
@@ -87,8 +156,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
for resource in metadata.get("resources", []):
|
for resource in metadata.get("resources", []):
|
||||||
if resource.get("type") == "model" and resource.get("name") == metadata.get("Model"):
|
if resource.get("type") == "model" and resource.get("name") == metadata.get("Model"):
|
||||||
# This is likely the checkpoint model
|
# This is likely the checkpoint model
|
||||||
if civitai_client and resource.get("hash"):
|
if metadata_provider and resource.get("hash"):
|
||||||
model_info = await civitai_client.get_model_by_hash(resource.get("hash"))
|
model_info, error = await metadata_provider.get_model_by_hash(resource.get("hash"))
|
||||||
if model_info:
|
if model_info:
|
||||||
result["base_model"] = model_info.get("baseModel", "")
|
result["base_model"] = model_info.get("baseModel", "")
|
||||||
|
|
||||||
@@ -101,6 +170,10 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
if resource.get("type", "lora") == "lora":
|
if resource.get("type", "lora") == "lora":
|
||||||
lora_hash = resource.get("hash", "")
|
lora_hash = resource.get("hash", "")
|
||||||
|
|
||||||
|
# Try to get hash from the hashes field if not present in resource
|
||||||
|
if not lora_hash and resource.get("name"):
|
||||||
|
lora_hash = lora_hashes.get(resource["name"], "")
|
||||||
|
|
||||||
# Skip LoRAs without proper identification (hash or modelVersionId)
|
# Skip LoRAs without proper identification (hash or modelVersionId)
|
||||||
if not lora_hash and not resource.get("modelVersionId"):
|
if not lora_hash and not resource.get("modelVersionId"):
|
||||||
logger.debug(f"Skipping LoRA resource '{resource.get('name', 'Unknown')}' - no hash or modelVersionId")
|
logger.debug(f"Skipping LoRA resource '{resource.get('name', 'Unknown')}' - no hash or modelVersionId")
|
||||||
@@ -126,9 +199,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Try to get info from Civitai if hash is available
|
# Try to get info from Civitai if hash is available
|
||||||
if lora_entry['hash'] and civitai_client:
|
if lora_entry['hash'] and metadata_provider:
|
||||||
try:
|
try:
|
||||||
civitai_info = await civitai_client.get_model_by_hash(lora_hash)
|
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||||
|
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
lora_entry,
|
lora_entry,
|
||||||
@@ -158,13 +231,48 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
# Process civitaiResources array
|
# Process civitaiResources array
|
||||||
if "civitaiResources" in metadata and isinstance(metadata["civitaiResources"], list):
|
if "civitaiResources" in metadata and isinstance(metadata["civitaiResources"], list):
|
||||||
for resource in metadata["civitaiResources"]:
|
for resource in metadata["civitaiResources"]:
|
||||||
# Get unique identifier for deduplication
|
# Get resource type and identifier
|
||||||
|
resource_type = str(resource.get("type") or "").lower()
|
||||||
version_id = str(resource.get("modelVersionId", ""))
|
version_id = str(resource.get("modelVersionId", ""))
|
||||||
|
|
||||||
|
if resource_type == "checkpoint":
|
||||||
|
checkpoint_entry = {
|
||||||
|
'id': resource.get("modelVersionId", 0),
|
||||||
|
'modelId': resource.get("modelId", 0),
|
||||||
|
'name': resource.get("modelName", "Unknown Checkpoint"),
|
||||||
|
'version': resource.get("modelVersionName", ""),
|
||||||
|
'type': resource.get("type", "checkpoint"),
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': resource.get("modelName", ""),
|
||||||
|
'hash': resource.get("hash", "") or "",
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
if version_id and metadata_provider:
|
||||||
|
try:
|
||||||
|
civitai_info = await metadata_provider.get_model_version_info(version_id)
|
||||||
|
|
||||||
|
checkpoint_entry = await self.populate_checkpoint_from_civitai(
|
||||||
|
checkpoint_entry,
|
||||||
|
civitai_info
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for checkpoint version {version_id}: {e}")
|
||||||
|
|
||||||
|
if result["model"] is None:
|
||||||
|
result["model"] = checkpoint_entry
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
# Skip if we've already added this LoRA
|
# Skip if we've already added this LoRA
|
||||||
if version_id and version_id in added_loras:
|
if version_id and version_id in added_loras:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Initialize lora entry
|
# Initialize lora entry
|
||||||
lora_entry = {
|
lora_entry = {
|
||||||
'id': resource.get("modelVersionId", 0),
|
'id': resource.get("modelVersionId", 0),
|
||||||
@@ -180,35 +288,31 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
'downloadUrl': '',
|
'downloadUrl': '',
|
||||||
'isDeleted': False
|
'isDeleted': False
|
||||||
}
|
}
|
||||||
|
|
||||||
# Try to get info from Civitai if modelVersionId is available
|
# Try to get info from Civitai if modelVersionId is available
|
||||||
if version_id and civitai_client:
|
if version_id and metadata_provider:
|
||||||
try:
|
try:
|
||||||
# Use get_model_version_info instead of get_model_version
|
# Use get_model_version_info instead of get_model_version
|
||||||
civitai_info, error = await civitai_client.get_model_version_info(version_id)
|
civitai_info = await metadata_provider.get_model_version_info(version_id)
|
||||||
|
|
||||||
if error:
|
|
||||||
logger.warning(f"Error getting model version info: {error}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
lora_entry,
|
lora_entry,
|
||||||
civitai_info,
|
civitai_info,
|
||||||
recipe_scanner,
|
recipe_scanner,
|
||||||
base_model_counts
|
base_model_counts
|
||||||
)
|
)
|
||||||
|
|
||||||
if populated_entry is None:
|
if populated_entry is None:
|
||||||
continue # Skip invalid LoRA types
|
continue # Skip invalid LoRA types
|
||||||
|
|
||||||
lora_entry = populated_entry
|
lora_entry = populated_entry
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching Civitai info for model version {version_id}: {e}")
|
logger.error(f"Error fetching Civitai info for model version {version_id}: {e}")
|
||||||
|
|
||||||
# Track this LoRA in our deduplication dict
|
# Track this LoRA in our deduplication dict
|
||||||
if version_id:
|
if version_id:
|
||||||
added_loras[version_id] = len(result["loras"])
|
added_loras[version_id] = len(result["loras"])
|
||||||
|
|
||||||
result["loras"].append(lora_entry)
|
result["loras"].append(lora_entry)
|
||||||
|
|
||||||
# Process additionalResources array
|
# Process additionalResources array
|
||||||
@@ -247,35 +351,84 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
'isDeleted': False
|
'isDeleted': False
|
||||||
}
|
}
|
||||||
|
|
||||||
# If we have a version ID and civitai client, try to get more info
|
# If we have a version ID and metadata provider, try to get more info
|
||||||
if version_id and civitai_client:
|
if version_id and metadata_provider:
|
||||||
try:
|
try:
|
||||||
# Use get_model_version_info with the version ID
|
# Use get_model_version_info with the version ID
|
||||||
civitai_info, error = await civitai_client.get_model_version_info(version_id)
|
civitai_info = await metadata_provider.get_model_version_info(version_id)
|
||||||
|
|
||||||
if error:
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
logger.warning(f"Error getting model version info: {error}")
|
lora_entry,
|
||||||
else:
|
civitai_info,
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
recipe_scanner,
|
||||||
lora_entry,
|
base_model_counts
|
||||||
civitai_info,
|
)
|
||||||
recipe_scanner,
|
|
||||||
base_model_counts
|
if populated_entry is None:
|
||||||
)
|
continue # Skip invalid LoRA types
|
||||||
|
|
||||||
if populated_entry is None:
|
lora_entry = populated_entry
|
||||||
continue # Skip invalid LoRA types
|
|
||||||
|
# Track this LoRA for deduplication
|
||||||
lora_entry = populated_entry
|
if version_id:
|
||||||
|
added_loras[version_id] = len(result["loras"])
|
||||||
# Track this LoRA for deduplication
|
|
||||||
if version_id:
|
|
||||||
added_loras[version_id] = len(result["loras"])
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching Civitai info for model ID {version_id}: {e}")
|
logger.error(f"Error fetching Civitai info for model ID {version_id}: {e}")
|
||||||
|
|
||||||
result["loras"].append(lora_entry)
|
result["loras"].append(lora_entry)
|
||||||
|
|
||||||
|
# If we found LoRA hashes in the metadata but haven't already
|
||||||
|
# populated entries for them, fall back to creating LoRAs from
|
||||||
|
# the hashes section. Some Civitai image responses only include
|
||||||
|
# LoRA information here without explicit resources entries.
|
||||||
|
for lora_name, lora_hash in lora_hashes.items():
|
||||||
|
if not lora_hash:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip LoRAs we've already added via resources or other fields
|
||||||
|
if lora_hash in added_loras:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lora_entry = {
|
||||||
|
'name': lora_name,
|
||||||
|
'type': "lora",
|
||||||
|
'weight': 1.0,
|
||||||
|
'hash': lora_hash,
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': lora_name,
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata_provider:
|
||||||
|
try:
|
||||||
|
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||||
|
|
||||||
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
|
lora_entry,
|
||||||
|
civitai_info,
|
||||||
|
recipe_scanner,
|
||||||
|
base_model_counts,
|
||||||
|
lora_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
if populated_entry is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lora_entry = populated_entry
|
||||||
|
|
||||||
|
if 'id' in lora_entry and lora_entry['id']:
|
||||||
|
added_loras[str(lora_entry['id'])] = len(result["loras"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for LoRA hash {lora_hash}: {e}")
|
||||||
|
|
||||||
|
added_loras[lora_hash] = len(result["loras"])
|
||||||
|
result["loras"].append(lora_entry)
|
||||||
|
|
||||||
# Check for LoRA info in the format "Lora_0 Model hash", "Lora_0 Model name", etc.
|
# Check for LoRA info in the format "Lora_0 Model hash", "Lora_0 Model name", etc.
|
||||||
lora_index = 0
|
lora_index = 0
|
||||||
while f"Lora_{lora_index} Model hash" in metadata and f"Lora_{lora_index} Model name" in metadata:
|
while f"Lora_{lora_index} Model hash" in metadata and f"Lora_{lora_index} Model name" in metadata:
|
||||||
@@ -304,9 +457,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Try to get info from Civitai if hash is available
|
# Try to get info from Civitai if hash is available
|
||||||
if lora_entry['hash'] and civitai_client:
|
if lora_entry['hash'] and metadata_provider:
|
||||||
try:
|
try:
|
||||||
civitai_info = await civitai_client.get_model_by_hash(lora_hash)
|
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||||
|
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
lora_entry,
|
lora_entry,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import logging
|
|||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from ..base import RecipeMetadataParser
|
from ..base import RecipeMetadataParser
|
||||||
from ..constants import GEN_PARAM_KEYS
|
from ..constants import GEN_PARAM_KEYS
|
||||||
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,6 +27,9 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
|||||||
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||||
"""Parse metadata from Civitai ComfyUI metadata format"""
|
"""Parse metadata from Civitai ComfyUI metadata format"""
|
||||||
try:
|
try:
|
||||||
|
# Get metadata provider instead of using civitai_client directly
|
||||||
|
metadata_provider = await get_default_metadata_provider()
|
||||||
|
|
||||||
data = json.loads(user_comment)
|
data = json.loads(user_comment)
|
||||||
loras = []
|
loras = []
|
||||||
|
|
||||||
@@ -73,10 +77,10 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
|||||||
'isDeleted': False
|
'isDeleted': False
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get additional info from Civitai if client is available
|
# Get additional info from Civitai if metadata provider is available
|
||||||
if civitai_client:
|
if metadata_provider:
|
||||||
try:
|
try:
|
||||||
civitai_info_tuple = await civitai_client.get_model_version_info(model_version_id)
|
civitai_info_tuple = await metadata_provider.get_model_version_info(model_version_id)
|
||||||
# Populate lora entry with Civitai info
|
# Populate lora entry with Civitai info
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
lora_entry,
|
lora_entry,
|
||||||
@@ -116,9 +120,9 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Get additional checkpoint info from Civitai
|
# Get additional checkpoint info from Civitai
|
||||||
if civitai_client:
|
if metadata_provider:
|
||||||
try:
|
try:
|
||||||
civitai_info_tuple = await civitai_client.get_model_version_info(checkpoint_version_id)
|
civitai_info_tuple = await metadata_provider.get_model_version_info(checkpoint_version_id)
|
||||||
civitai_info, _ = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
civitai_info, _ = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
||||||
# Populate checkpoint with Civitai info
|
# Populate checkpoint with Civitai info
|
||||||
checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info)
|
checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Parser for meta format (Lora_N Model hash) metadata."""
|
"""Parser for meta format (Lora_N Model hash) metadata."""
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from ..base import RecipeMetadataParser
|
from ..base import RecipeMetadataParser
|
||||||
from ..constants import GEN_PARAM_KEYS
|
from ..constants import GEN_PARAM_KEYS
|
||||||
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -18,8 +20,11 @@ class MetaFormatParser(RecipeMetadataParser):
|
|||||||
return re.search(self.METADATA_MARKER, user_comment, re.IGNORECASE | re.DOTALL) is not None
|
return re.search(self.METADATA_MARKER, user_comment, re.IGNORECASE | re.DOTALL) is not None
|
||||||
|
|
||||||
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||||
"""Parse metadata from images with meta format metadata"""
|
"""Parse metadata from images with meta format metadata (Lora_N Model hash format)"""
|
||||||
try:
|
try:
|
||||||
|
# Get metadata provider instead of using civitai_client directly
|
||||||
|
metadata_provider = await get_default_metadata_provider()
|
||||||
|
|
||||||
# Extract prompt and negative prompt
|
# Extract prompt and negative prompt
|
||||||
parts = user_comment.split('Negative prompt:', 1)
|
parts = user_comment.split('Negative prompt:', 1)
|
||||||
prompt = parts[0].strip()
|
prompt = parts[0].strip()
|
||||||
@@ -122,9 +127,9 @@ class MetaFormatParser(RecipeMetadataParser):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Get info from Civitai by hash if available
|
# Get info from Civitai by hash if available
|
||||||
if civitai_client and hash_value:
|
if metadata_provider and hash_value:
|
||||||
try:
|
try:
|
||||||
civitai_info = await civitai_client.get_model_by_hash(hash_value)
|
civitai_info = await metadata_provider.get_model_by_hash(hash_value)
|
||||||
# Populate lora entry with Civitai info
|
# Populate lora entry with Civitai info
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
lora_entry,
|
lora_entry,
|
||||||
@@ -141,14 +146,53 @@ class MetaFormatParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
loras.append(lora_entry)
|
loras.append(lora_entry)
|
||||||
|
|
||||||
# Extract model information
|
# Extract checkpoint information from generic Model/Model hash fields
|
||||||
model = None
|
checkpoint = None
|
||||||
if 'model' in metadata:
|
model_hash = metadata.get("model_hash")
|
||||||
model = metadata['model']
|
model_name = metadata.get("model")
|
||||||
|
|
||||||
|
if model_hash or model_name:
|
||||||
|
cleaned_name = None
|
||||||
|
if model_name:
|
||||||
|
cleaned_name = re.split(r"[\\\\/]", model_name)[-1]
|
||||||
|
cleaned_name = os.path.splitext(cleaned_name)[0]
|
||||||
|
|
||||||
|
checkpoint_entry = {
|
||||||
|
'id': 0,
|
||||||
|
'modelId': 0,
|
||||||
|
'name': model_name or "Unknown Checkpoint",
|
||||||
|
'version': '',
|
||||||
|
'type': 'checkpoint',
|
||||||
|
'hash': model_hash or "",
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': cleaned_name or (model_name or ""),
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata_provider and model_hash:
|
||||||
|
try:
|
||||||
|
civitai_info = await metadata_provider.get_model_by_hash(model_hash)
|
||||||
|
checkpoint_entry = await self.populate_checkpoint_from_civitai(
|
||||||
|
checkpoint_entry,
|
||||||
|
civitai_info
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for checkpoint hash {model_hash}: {e}")
|
||||||
|
|
||||||
|
if checkpoint_entry.get("baseModel"):
|
||||||
|
base_model_value = checkpoint_entry["baseModel"]
|
||||||
|
base_model_counts[base_model_value] = base_model_counts.get(base_model_value, 0) + 1
|
||||||
|
|
||||||
|
checkpoint = checkpoint_entry
|
||||||
|
|
||||||
# Set base_model to the most common one from civitai_info
|
# Set base_model to the most common one from civitai_info or checkpoint
|
||||||
base_model = None
|
base_model = checkpoint["baseModel"] if checkpoint and checkpoint.get("baseModel") else None
|
||||||
if base_model_counts:
|
if not base_model and base_model_counts:
|
||||||
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||||
|
|
||||||
# Extract generation parameters for recipe metadata
|
# Extract generation parameters for recipe metadata
|
||||||
@@ -166,7 +210,8 @@ class MetaFormatParser(RecipeMetadataParser):
|
|||||||
'loras': loras,
|
'loras': loras,
|
||||||
'gen_params': gen_params,
|
'gen_params': gen_params,
|
||||||
'raw_metadata': metadata,
|
'raw_metadata': metadata,
|
||||||
'from_meta_format': True
|
'from_meta_format': True,
|
||||||
|
**({'checkpoint': checkpoint, 'model': checkpoint} if checkpoint else {})
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, Optional
|
||||||
from ...config import config
|
from ...config import config
|
||||||
from ..base import RecipeMetadataParser
|
from ..base import RecipeMetadataParser
|
||||||
from ..constants import GEN_PARAM_KEYS
|
from ..constants import GEN_PARAM_KEYS
|
||||||
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -15,6 +16,28 @@ class RecipeFormatParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
# Regular expression pattern for extracting recipe metadata
|
# Regular expression pattern for extracting recipe metadata
|
||||||
METADATA_MARKER = r'Recipe metadata: (\{.*\})'
|
METADATA_MARKER = r'Recipe metadata: (\{.*\})'
|
||||||
|
|
||||||
|
async def _get_lora_from_version_index(self, recipe_scanner, model_version_id: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return a cached LoRA entry by modelVersionId if available."""
|
||||||
|
|
||||||
|
if not recipe_scanner or not getattr(recipe_scanner, "_lora_scanner", None):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
normalized_id = int(model_version_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache = await recipe_scanner._lora_scanner.get_cached_data()
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.debug("Unable to load lora cache for version lookup: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not cache or not getattr(cache, "version_index", None):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return cache.version_index.get(normalized_id)
|
||||||
|
|
||||||
def is_metadata_matching(self, user_comment: str) -> bool:
|
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||||
"""Check if the user comment matches the metadata format"""
|
"""Check if the user comment matches the metadata format"""
|
||||||
@@ -23,6 +46,9 @@ class RecipeFormatParser(RecipeMetadataParser):
|
|||||||
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||||
"""Parse metadata from images with dedicated recipe metadata format"""
|
"""Parse metadata from images with dedicated recipe metadata format"""
|
||||||
try:
|
try:
|
||||||
|
# Get metadata provider instead of using civitai_client directly
|
||||||
|
metadata_provider = await get_default_metadata_provider()
|
||||||
|
|
||||||
# Extract recipe metadata from user comment
|
# Extract recipe metadata from user comment
|
||||||
try:
|
try:
|
||||||
# Look for recipe metadata section
|
# Look for recipe metadata section
|
||||||
@@ -49,49 +75,110 @@ class RecipeFormatParser(RecipeMetadataParser):
|
|||||||
'type': 'lora',
|
'type': 'lora',
|
||||||
'weight': lora.get('strength', 1.0),
|
'weight': lora.get('strength', 1.0),
|
||||||
'file_name': lora.get('file_name', ''),
|
'file_name': lora.get('file_name', ''),
|
||||||
'hash': lora.get('hash', '')
|
'hash': lora.get('hash', ''),
|
||||||
|
'existsLocally': False,
|
||||||
|
'inLibrary': False,
|
||||||
|
'localPath': None,
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'size': 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if this LoRA exists locally by SHA256 hash
|
# Check if this LoRA exists locally by SHA256 hash
|
||||||
if lora.get('hash') and recipe_scanner:
|
if recipe_scanner:
|
||||||
lora_scanner = recipe_scanner._lora_scanner
|
lora_scanner = recipe_scanner._lora_scanner
|
||||||
exists_locally = lora_scanner.has_hash(lora['hash'])
|
|
||||||
if exists_locally:
|
if lora.get('hash'):
|
||||||
lora_cache = await lora_scanner.get_cached_data()
|
exists_locally = lora_scanner.has_hash(lora['hash'])
|
||||||
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
|
if exists_locally:
|
||||||
if lora_item:
|
lora_cache = await lora_scanner.get_cached_data()
|
||||||
|
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
|
||||||
|
if lora_item:
|
||||||
|
lora_entry['existsLocally'] = True
|
||||||
|
lora_entry['inLibrary'] = True
|
||||||
|
lora_entry['localPath'] = lora_item['file_path']
|
||||||
|
lora_entry['file_name'] = lora_item['file_name']
|
||||||
|
lora_entry['size'] = lora_item['size']
|
||||||
|
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
|
||||||
|
|
||||||
|
else:
|
||||||
|
lora_entry['existsLocally'] = False
|
||||||
|
lora_entry['inLibrary'] = False
|
||||||
|
lora_entry['localPath'] = None
|
||||||
|
|
||||||
|
# If we still don't have a local match, try matching by modelVersionId
|
||||||
|
if not lora_entry['existsLocally'] and lora.get('modelVersionId') is not None:
|
||||||
|
cached_lora = await self._get_lora_from_version_index(recipe_scanner, lora.get('modelVersionId'))
|
||||||
|
if cached_lora:
|
||||||
lora_entry['existsLocally'] = True
|
lora_entry['existsLocally'] = True
|
||||||
lora_entry['localPath'] = lora_item['file_path']
|
lora_entry['inLibrary'] = True
|
||||||
lora_entry['file_name'] = lora_item['file_name']
|
lora_entry['localPath'] = cached_lora.get('file_path')
|
||||||
lora_entry['size'] = lora_item['size']
|
lora_entry['file_name'] = cached_lora.get('file_name') or lora_entry['file_name']
|
||||||
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
|
lora_entry['size'] = cached_lora.get('size', lora_entry['size'])
|
||||||
|
if cached_lora.get('sha256'):
|
||||||
else:
|
lora_entry['hash'] = cached_lora['sha256']
|
||||||
lora_entry['existsLocally'] = False
|
preview_url = cached_lora.get('preview_url')
|
||||||
lora_entry['localPath'] = None
|
if preview_url:
|
||||||
|
lora_entry['thumbnailUrl'] = config.get_preview_static_url(preview_url)
|
||||||
# Try to get additional info from Civitai if we have a model version ID
|
|
||||||
if lora.get('modelVersionId') and civitai_client:
|
# Try to get additional info from Civitai if we have a model version ID and still missing locally
|
||||||
try:
|
if not lora_entry['existsLocally'] and lora.get('modelVersionId') and metadata_provider:
|
||||||
civitai_info_tuple = await civitai_client.get_model_version_info(lora['modelVersionId'])
|
try:
|
||||||
# Populate lora entry with Civitai info
|
civitai_info_tuple = await metadata_provider.get_model_version_info(lora['modelVersionId'])
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
# Populate lora entry with Civitai info
|
||||||
lora_entry,
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
civitai_info_tuple,
|
lora_entry,
|
||||||
recipe_scanner,
|
civitai_info_tuple,
|
||||||
None, # No need to track base model counts
|
recipe_scanner,
|
||||||
lora['hash']
|
None, # No need to track base model counts
|
||||||
)
|
lora_entry.get('hash', '')
|
||||||
if populated_entry is None:
|
)
|
||||||
continue # Skip invalid LoRA types
|
if populated_entry is None:
|
||||||
lora_entry = populated_entry
|
continue # Skip invalid LoRA types
|
||||||
except Exception as e:
|
lora_entry = populated_entry
|
||||||
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
except Exception as e:
|
||||||
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
||||||
|
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||||
|
|
||||||
loras.append(lora_entry)
|
loras.append(lora_entry)
|
||||||
|
|
||||||
logger.info(f"Found {len(loras)} loras in recipe metadata")
|
logger.info(f"Found {len(loras)} loras in recipe metadata")
|
||||||
|
|
||||||
|
# Process checkpoint information if present
|
||||||
|
checkpoint = None
|
||||||
|
checkpoint_data = recipe_metadata.get('checkpoint') or {}
|
||||||
|
if isinstance(checkpoint_data, dict) and checkpoint_data:
|
||||||
|
version_id = checkpoint_data.get('modelVersionId') or checkpoint_data.get('id')
|
||||||
|
checkpoint_entry = {
|
||||||
|
'id': version_id or 0,
|
||||||
|
'modelId': checkpoint_data.get('modelId', 0),
|
||||||
|
'name': checkpoint_data.get('name', 'Unknown Checkpoint'),
|
||||||
|
'version': checkpoint_data.get('version', ''),
|
||||||
|
'type': checkpoint_data.get('type', 'checkpoint'),
|
||||||
|
'hash': checkpoint_data.get('hash', ''),
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': checkpoint_data.get('file_name', ''),
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata_provider:
|
||||||
|
try:
|
||||||
|
civitai_info = None
|
||||||
|
if version_id:
|
||||||
|
civitai_info = await metadata_provider.get_model_version_info(str(version_id))
|
||||||
|
elif checkpoint_entry.get('hash'):
|
||||||
|
civitai_info = await metadata_provider.get_model_by_hash(checkpoint_entry['hash'])
|
||||||
|
|
||||||
|
if civitai_info:
|
||||||
|
checkpoint_entry = await self.populate_checkpoint_from_civitai(checkpoint_entry, civitai_info)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for checkpoint in recipe metadata: {e}")
|
||||||
|
|
||||||
|
checkpoint = checkpoint_entry
|
||||||
|
|
||||||
# Filter gen_params to only include recognized keys
|
# Filter gen_params to only include recognized keys
|
||||||
filtered_gen_params = {}
|
filtered_gen_params = {}
|
||||||
@@ -101,12 +188,13 @@ class RecipeFormatParser(RecipeMetadataParser):
|
|||||||
filtered_gen_params[key] = value
|
filtered_gen_params[key] = value
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'base_model': recipe_metadata.get('base_model', ''),
|
'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else recipe_metadata.get('base_model', ''),
|
||||||
'loras': loras,
|
'loras': loras,
|
||||||
'gen_params': filtered_gen_params,
|
'gen_params': filtered_gen_params,
|
||||||
'tags': recipe_metadata.get('tags', []),
|
'tags': recipe_metadata.get('tags', []),
|
||||||
'title': recipe_metadata.get('title', ''),
|
'title': recipe_metadata.get('title', ''),
|
||||||
'from_recipe_metadata': True
|
'from_recipe_metadata': True,
|
||||||
|
**({'checkpoint': checkpoint, 'model': checkpoint} if checkpoint else {})
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
218
py/routes/base_recipe_routes.py
Normal file
218
py/routes/base_recipe_routes.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""Base infrastructure shared across recipe routes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Callable, Mapping
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from ..config import config
|
||||||
|
from ..recipes import RecipeParserFactory
|
||||||
|
from ..services.downloader import get_downloader
|
||||||
|
from ..services.recipes import (
|
||||||
|
RecipeAnalysisService,
|
||||||
|
RecipePersistenceService,
|
||||||
|
RecipeSharingService,
|
||||||
|
)
|
||||||
|
from ..services.server_i18n import server_i18n
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
from ..services.settings_manager import get_settings_manager
|
||||||
|
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||||
|
from ..utils.exif_utils import ExifUtils
|
||||||
|
from .handlers.recipe_handlers import (
|
||||||
|
RecipeAnalysisHandler,
|
||||||
|
RecipeHandlerSet,
|
||||||
|
RecipeListingHandler,
|
||||||
|
RecipeManagementHandler,
|
||||||
|
RecipePageView,
|
||||||
|
RecipeQueryHandler,
|
||||||
|
RecipeSharingHandler,
|
||||||
|
)
|
||||||
|
from .recipe_route_registrar import ROUTE_DEFINITIONS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRecipeRoutes:
|
||||||
|
"""Common dependency and startup wiring for recipe routes."""
|
||||||
|
|
||||||
|
_HANDLER_NAMES: tuple[str, ...] = tuple(
|
||||||
|
definition.handler_name for definition in ROUTE_DEFINITIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
template_name: str = "recipes.html"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.recipe_scanner = None
|
||||||
|
self.lora_scanner = None
|
||||||
|
self.civitai_client = None
|
||||||
|
self.settings = get_settings_manager()
|
||||||
|
self.server_i18n = server_i18n
|
||||||
|
self.template_env = jinja2.Environment(
|
||||||
|
loader=jinja2.FileSystemLoader(config.templates_path),
|
||||||
|
autoescape=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._i18n_registered = False
|
||||||
|
self._startup_hooks_registered = False
|
||||||
|
self._handler_set: RecipeHandlerSet | None = None
|
||||||
|
self._handler_mapping: dict[str, Callable] | None = None
|
||||||
|
|
||||||
|
async def attach_dependencies(self, app: web.Application | None = None) -> None:
|
||||||
|
"""Resolve shared services from the registry."""
|
||||||
|
|
||||||
|
await self._ensure_services()
|
||||||
|
self._ensure_i18n_filter()
|
||||||
|
|
||||||
|
async def ensure_dependencies_ready(self) -> None:
|
||||||
|
"""Ensure dependencies are available for request handlers."""
|
||||||
|
|
||||||
|
if self.recipe_scanner is None or self.civitai_client is None:
|
||||||
|
await self.attach_dependencies()
|
||||||
|
|
||||||
|
def register_startup_hooks(self, app: web.Application) -> None:
|
||||||
|
"""Register startup hooks once for dependency wiring."""
|
||||||
|
|
||||||
|
if self._startup_hooks_registered:
|
||||||
|
return
|
||||||
|
|
||||||
|
app.on_startup.append(self.attach_dependencies)
|
||||||
|
app.on_startup.append(self.prewarm_cache)
|
||||||
|
self._startup_hooks_registered = True
|
||||||
|
|
||||||
|
async def prewarm_cache(self, app: web.Application | None = None) -> None:
|
||||||
|
"""Pre-load recipe and LoRA caches on startup."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.attach_dependencies(app)
|
||||||
|
|
||||||
|
if self.lora_scanner is not None:
|
||||||
|
await self.lora_scanner.get_cached_data()
|
||||||
|
hash_index = getattr(self.lora_scanner, "_hash_index", None)
|
||||||
|
if hash_index is not None and hasattr(hash_index, "_hash_to_path"):
|
||||||
|
_ = len(hash_index._hash_to_path)
|
||||||
|
|
||||||
|
if self.recipe_scanner is not None:
|
||||||
|
await self.recipe_scanner.get_cached_data(force_refresh=True)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error pre-warming recipe cache: %s", exc, exc_info=True)
|
||||||
|
|
||||||
|
def to_route_mapping(self) -> Mapping[str, Callable]:
|
||||||
|
"""Return a mapping of handler name to coroutine for registrar binding."""
|
||||||
|
|
||||||
|
if self._handler_mapping is None:
|
||||||
|
handler_set = self._create_handler_set()
|
||||||
|
self._handler_set = handler_set
|
||||||
|
self._handler_mapping = handler_set.to_route_mapping()
|
||||||
|
return self._handler_mapping
|
||||||
|
|
||||||
|
# Internal helpers -------------------------------------------------
|
||||||
|
|
||||||
|
async def _ensure_services(self) -> None:
|
||||||
|
if self.recipe_scanner is None:
|
||||||
|
self.recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
||||||
|
self.lora_scanner = getattr(self.recipe_scanner, "_lora_scanner", None)
|
||||||
|
|
||||||
|
if self.civitai_client is None:
|
||||||
|
self.civitai_client = await ServiceRegistry.get_civitai_client()
|
||||||
|
|
||||||
|
def _ensure_i18n_filter(self) -> None:
|
||||||
|
if not self._i18n_registered:
|
||||||
|
self.template_env.filters["t"] = self.server_i18n.create_template_filter()
|
||||||
|
self._i18n_registered = True
|
||||||
|
|
||||||
|
def get_handler_owner(self):
|
||||||
|
"""Return the object supplying bound handler coroutines."""
|
||||||
|
|
||||||
|
if self._handler_set is None:
|
||||||
|
self._handler_set = self._create_handler_set()
|
||||||
|
return self._handler_set
|
||||||
|
|
||||||
|
def _create_handler_set(self) -> RecipeHandlerSet:
|
||||||
|
recipe_scanner_getter = lambda: self.recipe_scanner
|
||||||
|
civitai_client_getter = lambda: self.civitai_client
|
||||||
|
|
||||||
|
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||||
|
if not standalone_mode:
|
||||||
|
from ..metadata_collector import get_metadata # type: ignore[import-not-found]
|
||||||
|
from ..metadata_collector.metadata_processor import ( # type: ignore[import-not-found]
|
||||||
|
MetadataProcessor,
|
||||||
|
)
|
||||||
|
from ..metadata_collector.metadata_registry import ( # type: ignore[import-not-found]
|
||||||
|
MetadataRegistry,
|
||||||
|
)
|
||||||
|
else: # pragma: no cover - optional dependency path
|
||||||
|
get_metadata = None # type: ignore[assignment]
|
||||||
|
MetadataProcessor = None # type: ignore[assignment]
|
||||||
|
MetadataRegistry = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
analysis_service = RecipeAnalysisService(
|
||||||
|
exif_utils=ExifUtils,
|
||||||
|
recipe_parser_factory=RecipeParserFactory,
|
||||||
|
downloader_factory=get_downloader,
|
||||||
|
metadata_collector=get_metadata,
|
||||||
|
metadata_processor_cls=MetadataProcessor,
|
||||||
|
metadata_registry_cls=MetadataRegistry,
|
||||||
|
standalone_mode=standalone_mode,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
persistence_service = RecipePersistenceService(
|
||||||
|
exif_utils=ExifUtils,
|
||||||
|
card_preview_width=CARD_PREVIEW_WIDTH,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
sharing_service = RecipeSharingService(logger=logger)
|
||||||
|
|
||||||
|
page_view = RecipePageView(
|
||||||
|
ensure_dependencies_ready=self.ensure_dependencies_ready,
|
||||||
|
settings_service=self.settings,
|
||||||
|
server_i18n=self.server_i18n,
|
||||||
|
template_env=self.template_env,
|
||||||
|
template_name=self.template_name,
|
||||||
|
recipe_scanner_getter=recipe_scanner_getter,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
listing = RecipeListingHandler(
|
||||||
|
ensure_dependencies_ready=self.ensure_dependencies_ready,
|
||||||
|
recipe_scanner_getter=recipe_scanner_getter,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
query = RecipeQueryHandler(
|
||||||
|
ensure_dependencies_ready=self.ensure_dependencies_ready,
|
||||||
|
recipe_scanner_getter=recipe_scanner_getter,
|
||||||
|
format_recipe_file_url=listing.format_recipe_file_url,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
management = RecipeManagementHandler(
|
||||||
|
ensure_dependencies_ready=self.ensure_dependencies_ready,
|
||||||
|
recipe_scanner_getter=recipe_scanner_getter,
|
||||||
|
logger=logger,
|
||||||
|
persistence_service=persistence_service,
|
||||||
|
analysis_service=analysis_service,
|
||||||
|
downloader_factory=get_downloader,
|
||||||
|
civitai_client_getter=civitai_client_getter,
|
||||||
|
)
|
||||||
|
analysis = RecipeAnalysisHandler(
|
||||||
|
ensure_dependencies_ready=self.ensure_dependencies_ready,
|
||||||
|
recipe_scanner_getter=recipe_scanner_getter,
|
||||||
|
civitai_client_getter=civitai_client_getter,
|
||||||
|
logger=logger,
|
||||||
|
analysis_service=analysis_service,
|
||||||
|
)
|
||||||
|
sharing = RecipeSharingHandler(
|
||||||
|
ensure_dependencies_ready=self.ensure_dependencies_ready,
|
||||||
|
recipe_scanner_getter=recipe_scanner_getter,
|
||||||
|
logger=logger,
|
||||||
|
sharing_service=sharing_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
return RecipeHandlerSet(
|
||||||
|
page_view=page_view,
|
||||||
|
listing=listing,
|
||||||
|
query=query,
|
||||||
|
management=management,
|
||||||
|
analysis=analysis,
|
||||||
|
sharing=sharing,
|
||||||
|
)
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from typing import Dict
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from .base_model_routes import BaseModelRoutes
|
from .base_model_routes import BaseModelRoutes
|
||||||
|
from .model_route_registrar import ModelRouteRegistrar
|
||||||
from ..services.checkpoint_service import CheckpointService
|
from ..services.checkpoint_service import CheckpointService
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..config import config
|
from ..config import config
|
||||||
@@ -13,19 +15,18 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize Checkpoint routes with Checkpoint service"""
|
"""Initialize Checkpoint routes with Checkpoint service"""
|
||||||
# Service will be initialized later via setup_routes
|
super().__init__()
|
||||||
self.service = None
|
|
||||||
self.civitai_client = None
|
|
||||||
self.template_name = "checkpoints.html"
|
self.template_name = "checkpoints.html"
|
||||||
|
|
||||||
async def initialize_services(self):
|
async def initialize_services(self):
|
||||||
"""Initialize services from ServiceRegistry"""
|
"""Initialize services from ServiceRegistry"""
|
||||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
self.service = CheckpointService(checkpoint_scanner)
|
update_service = await ServiceRegistry.get_model_update_service()
|
||||||
self.civitai_client = await ServiceRegistry.get_civitai_client()
|
self.service = CheckpointService(checkpoint_scanner, update_service=update_service)
|
||||||
|
self.set_model_update_service(update_service)
|
||||||
# Initialize parent with the service
|
|
||||||
super().__init__(self.service)
|
# Attach service dependencies
|
||||||
|
self.attach_service(self.service)
|
||||||
|
|
||||||
def setup_routes(self, app: web.Application):
|
def setup_routes(self, app: web.Application):
|
||||||
"""Setup Checkpoint routes"""
|
"""Setup Checkpoint routes"""
|
||||||
@@ -35,17 +36,35 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
# Setup common routes with 'checkpoints' prefix (includes page route)
|
# Setup common routes with 'checkpoints' prefix (includes page route)
|
||||||
super().setup_routes(app, 'checkpoints')
|
super().setup_routes(app, 'checkpoints')
|
||||||
|
|
||||||
def setup_specific_routes(self, app: web.Application, prefix: str):
|
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
||||||
"""Setup Checkpoint-specific routes"""
|
"""Setup Checkpoint-specific routes"""
|
||||||
# Checkpoint-specific CivitAI integration
|
|
||||||
app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions_checkpoint)
|
|
||||||
|
|
||||||
# Checkpoint info by name
|
# Checkpoint info by name
|
||||||
app.router.add_get(f'/api/{prefix}/info/{{name}}', self.get_checkpoint_info)
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/info/{name}', prefix, self.get_checkpoint_info)
|
||||||
|
|
||||||
# Checkpoint roots and Unet roots
|
# Checkpoint roots and Unet roots
|
||||||
app.router.add_get(f'/api/{prefix}/checkpoints_roots', self.get_checkpoints_roots)
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/checkpoints_roots', prefix, self.get_checkpoints_roots)
|
||||||
app.router.add_get(f'/api/{prefix}/unet_roots', self.get_unet_roots)
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/unet_roots', prefix, self.get_unet_roots)
|
||||||
|
|
||||||
|
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
||||||
|
"""Validate CivitAI model type for Checkpoint"""
|
||||||
|
return model_type.lower() == 'checkpoint'
|
||||||
|
|
||||||
|
def _get_expected_model_types(self) -> str:
|
||||||
|
"""Get expected model types string for error messages"""
|
||||||
|
return "Checkpoint"
|
||||||
|
|
||||||
|
def _parse_specific_params(self, request: web.Request) -> Dict:
|
||||||
|
"""Parse Checkpoint-specific parameters"""
|
||||||
|
params: Dict = {}
|
||||||
|
|
||||||
|
if 'checkpoint_hash' in request.query:
|
||||||
|
params['hash_filters'] = {'single_hash': request.query['checkpoint_hash'].lower()}
|
||||||
|
elif 'checkpoint_hashes' in request.query:
|
||||||
|
params['hash_filters'] = {
|
||||||
|
'multiple_hashes': [h.lower() for h in request.query['checkpoint_hashes'].split(',')]
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
async def get_checkpoint_info(self, request: web.Request) -> web.Response:
|
async def get_checkpoint_info(self, request: web.Request) -> web.Response:
|
||||||
"""Get detailed information for a specific checkpoint by name"""
|
"""Get detailed information for a specific checkpoint by name"""
|
||||||
@@ -62,53 +81,6 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
logger.error(f"Error in get_checkpoint_info: {e}", exc_info=True)
|
logger.error(f"Error in get_checkpoint_info: {e}", exc_info=True)
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
async def get_civitai_versions_checkpoint(self, request: web.Request) -> web.Response:
|
|
||||||
"""Get available versions for a Civitai checkpoint model with local availability info"""
|
|
||||||
try:
|
|
||||||
model_id = request.match_info['model_id']
|
|
||||||
response = await self.civitai_client.get_model_versions(model_id)
|
|
||||||
if not response or not response.get('modelVersions'):
|
|
||||||
return web.Response(status=404, text="Model not found")
|
|
||||||
|
|
||||||
versions = response.get('modelVersions', [])
|
|
||||||
model_type = response.get('type', '')
|
|
||||||
|
|
||||||
# Check model type - should be Checkpoint
|
|
||||||
if model_type.lower() != 'checkpoint':
|
|
||||||
return web.json_response({
|
|
||||||
'error': f"Model type mismatch. Expected Checkpoint, got {model_type}"
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Check local availability for each version
|
|
||||||
for version in versions:
|
|
||||||
# Find the primary model file (type="Model" and primary=true) in the files list
|
|
||||||
model_file = next((file for file in version.get('files', [])
|
|
||||||
if file.get('type') == 'Model' and file.get('primary') == True), None)
|
|
||||||
|
|
||||||
# If no primary file found, try to find any model file
|
|
||||||
if not model_file:
|
|
||||||
model_file = next((file for file in version.get('files', [])
|
|
||||||
if file.get('type') == 'Model'), None)
|
|
||||||
|
|
||||||
if model_file:
|
|
||||||
sha256 = model_file.get('hashes', {}).get('SHA256')
|
|
||||||
if sha256:
|
|
||||||
# Set existsLocally and localPath at the version level
|
|
||||||
version['existsLocally'] = self.service.has_hash(sha256)
|
|
||||||
if version['existsLocally']:
|
|
||||||
version['localPath'] = self.service.get_path_by_hash(sha256)
|
|
||||||
|
|
||||||
# Also set the model file size at the version level for easier access
|
|
||||||
version['modelSizeKB'] = model_file.get('sizeKB')
|
|
||||||
else:
|
|
||||||
# No model file found in this version
|
|
||||||
version['existsLocally'] = False
|
|
||||||
|
|
||||||
return web.json_response(versions)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching checkpoint model versions: {e}")
|
|
||||||
return web.Response(status=500, text=str(e))
|
|
||||||
|
|
||||||
async def get_checkpoints_roots(self, request: web.Request) -> web.Response:
|
async def get_checkpoints_roots(self, request: web.Request) -> web.Response:
|
||||||
"""Return the list of checkpoint roots from config"""
|
"""Return the list of checkpoint roots from config"""
|
||||||
try:
|
try:
|
||||||
@@ -137,4 +109,4 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
return web.json_response({
|
return web.json_response({
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": str(e)
|
"error": str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from .base_model_routes import BaseModelRoutes
|
from .base_model_routes import BaseModelRoutes
|
||||||
|
from .model_route_registrar import ModelRouteRegistrar
|
||||||
from ..services.embedding_service import EmbeddingService
|
from ..services.embedding_service import EmbeddingService
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
@@ -12,19 +13,18 @@ class EmbeddingRoutes(BaseModelRoutes):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize Embedding routes with Embedding service"""
|
"""Initialize Embedding routes with Embedding service"""
|
||||||
# Service will be initialized later via setup_routes
|
super().__init__()
|
||||||
self.service = None
|
|
||||||
self.civitai_client = None
|
|
||||||
self.template_name = "embeddings.html"
|
self.template_name = "embeddings.html"
|
||||||
|
|
||||||
async def initialize_services(self):
|
async def initialize_services(self):
|
||||||
"""Initialize services from ServiceRegistry"""
|
"""Initialize services from ServiceRegistry"""
|
||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
self.service = EmbeddingService(embedding_scanner)
|
update_service = await ServiceRegistry.get_model_update_service()
|
||||||
self.civitai_client = await ServiceRegistry.get_civitai_client()
|
self.service = EmbeddingService(embedding_scanner, update_service=update_service)
|
||||||
|
self.set_model_update_service(update_service)
|
||||||
# Initialize parent with the service
|
|
||||||
super().__init__(self.service)
|
# Attach service dependencies
|
||||||
|
self.attach_service(self.service)
|
||||||
|
|
||||||
def setup_routes(self, app: web.Application):
|
def setup_routes(self, app: web.Application):
|
||||||
"""Setup Embedding routes"""
|
"""Setup Embedding routes"""
|
||||||
@@ -34,13 +34,18 @@ class EmbeddingRoutes(BaseModelRoutes):
|
|||||||
# Setup common routes with 'embeddings' prefix (includes page route)
|
# Setup common routes with 'embeddings' prefix (includes page route)
|
||||||
super().setup_routes(app, 'embeddings')
|
super().setup_routes(app, 'embeddings')
|
||||||
|
|
||||||
def setup_specific_routes(self, app: web.Application, prefix: str):
|
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
||||||
"""Setup Embedding-specific routes"""
|
"""Setup Embedding-specific routes"""
|
||||||
# Embedding-specific CivitAI integration
|
|
||||||
app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions_embedding)
|
|
||||||
|
|
||||||
# Embedding info by name
|
# Embedding info by name
|
||||||
app.router.add_get(f'/api/{prefix}/info/{{name}}', self.get_embedding_info)
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/info/{name}', prefix, self.get_embedding_info)
|
||||||
|
|
||||||
|
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
||||||
|
"""Validate CivitAI model type for Embedding"""
|
||||||
|
return model_type.lower() == 'textualinversion'
|
||||||
|
|
||||||
|
def _get_expected_model_types(self) -> str:
|
||||||
|
"""Get expected model types string for error messages"""
|
||||||
|
return "TextualInversion"
|
||||||
|
|
||||||
async def get_embedding_info(self, request: web.Request) -> web.Response:
|
async def get_embedding_info(self, request: web.Request) -> web.Response:
|
||||||
"""Get detailed information for a specific embedding by name"""
|
"""Get detailed information for a specific embedding by name"""
|
||||||
@@ -56,50 +61,3 @@ class EmbeddingRoutes(BaseModelRoutes):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in get_embedding_info: {e}", exc_info=True)
|
logger.error(f"Error in get_embedding_info: {e}", exc_info=True)
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
async def get_civitai_versions_embedding(self, request: web.Request) -> web.Response:
|
|
||||||
"""Get available versions for a Civitai embedding model with local availability info"""
|
|
||||||
try:
|
|
||||||
model_id = request.match_info['model_id']
|
|
||||||
response = await self.civitai_client.get_model_versions(model_id)
|
|
||||||
if not response or not response.get('modelVersions'):
|
|
||||||
return web.Response(status=404, text="Model not found")
|
|
||||||
|
|
||||||
versions = response.get('modelVersions', [])
|
|
||||||
model_type = response.get('type', '')
|
|
||||||
|
|
||||||
# Check model type - should be TextualInversion (Embedding)
|
|
||||||
if model_type.lower() not in ['textualinversion', 'embedding']:
|
|
||||||
return web.json_response({
|
|
||||||
'error': f"Model type mismatch. Expected TextualInversion/Embedding, got {model_type}"
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Check local availability for each version
|
|
||||||
for version in versions:
|
|
||||||
# Find the primary model file (type="Model" and primary=true) in the files list
|
|
||||||
model_file = next((file for file in version.get('files', [])
|
|
||||||
if file.get('type') == 'Model' and file.get('primary') == True), None)
|
|
||||||
|
|
||||||
# If no primary file found, try to find any model file
|
|
||||||
if not model_file:
|
|
||||||
model_file = next((file for file in version.get('files', [])
|
|
||||||
if file.get('type') == 'Model'), None)
|
|
||||||
|
|
||||||
if model_file:
|
|
||||||
sha256 = model_file.get('hashes', {}).get('SHA256')
|
|
||||||
if sha256:
|
|
||||||
# Set existsLocally and localPath at the version level
|
|
||||||
version['existsLocally'] = self.service.has_hash(sha256)
|
|
||||||
if version['existsLocally']:
|
|
||||||
version['localPath'] = self.service.get_path_by_hash(sha256)
|
|
||||||
|
|
||||||
# Also set the model file size at the version level for easier access
|
|
||||||
version['modelSizeKB'] = model_file.get('sizeKB')
|
|
||||||
else:
|
|
||||||
# No model file found in this version
|
|
||||||
version['existsLocally'] = False
|
|
||||||
|
|
||||||
return web.json_response(versions)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching embedding model versions: {e}")
|
|
||||||
return web.Response(status=500, text=str(e))
|
|
||||||
|
|||||||
63
py/routes/example_images_route_registrar.py
Normal file
63
py/routes/example_images_route_registrar.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""Route registrar for example image endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Iterable, Mapping
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RouteDefinition:
|
||||||
|
"""Declarative configuration for a HTTP route."""
|
||||||
|
|
||||||
|
method: str
|
||||||
|
path: str
|
||||||
|
handler_name: str
|
||||||
|
|
||||||
|
|
||||||
|
ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||||
|
RouteDefinition("POST", "/api/lm/download-example-images", "download_example_images"),
|
||||||
|
RouteDefinition("POST", "/api/lm/import-example-images", "import_example_images"),
|
||||||
|
RouteDefinition("GET", "/api/lm/example-images-status", "get_example_images_status"),
|
||||||
|
RouteDefinition("POST", "/api/lm/pause-example-images", "pause_example_images"),
|
||||||
|
RouteDefinition("POST", "/api/lm/resume-example-images", "resume_example_images"),
|
||||||
|
RouteDefinition("POST", "/api/lm/stop-example-images", "stop_example_images"),
|
||||||
|
RouteDefinition("POST", "/api/lm/open-example-images-folder", "open_example_images_folder"),
|
||||||
|
RouteDefinition("GET", "/api/lm/example-image-files", "get_example_image_files"),
|
||||||
|
RouteDefinition("GET", "/api/lm/has-example-images", "has_example_images"),
|
||||||
|
RouteDefinition("POST", "/api/lm/delete-example-image", "delete_example_image"),
|
||||||
|
RouteDefinition("POST", "/api/lm/force-download-example-images", "force_download_example_images"),
|
||||||
|
RouteDefinition("POST", "/api/lm/cleanup-example-image-folders", "cleanup_example_image_folders"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleImagesRouteRegistrar:
|
||||||
|
"""Bind declarative example image routes to an aiohttp router."""
|
||||||
|
|
||||||
|
_METHOD_MAP = {
|
||||||
|
"GET": "add_get",
|
||||||
|
"POST": "add_post",
|
||||||
|
"PUT": "add_put",
|
||||||
|
"DELETE": "add_delete",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, app: web.Application) -> None:
|
||||||
|
self._app = app
|
||||||
|
|
||||||
|
def register_routes(
|
||||||
|
self,
|
||||||
|
handler_lookup: Mapping[str, Callable[[web.Request], object]],
|
||||||
|
*,
|
||||||
|
definitions: Iterable[RouteDefinition] = ROUTE_DEFINITIONS,
|
||||||
|
) -> None:
|
||||||
|
"""Register each route definition using the supplied handlers."""
|
||||||
|
|
||||||
|
for definition in definitions:
|
||||||
|
handler = handler_lookup[definition.handler_name]
|
||||||
|
self._bind_route(definition.method, definition.path, handler)
|
||||||
|
|
||||||
|
def _bind_route(self, method: str, path: str, handler: Callable[[web.Request], object]) -> None:
|
||||||
|
add_method_name = self._METHOD_MAP[method.upper()]
|
||||||
|
add_method = getattr(self._app.router, add_method_name)
|
||||||
|
add_method(path, handler)
|
||||||
@@ -1,74 +1,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from ..utils.example_images_download_manager import DownloadManager
|
from typing import Callable, Mapping
|
||||||
from ..utils.example_images_processor import ExampleImagesProcessor
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from .example_images_route_registrar import ExampleImagesRouteRegistrar
|
||||||
|
from .handlers.example_images_handlers import (
|
||||||
|
ExampleImagesDownloadHandler,
|
||||||
|
ExampleImagesFileHandler,
|
||||||
|
ExampleImagesHandlerSet,
|
||||||
|
ExampleImagesManagementHandler,
|
||||||
|
)
|
||||||
|
from ..services.use_cases.example_images import (
|
||||||
|
DownloadExampleImagesUseCase,
|
||||||
|
ImportExampleImagesUseCase,
|
||||||
|
)
|
||||||
|
from ..utils.example_images_download_manager import (
|
||||||
|
DownloadManager,
|
||||||
|
get_default_download_manager,
|
||||||
|
)
|
||||||
from ..utils.example_images_file_manager import ExampleImagesFileManager
|
from ..utils.example_images_file_manager import ExampleImagesFileManager
|
||||||
from ..services.websocket_manager import ws_manager
|
from ..utils.example_images_processor import ExampleImagesProcessor
|
||||||
|
from ..services.example_images_cleanup_service import ExampleImagesCleanupService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ExampleImagesRoutes:
|
class ExampleImagesRoutes:
|
||||||
"""Routes for example images related functionality"""
|
"""Route controller for example image endpoints."""
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def setup_routes(app):
|
|
||||||
"""Register example images routes"""
|
|
||||||
app.router.add_post('/api/download-example-images', ExampleImagesRoutes.download_example_images)
|
|
||||||
app.router.add_post('/api/import-example-images', ExampleImagesRoutes.import_example_images)
|
|
||||||
app.router.add_get('/api/example-images-status', ExampleImagesRoutes.get_example_images_status)
|
|
||||||
app.router.add_post('/api/pause-example-images', ExampleImagesRoutes.pause_example_images)
|
|
||||||
app.router.add_post('/api/resume-example-images', ExampleImagesRoutes.resume_example_images)
|
|
||||||
app.router.add_post('/api/open-example-images-folder', ExampleImagesRoutes.open_example_images_folder)
|
|
||||||
app.router.add_get('/api/example-image-files', ExampleImagesRoutes.get_example_image_files)
|
|
||||||
app.router.add_get('/api/has-example-images', ExampleImagesRoutes.has_example_images)
|
|
||||||
app.router.add_post('/api/delete-example-image', ExampleImagesRoutes.delete_example_image)
|
|
||||||
app.router.add_post('/api/force-download-example-images', ExampleImagesRoutes.force_download_example_images)
|
|
||||||
|
|
||||||
@staticmethod
|
def __init__(
|
||||||
async def download_example_images(request):
|
self,
|
||||||
"""Download example images for models from Civitai"""
|
*,
|
||||||
return await DownloadManager.start_download(request)
|
ws_manager,
|
||||||
|
download_manager: DownloadManager | None = None,
|
||||||
|
processor=ExampleImagesProcessor,
|
||||||
|
file_manager=ExampleImagesFileManager,
|
||||||
|
cleanup_service: ExampleImagesCleanupService | None = None,
|
||||||
|
) -> None:
|
||||||
|
if ws_manager is None:
|
||||||
|
raise ValueError("ws_manager is required")
|
||||||
|
self._download_manager = download_manager or get_default_download_manager(ws_manager)
|
||||||
|
self._processor = processor
|
||||||
|
self._file_manager = file_manager
|
||||||
|
self._cleanup_service = cleanup_service or ExampleImagesCleanupService()
|
||||||
|
self._handler_set: ExampleImagesHandlerSet | None = None
|
||||||
|
self._handler_mapping: Mapping[str, Callable[[web.Request], web.StreamResponse]] | None = None
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
async def get_example_images_status(request):
|
def setup_routes(cls, app: web.Application, *, ws_manager) -> None:
|
||||||
"""Get the current status of example images download"""
|
"""Register routes on the given aiohttp application using default wiring."""
|
||||||
return await DownloadManager.get_status(request)
|
|
||||||
|
|
||||||
@staticmethod
|
controller = cls(ws_manager=ws_manager)
|
||||||
async def pause_example_images(request):
|
controller.register(app)
|
||||||
"""Pause the example images download"""
|
|
||||||
return await DownloadManager.pause_download(request)
|
|
||||||
|
|
||||||
@staticmethod
|
def register(self, app: web.Application) -> None:
|
||||||
async def resume_example_images(request):
|
"""Bind the controller's handlers to the aiohttp router."""
|
||||||
"""Resume the example images download"""
|
|
||||||
return await DownloadManager.resume_download(request)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def open_example_images_folder(request):
|
|
||||||
"""Open the example images folder for a specific model"""
|
|
||||||
return await ExampleImagesFileManager.open_folder(request)
|
|
||||||
|
|
||||||
@staticmethod
|
registrar = ExampleImagesRouteRegistrar(app)
|
||||||
async def get_example_image_files(request):
|
registrar.register_routes(self.to_route_mapping())
|
||||||
"""Get list of example image files for a specific model"""
|
|
||||||
return await ExampleImagesFileManager.get_files(request)
|
|
||||||
|
|
||||||
@staticmethod
|
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], web.StreamResponse]]:
|
||||||
async def import_example_images(request):
|
"""Return the registrar-compatible mapping of handler names to callables."""
|
||||||
"""Import local example images for a model"""
|
|
||||||
return await ExampleImagesProcessor.import_images(request)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def has_example_images(request):
|
|
||||||
"""Check if example images folder exists and is not empty for a model"""
|
|
||||||
return await ExampleImagesFileManager.has_images(request)
|
|
||||||
|
|
||||||
@staticmethod
|
if self._handler_mapping is None:
|
||||||
async def delete_example_image(request):
|
handler_set = self._build_handler_set()
|
||||||
"""Delete a custom example image for a model"""
|
self._handler_set = handler_set
|
||||||
return await ExampleImagesProcessor.delete_custom_image(request)
|
self._handler_mapping = handler_set.to_route_mapping()
|
||||||
|
return self._handler_mapping
|
||||||
|
|
||||||
@staticmethod
|
def _build_handler_set(self) -> ExampleImagesHandlerSet:
|
||||||
async def force_download_example_images(request):
|
logger.debug("Building ExampleImagesHandlerSet with %s, %s, %s", self._download_manager, self._processor, self._file_manager)
|
||||||
"""Force download example images for specific models"""
|
download_use_case = DownloadExampleImagesUseCase(download_manager=self._download_manager)
|
||||||
return await DownloadManager.start_force_download(request)
|
download_handler = ExampleImagesDownloadHandler(download_use_case, self._download_manager)
|
||||||
|
import_use_case = ImportExampleImagesUseCase(processor=self._processor)
|
||||||
|
management_handler = ExampleImagesManagementHandler(
|
||||||
|
import_use_case,
|
||||||
|
self._processor,
|
||||||
|
self._cleanup_service,
|
||||||
|
)
|
||||||
|
file_handler = ExampleImagesFileHandler(self._file_manager)
|
||||||
|
return ExampleImagesHandlerSet(
|
||||||
|
download=download_handler,
|
||||||
|
management=management_handler,
|
||||||
|
files=file_handler,
|
||||||
|
)
|
||||||
|
|||||||
167
py/routes/handlers/example_images_handlers.py
Normal file
167
py/routes/handlers/example_images_handlers.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Handler set for example image routes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Mapping
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from ...services.use_cases.example_images import (
|
||||||
|
DownloadExampleImagesConfigurationError,
|
||||||
|
DownloadExampleImagesInProgressError,
|
||||||
|
DownloadExampleImagesUseCase,
|
||||||
|
ImportExampleImagesUseCase,
|
||||||
|
ImportExampleImagesValidationError,
|
||||||
|
)
|
||||||
|
from ...utils.example_images_download_manager import (
|
||||||
|
DownloadConfigurationError,
|
||||||
|
DownloadInProgressError,
|
||||||
|
DownloadNotRunningError,
|
||||||
|
ExampleImagesDownloadError,
|
||||||
|
)
|
||||||
|
from ...utils.example_images_processor import ExampleImagesImportError
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleImagesDownloadHandler:
|
||||||
|
"""HTTP adapters for download-related example image endpoints."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
download_use_case: DownloadExampleImagesUseCase,
|
||||||
|
download_manager,
|
||||||
|
) -> None:
|
||||||
|
self._download_use_case = download_use_case
|
||||||
|
self._download_manager = download_manager
|
||||||
|
|
||||||
|
async def download_example_images(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
result = await self._download_use_case.execute(payload)
|
||||||
|
return web.json_response(result)
|
||||||
|
except DownloadExampleImagesInProgressError as exc:
|
||||||
|
response = {
|
||||||
|
'success': False,
|
||||||
|
'error': str(exc),
|
||||||
|
'status': exc.progress,
|
||||||
|
}
|
||||||
|
return web.json_response(response, status=400)
|
||||||
|
except DownloadExampleImagesConfigurationError as exc:
|
||||||
|
return web.json_response({'success': False, 'error': str(exc)}, status=400)
|
||||||
|
except ExampleImagesDownloadError as exc:
|
||||||
|
return web.json_response({'success': False, 'error': str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_example_images_status(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
result = await self._download_manager.get_status(request)
|
||||||
|
return web.json_response(result)
|
||||||
|
|
||||||
|
async def pause_example_images(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
try:
|
||||||
|
result = await self._download_manager.pause_download(request)
|
||||||
|
return web.json_response(result)
|
||||||
|
except DownloadNotRunningError as exc:
|
||||||
|
return web.json_response({'success': False, 'error': str(exc)}, status=400)
|
||||||
|
|
||||||
|
async def resume_example_images(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
try:
|
||||||
|
result = await self._download_manager.resume_download(request)
|
||||||
|
return web.json_response(result)
|
||||||
|
except DownloadNotRunningError as exc:
|
||||||
|
return web.json_response({'success': False, 'error': str(exc)}, status=400)
|
||||||
|
|
||||||
|
async def stop_example_images(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
try:
|
||||||
|
result = await self._download_manager.stop_download(request)
|
||||||
|
return web.json_response(result)
|
||||||
|
except DownloadNotRunningError as exc:
|
||||||
|
return web.json_response({'success': False, 'error': str(exc)}, status=400)
|
||||||
|
|
||||||
|
async def force_download_example_images(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
result = await self._download_manager.start_force_download(payload)
|
||||||
|
return web.json_response(result)
|
||||||
|
except DownloadInProgressError as exc:
|
||||||
|
response = {
|
||||||
|
'success': False,
|
||||||
|
'error': str(exc),
|
||||||
|
'status': exc.progress_snapshot,
|
||||||
|
}
|
||||||
|
return web.json_response(response, status=400)
|
||||||
|
except DownloadConfigurationError as exc:
|
||||||
|
return web.json_response({'success': False, 'error': str(exc)}, status=400)
|
||||||
|
except ExampleImagesDownloadError as exc:
|
||||||
|
return web.json_response({'success': False, 'error': str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleImagesManagementHandler:
|
||||||
|
"""HTTP adapters for import/delete endpoints."""
|
||||||
|
|
||||||
|
def __init__(self, import_use_case: ImportExampleImagesUseCase, processor, cleanup_service) -> None:
|
||||||
|
self._import_use_case = import_use_case
|
||||||
|
self._processor = processor
|
||||||
|
self._cleanup_service = cleanup_service
|
||||||
|
|
||||||
|
async def import_example_images(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
try:
|
||||||
|
result = await self._import_use_case.execute(request)
|
||||||
|
return web.json_response(result)
|
||||||
|
except ImportExampleImagesValidationError as exc:
|
||||||
|
return web.json_response({'success': False, 'error': str(exc)}, status=400)
|
||||||
|
except ExampleImagesImportError as exc:
|
||||||
|
return web.json_response({'success': False, 'error': str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def delete_example_image(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
return await self._processor.delete_custom_image(request)
|
||||||
|
|
||||||
|
async def cleanup_example_image_folders(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
result = await self._cleanup_service.cleanup_example_image_folders()
|
||||||
|
|
||||||
|
if result.get('success') or result.get('partial_success'):
|
||||||
|
return web.json_response(result, status=200)
|
||||||
|
|
||||||
|
error_code = result.get('error_code')
|
||||||
|
status = 400 if error_code in {'path_not_configured', 'path_not_found'} else 500
|
||||||
|
return web.json_response(result, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleImagesFileHandler:
|
||||||
|
"""HTTP adapters for filesystem-centric endpoints."""
|
||||||
|
|
||||||
|
def __init__(self, file_manager) -> None:
|
||||||
|
self._file_manager = file_manager
|
||||||
|
|
||||||
|
async def open_example_images_folder(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
return await self._file_manager.open_folder(request)
|
||||||
|
|
||||||
|
async def get_example_image_files(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
return await self._file_manager.get_files(request)
|
||||||
|
|
||||||
|
async def has_example_images(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
return await self._file_manager.has_images(request)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExampleImagesHandlerSet:
|
||||||
|
"""Aggregate of handlers exposed to the registrar."""
|
||||||
|
|
||||||
|
download: ExampleImagesDownloadHandler
|
||||||
|
management: ExampleImagesManagementHandler
|
||||||
|
files: ExampleImagesFileHandler
|
||||||
|
|
||||||
|
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], web.StreamResponse]]:
|
||||||
|
"""Flatten handler methods into the registrar mapping."""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"download_example_images": self.download.download_example_images,
|
||||||
|
"get_example_images_status": self.download.get_example_images_status,
|
||||||
|
"pause_example_images": self.download.pause_example_images,
|
||||||
|
"resume_example_images": self.download.resume_example_images,
|
||||||
|
"stop_example_images": self.download.stop_example_images,
|
||||||
|
"force_download_example_images": self.download.force_download_example_images,
|
||||||
|
"import_example_images": self.management.import_example_images,
|
||||||
|
"delete_example_image": self.management.delete_example_image,
|
||||||
|
"cleanup_example_image_folders": self.management.cleanup_example_image_folders,
|
||||||
|
"open_example_images_folder": self.files.open_example_images_folder,
|
||||||
|
"get_example_image_files": self.files.get_example_image_files,
|
||||||
|
"has_example_images": self.files.has_example_images,
|
||||||
|
}
|
||||||
1114
py/routes/handlers/misc_handlers.py
Normal file
1114
py/routes/handlers/misc_handlers.py
Normal file
File diff suppressed because it is too large
Load Diff
1636
py/routes/handlers/model_handlers.py
Normal file
1636
py/routes/handlers/model_handlers.py
Normal file
File diff suppressed because it is too large
Load Diff
56
py/routes/handlers/preview_handlers.py
Normal file
56
py/routes/handlers/preview_handlers.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Handlers responsible for serving preview assets dynamically."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import urllib.parse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from ...config import config as global_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewHandler:
|
||||||
|
"""Serve preview assets for the active library at request time."""
|
||||||
|
|
||||||
|
def __init__(self, *, config=global_config) -> None:
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
async def serve_preview(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
"""Return the preview file referenced by the encoded ``path`` query."""
|
||||||
|
|
||||||
|
raw_path = request.query.get("path", "")
|
||||||
|
if not raw_path:
|
||||||
|
raise web.HTTPBadRequest(text="Missing 'path' query parameter")
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoded_path = urllib.parse.unquote(raw_path)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive guard
|
||||||
|
logger.debug("Failed to decode preview path %s: %s", raw_path, exc)
|
||||||
|
raise web.HTTPBadRequest(text="Invalid preview path encoding") from exc
|
||||||
|
|
||||||
|
normalized = decoded_path.replace("\\", "/")
|
||||||
|
candidate = Path(normalized)
|
||||||
|
try:
|
||||||
|
resolved = candidate.expanduser().resolve(strict=False)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Failed to resolve preview path %s: %s", normalized, exc)
|
||||||
|
raise web.HTTPBadRequest(text="Unable to resolve preview path") from exc
|
||||||
|
|
||||||
|
resolved_str = str(resolved)
|
||||||
|
if not self._config.is_preview_path_allowed(resolved_str):
|
||||||
|
logger.debug("Rejected preview outside allowed roots: %s", resolved_str)
|
||||||
|
raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
|
||||||
|
|
||||||
|
if not resolved.is_file():
|
||||||
|
logger.debug("Preview file not found at %s", resolved_str)
|
||||||
|
raise web.HTTPNotFound(text="Preview file not found")
|
||||||
|
|
||||||
|
# aiohttp's FileResponse handles range requests and content headers for us.
|
||||||
|
return web.FileResponse(path=resolved, chunk_size=256 * 1024)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["PreviewHandler"]
|
||||||
940
py/routes/handlers/recipe_handlers.py
Normal file
940
py/routes/handlers/recipe_handlers.py
Normal file
@@ -0,0 +1,940 @@
|
|||||||
|
"""Dedicated handler objects for recipe-related routes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from ...config import config
|
||||||
|
from ...services.server_i18n import server_i18n as default_server_i18n
|
||||||
|
from ...services.settings_manager import SettingsManager
|
||||||
|
from ...services.recipes import (
|
||||||
|
RecipeAnalysisService,
|
||||||
|
RecipeDownloadError,
|
||||||
|
RecipeNotFoundError,
|
||||||
|
RecipePersistenceService,
|
||||||
|
RecipeSharingService,
|
||||||
|
RecipeValidationError,
|
||||||
|
)
|
||||||
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
|
|
||||||
|
Logger = logging.Logger
|
||||||
|
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
||||||
|
RecipeScannerGetter = Callable[[], Any]
|
||||||
|
CivitaiClientGetter = Callable[[], Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RecipeHandlerSet:
|
||||||
|
"""Group of handlers providing recipe route implementations."""
|
||||||
|
|
||||||
|
page_view: "RecipePageView"
|
||||||
|
listing: "RecipeListingHandler"
|
||||||
|
query: "RecipeQueryHandler"
|
||||||
|
management: "RecipeManagementHandler"
|
||||||
|
analysis: "RecipeAnalysisHandler"
|
||||||
|
sharing: "RecipeSharingHandler"
|
||||||
|
|
||||||
|
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||||
|
"""Expose handler coroutines keyed by registrar handler names."""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"render_page": self.page_view.render_page,
|
||||||
|
"list_recipes": self.listing.list_recipes,
|
||||||
|
"get_recipe": self.listing.get_recipe,
|
||||||
|
"import_remote_recipe": self.management.import_remote_recipe,
|
||||||
|
"analyze_uploaded_image": self.analysis.analyze_uploaded_image,
|
||||||
|
"analyze_local_image": self.analysis.analyze_local_image,
|
||||||
|
"save_recipe": self.management.save_recipe,
|
||||||
|
"delete_recipe": self.management.delete_recipe,
|
||||||
|
"get_top_tags": self.query.get_top_tags,
|
||||||
|
"get_base_models": self.query.get_base_models,
|
||||||
|
"share_recipe": self.sharing.share_recipe,
|
||||||
|
"download_shared_recipe": self.sharing.download_shared_recipe,
|
||||||
|
"get_recipe_syntax": self.query.get_recipe_syntax,
|
||||||
|
"update_recipe": self.management.update_recipe,
|
||||||
|
"reconnect_lora": self.management.reconnect_lora,
|
||||||
|
"find_duplicates": self.query.find_duplicates,
|
||||||
|
"bulk_delete": self.management.bulk_delete,
|
||||||
|
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
||||||
|
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
||||||
|
"scan_recipes": self.query.scan_recipes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RecipePageView:
|
||||||
|
"""Render the recipe shell page."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
||||||
|
settings_service: SettingsManager,
|
||||||
|
server_i18n=default_server_i18n,
|
||||||
|
template_env,
|
||||||
|
template_name: str,
|
||||||
|
recipe_scanner_getter: RecipeScannerGetter,
|
||||||
|
logger: Logger,
|
||||||
|
) -> None:
|
||||||
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
||||||
|
self._settings = settings_service
|
||||||
|
self._server_i18n = server_i18n
|
||||||
|
self._template_env = template_env
|
||||||
|
self._template_name = template_name
|
||||||
|
self._recipe_scanner_getter = recipe_scanner_getter
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
async def render_page(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None: # pragma: no cover - defensive guard
|
||||||
|
raise RuntimeError("Recipe scanner not available")
|
||||||
|
|
||||||
|
user_language = self._settings.get("language", "en")
|
||||||
|
self._server_i18n.set_locale(user_language)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await recipe_scanner.get_cached_data(force_refresh=False)
|
||||||
|
rendered = self._template_env.get_template(self._template_name).render(
|
||||||
|
recipes=[],
|
||||||
|
is_initializing=False,
|
||||||
|
settings=self._settings,
|
||||||
|
request=request,
|
||||||
|
t=self._server_i18n.get_translation,
|
||||||
|
)
|
||||||
|
except Exception as cache_error: # pragma: no cover - logging path
|
||||||
|
self._logger.error("Error loading recipe cache data: %s", cache_error)
|
||||||
|
rendered = self._template_env.get_template(self._template_name).render(
|
||||||
|
is_initializing=True,
|
||||||
|
settings=self._settings,
|
||||||
|
request=request,
|
||||||
|
t=self._server_i18n.get_translation,
|
||||||
|
)
|
||||||
|
return web.Response(text=rendered, content_type="text/html")
|
||||||
|
except Exception as exc: # pragma: no cover - logging path
|
||||||
|
self._logger.error("Error handling recipes request: %s", exc, exc_info=True)
|
||||||
|
return web.Response(text="Error loading recipes page", status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeListingHandler:
|
||||||
|
"""Provide listing and detail APIs for recipes."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
||||||
|
recipe_scanner_getter: RecipeScannerGetter,
|
||||||
|
logger: Logger,
|
||||||
|
) -> None:
|
||||||
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
||||||
|
self._recipe_scanner_getter = recipe_scanner_getter
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
async def list_recipes(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
page = int(request.query.get("page", "1"))
|
||||||
|
page_size = int(request.query.get("page_size", "20"))
|
||||||
|
sort_by = request.query.get("sort_by", "date")
|
||||||
|
search = request.query.get("search")
|
||||||
|
|
||||||
|
search_options = {
|
||||||
|
"title": request.query.get("search_title", "true").lower() == "true",
|
||||||
|
"tags": request.query.get("search_tags", "true").lower() == "true",
|
||||||
|
"lora_name": request.query.get("search_lora_name", "true").lower() == "true",
|
||||||
|
"lora_model": request.query.get("search_lora_model", "true").lower() == "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
filters: Dict[str, Any] = {}
|
||||||
|
base_models = request.query.get("base_models")
|
||||||
|
if base_models:
|
||||||
|
filters["base_model"] = base_models.split(",")
|
||||||
|
|
||||||
|
tag_filters: Dict[str, str] = {}
|
||||||
|
legacy_tags = request.query.get("tags")
|
||||||
|
if legacy_tags:
|
||||||
|
for tag in legacy_tags.split(","):
|
||||||
|
tag = tag.strip()
|
||||||
|
if tag:
|
||||||
|
tag_filters[tag] = "include"
|
||||||
|
|
||||||
|
include_tags = request.query.getall("tag_include", [])
|
||||||
|
for tag in include_tags:
|
||||||
|
if tag:
|
||||||
|
tag_filters[tag] = "include"
|
||||||
|
|
||||||
|
exclude_tags = request.query.getall("tag_exclude", [])
|
||||||
|
for tag in exclude_tags:
|
||||||
|
if tag:
|
||||||
|
tag_filters[tag] = "exclude"
|
||||||
|
|
||||||
|
if tag_filters:
|
||||||
|
filters["tags"] = tag_filters
|
||||||
|
|
||||||
|
lora_hash = request.query.get("lora_hash")
|
||||||
|
|
||||||
|
result = await recipe_scanner.get_paginated_data(
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
sort_by=sort_by,
|
||||||
|
search=search,
|
||||||
|
filters=filters,
|
||||||
|
search_options=search_options,
|
||||||
|
lora_hash=lora_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in result.get("items", []):
|
||||||
|
file_path = item.get("file_path")
|
||||||
|
if file_path:
|
||||||
|
item["file_url"] = self.format_recipe_file_url(file_path)
|
||||||
|
else:
|
||||||
|
item.setdefault("file_url", "/loras_static/images/no-preview.png")
|
||||||
|
item.setdefault("loras", [])
|
||||||
|
item.setdefault("base_model", "")
|
||||||
|
|
||||||
|
return web.json_response(result)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving recipes: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
recipe_id = request.match_info["recipe_id"]
|
||||||
|
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||||
|
|
||||||
|
if not recipe:
|
||||||
|
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||||
|
return web.json_response(recipe)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving recipe details: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
def format_recipe_file_url(self, file_path: str) -> str:
|
||||||
|
try:
|
||||||
|
normalized_path = os.path.normpath(file_path)
|
||||||
|
static_url = config.get_preview_static_url(normalized_path)
|
||||||
|
if static_url:
|
||||||
|
return static_url
|
||||||
|
except Exception as exc: # pragma: no cover - logging path
|
||||||
|
self._logger.error("Error formatting recipe file URL: %s", exc, exc_info=True)
|
||||||
|
return "/loras_static/images/no-preview.png"
|
||||||
|
|
||||||
|
return "/loras_static/images/no-preview.png"
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeQueryHandler:
|
||||||
|
"""Provide read-only insights on recipe data."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
||||||
|
recipe_scanner_getter: RecipeScannerGetter,
|
||||||
|
format_recipe_file_url: Callable[[str], str],
|
||||||
|
logger: Logger,
|
||||||
|
) -> None:
|
||||||
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
||||||
|
self._recipe_scanner_getter = recipe_scanner_getter
|
||||||
|
self._format_recipe_file_url = format_recipe_file_url
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
async def get_top_tags(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
limit = int(request.query.get("limit", "20"))
|
||||||
|
cache = await recipe_scanner.get_cached_data()
|
||||||
|
|
||||||
|
tag_counts: Dict[str, int] = {}
|
||||||
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
|
for tag in recipe.get("tags", []) or []:
|
||||||
|
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||||
|
|
||||||
|
sorted_tags = [{"tag": tag, "count": count} for tag, count in tag_counts.items()]
|
||||||
|
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
|
||||||
|
return web.json_response({"success": True, "tags": sorted_tags[:limit]})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving top tags: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
cache = await recipe_scanner.get_cached_data()
|
||||||
|
|
||||||
|
base_model_counts: Dict[str, int] = {}
|
||||||
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
|
base_model = recipe.get("base_model")
|
||||||
|
if base_model:
|
||||||
|
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
|
||||||
|
|
||||||
|
sorted_models = [{"name": model, "count": count} for model, count in base_model_counts.items()]
|
||||||
|
sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
|
||||||
|
return web.json_response({"success": True, "base_models": sorted_models})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
lora_hash = request.query.get("hash")
|
||||||
|
if not lora_hash:
|
||||||
|
return web.json_response({"success": False, "error": "Lora hash is required"}, status=400)
|
||||||
|
|
||||||
|
matching_recipes = await recipe_scanner.get_recipes_for_lora(lora_hash)
|
||||||
|
return web.json_response({"success": True, "recipes": matching_recipes})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error getting recipes for Lora: %s", exc)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def scan_recipes(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
self._logger.info("Manually triggering recipe cache rebuild")
|
||||||
|
await recipe_scanner.get_cached_data(force_refresh=True)
|
||||||
|
return web.json_response({"success": True, "message": "Recipe cache refreshed successfully"})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error refreshing recipe cache: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def find_duplicates(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
duplicate_groups = await recipe_scanner.find_all_duplicate_recipes()
|
||||||
|
response_data = []
|
||||||
|
|
||||||
|
for fingerprint, recipe_ids in duplicate_groups.items():
|
||||||
|
if len(recipe_ids) <= 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
recipes = []
|
||||||
|
for recipe_id in recipe_ids:
|
||||||
|
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||||
|
if recipe:
|
||||||
|
recipes.append(
|
||||||
|
{
|
||||||
|
"id": recipe.get("id"),
|
||||||
|
"title": recipe.get("title"),
|
||||||
|
"file_url": recipe.get("file_url")
|
||||||
|
or self._format_recipe_file_url(recipe.get("file_path", "")),
|
||||||
|
"modified": recipe.get("modified"),
|
||||||
|
"created_date": recipe.get("created_date"),
|
||||||
|
"lora_count": len(recipe.get("loras", [])),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(recipes) >= 2:
|
||||||
|
recipes.sort(key=lambda entry: entry.get("modified", 0), reverse=True)
|
||||||
|
response_data.append(
|
||||||
|
{
|
||||||
|
"fingerprint": fingerprint,
|
||||||
|
"count": len(recipes),
|
||||||
|
"recipes": recipes,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response_data.sort(key=lambda entry: entry["count"], reverse=True)
|
||||||
|
return web.json_response({"success": True, "duplicate_groups": response_data})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error finding duplicate recipes: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_recipe_syntax(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
recipe_id = request.match_info["recipe_id"]
|
||||||
|
try:
|
||||||
|
syntax_parts = await recipe_scanner.get_recipe_syntax_tokens(recipe_id)
|
||||||
|
except RecipeNotFoundError:
|
||||||
|
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||||
|
|
||||||
|
if not syntax_parts:
|
||||||
|
return web.json_response({"error": "No LoRAs found in this recipe"}, status=400)
|
||||||
|
|
||||||
|
return web.json_response({"success": True, "syntax": " ".join(syntax_parts)})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error generating recipe syntax: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeManagementHandler:
|
||||||
|
"""Handle create/update/delete style recipe operations."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
||||||
|
recipe_scanner_getter: RecipeScannerGetter,
|
||||||
|
logger: Logger,
|
||||||
|
persistence_service: RecipePersistenceService,
|
||||||
|
analysis_service: RecipeAnalysisService,
|
||||||
|
downloader_factory,
|
||||||
|
civitai_client_getter: CivitaiClientGetter,
|
||||||
|
) -> None:
|
||||||
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
||||||
|
self._recipe_scanner_getter = recipe_scanner_getter
|
||||||
|
self._logger = logger
|
||||||
|
self._persistence_service = persistence_service
|
||||||
|
self._analysis_service = analysis_service
|
||||||
|
self._downloader_factory = downloader_factory
|
||||||
|
self._civitai_client_getter = civitai_client_getter
|
||||||
|
|
||||||
|
async def save_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
reader = await request.multipart()
|
||||||
|
payload = await self._parse_save_payload(reader)
|
||||||
|
|
||||||
|
result = await self._persistence_service.save_recipe(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
image_bytes=payload["image_bytes"],
|
||||||
|
image_base64=payload["image_base64"],
|
||||||
|
name=payload["name"],
|
||||||
|
tags=payload["tags"],
|
||||||
|
metadata=payload["metadata"],
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def import_remote_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
params = request.rel_url.query
|
||||||
|
image_url = params.get("image_url")
|
||||||
|
name = params.get("name")
|
||||||
|
resources_raw = params.get("resources")
|
||||||
|
if not image_url:
|
||||||
|
raise RecipeValidationError("Missing required field: image_url")
|
||||||
|
if not name:
|
||||||
|
raise RecipeValidationError("Missing required field: name")
|
||||||
|
if not resources_raw:
|
||||||
|
raise RecipeValidationError("Missing required field: resources")
|
||||||
|
|
||||||
|
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
|
||||||
|
gen_params = self._parse_gen_params(params.get("gen_params"))
|
||||||
|
metadata: Dict[str, Any] = {
|
||||||
|
"base_model": params.get("base_model", "") or "",
|
||||||
|
"loras": lora_entries,
|
||||||
|
}
|
||||||
|
source_path = params.get("source_path")
|
||||||
|
if source_path:
|
||||||
|
metadata["source_path"] = source_path
|
||||||
|
if gen_params is not None:
|
||||||
|
metadata["gen_params"] = gen_params
|
||||||
|
if checkpoint_entry:
|
||||||
|
metadata["checkpoint"] = checkpoint_entry
|
||||||
|
gen_params_ref = metadata.setdefault("gen_params", {})
|
||||||
|
if "checkpoint" not in gen_params_ref:
|
||||||
|
gen_params_ref["checkpoint"] = checkpoint_entry
|
||||||
|
base_model_from_metadata = await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
||||||
|
if base_model_from_metadata:
|
||||||
|
metadata["base_model"] = base_model_from_metadata
|
||||||
|
|
||||||
|
tags = self._parse_tags(params.get("tags"))
|
||||||
|
image_bytes = await self._download_image_bytes(image_url)
|
||||||
|
|
||||||
|
result = await self._persistence_service.save_recipe(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
image_base64=None,
|
||||||
|
name=name,
|
||||||
|
tags=tags,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except RecipeDownloadError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error importing recipe from remote source: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def delete_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
recipe_id = request.match_info["recipe_id"]
|
||||||
|
result = await self._persistence_service.delete_recipe(
|
||||||
|
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error deleting recipe: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def update_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
recipe_id = request.match_info["recipe_id"]
|
||||||
|
data = await request.json()
|
||||||
|
result = await self._persistence_service.update_recipe(
|
||||||
|
recipe_scanner=recipe_scanner, recipe_id=recipe_id, updates=data
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
for field in ("recipe_id", "lora_index", "target_name"):
|
||||||
|
if field not in data:
|
||||||
|
raise RecipeValidationError(f"Missing required field: {field}")
|
||||||
|
|
||||||
|
result = await self._persistence_service.reconnect_lora(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
recipe_id=data["recipe_id"],
|
||||||
|
lora_index=int(data["lora_index"]),
|
||||||
|
target_name=data["target_name"],
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error reconnecting LoRA: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def bulk_delete(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
recipe_ids = data.get("recipe_ids", [])
|
||||||
|
result = await self._persistence_service.bulk_delete(
|
||||||
|
recipe_scanner=recipe_scanner, recipe_ids=recipe_ids
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error performing bulk delete: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def save_recipe_from_widget(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
analysis = await self._analysis_service.analyze_widget_metadata(
|
||||||
|
recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
metadata = analysis.payload.get("metadata")
|
||||||
|
image_bytes = analysis.payload.get("image_bytes")
|
||||||
|
if not metadata or image_bytes is None:
|
||||||
|
raise RecipeValidationError("Unable to extract metadata from widget")
|
||||||
|
|
||||||
|
result = await self._persistence_service.save_recipe_from_widget(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
metadata=metadata,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error saving recipe from widget: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def _parse_save_payload(self, reader) -> dict[str, Any]:
|
||||||
|
image_bytes: Optional[bytes] = None
|
||||||
|
image_base64: Optional[str] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
tags: list[str] = []
|
||||||
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
field = await reader.next()
|
||||||
|
if field is None:
|
||||||
|
break
|
||||||
|
if field.name == "image":
|
||||||
|
image_chunks = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = await field.read_chunk()
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
image_chunks.extend(chunk)
|
||||||
|
image_bytes = bytes(image_chunks)
|
||||||
|
elif field.name == "image_base64":
|
||||||
|
image_base64 = await field.text()
|
||||||
|
elif field.name == "name":
|
||||||
|
name = await field.text()
|
||||||
|
elif field.name == "tags":
|
||||||
|
tags_text = await field.text()
|
||||||
|
try:
|
||||||
|
parsed_tags = json.loads(tags_text)
|
||||||
|
tags = parsed_tags if isinstance(parsed_tags, list) else []
|
||||||
|
except Exception:
|
||||||
|
tags = []
|
||||||
|
elif field.name == "metadata":
|
||||||
|
metadata_text = await field.text()
|
||||||
|
try:
|
||||||
|
metadata = json.loads(metadata_text)
|
||||||
|
except Exception:
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"image_bytes": image_bytes,
|
||||||
|
"image_base64": image_base64,
|
||||||
|
"name": name,
|
||||||
|
"tags": tags,
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _parse_tags(self, tag_text: Optional[str]) -> list[str]:
|
||||||
|
if not tag_text:
|
||||||
|
return []
|
||||||
|
return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
|
||||||
|
|
||||||
|
def _parse_gen_params(self, payload: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
if payload == "":
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(payload)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RecipeValidationError(f"Invalid gen_params payload: {exc}") from exc
|
||||||
|
if parsed is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise RecipeValidationError("gen_params payload must be an object")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
def _parse_resources_payload(self, payload_raw: str) -> tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload_raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RecipeValidationError(f"Invalid resources payload: {exc}") from exc
|
||||||
|
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise RecipeValidationError("Resources payload must be a list")
|
||||||
|
|
||||||
|
checkpoint_entry: Optional[Dict[str, Any]] = None
|
||||||
|
lora_entries: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for resource in payload:
|
||||||
|
if not isinstance(resource, dict):
|
||||||
|
continue
|
||||||
|
resource_type = str(resource.get("type") or "").lower()
|
||||||
|
if resource_type == "checkpoint":
|
||||||
|
checkpoint_entry = self._build_checkpoint_entry(resource)
|
||||||
|
elif resource_type in {"lora", "lycoris"}:
|
||||||
|
lora_entries.append(self._build_lora_entry(resource))
|
||||||
|
|
||||||
|
return checkpoint_entry, lora_entries
|
||||||
|
|
||||||
|
def _build_checkpoint_entry(self, resource: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": resource.get("type", "checkpoint"),
|
||||||
|
"modelId": self._safe_int(resource.get("modelId")),
|
||||||
|
"modelVersionId": self._safe_int(resource.get("modelVersionId")),
|
||||||
|
"modelName": resource.get("modelName", ""),
|
||||||
|
"modelVersionName": resource.get("modelVersionName", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_lora_entry(self, resource: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
weight_raw = resource.get("weight", 1.0)
|
||||||
|
try:
|
||||||
|
weight = float(weight_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
weight = 1.0
|
||||||
|
return {
|
||||||
|
"file_name": resource.get("modelName", ""),
|
||||||
|
"weight": weight,
|
||||||
|
"id": self._safe_int(resource.get("modelVersionId")),
|
||||||
|
"name": resource.get("modelName", ""),
|
||||||
|
"version": resource.get("modelVersionName", ""),
|
||||||
|
"isDeleted": False,
|
||||||
|
"exclude": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _download_image_bytes(self, image_url: str) -> bytes:
|
||||||
|
civitai_client = self._civitai_client_getter()
|
||||||
|
downloader = await self._downloader_factory()
|
||||||
|
temp_path = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
||||||
|
temp_path = temp_file.name
|
||||||
|
download_url = image_url
|
||||||
|
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
|
||||||
|
if civitai_match:
|
||||||
|
if civitai_client is None:
|
||||||
|
raise RecipeDownloadError("Civitai client unavailable for image download")
|
||||||
|
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||||
|
if not image_info:
|
||||||
|
raise RecipeDownloadError("Failed to fetch image information from Civitai")
|
||||||
|
download_url = image_info.get("url")
|
||||||
|
if not download_url:
|
||||||
|
raise RecipeDownloadError("No image URL found in Civitai response")
|
||||||
|
|
||||||
|
success, result = await downloader.download_file(download_url, temp_path, use_auth=False)
|
||||||
|
if not success:
|
||||||
|
raise RecipeDownloadError(f"Failed to download image: {result}")
|
||||||
|
with open(temp_path, "rb") as file_obj:
|
||||||
|
return file_obj.read()
|
||||||
|
except RecipeDownloadError:
|
||||||
|
raise
|
||||||
|
except RecipeValidationError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - defensive guard
|
||||||
|
raise RecipeValidationError(f"Unable to download image: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
if temp_path:
|
||||||
|
try:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _safe_int(self, value: Any) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def _resolve_base_model_from_checkpoint(self, checkpoint_entry: Dict[str, Any]) -> str:
|
||||||
|
version_id = self._safe_int(checkpoint_entry.get("modelVersionId"))
|
||||||
|
|
||||||
|
if not version_id:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = await get_default_metadata_provider()
|
||||||
|
if not provider:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
version_info = await provider.get_model_version_info(version_id)
|
||||||
|
if isinstance(version_info, tuple):
|
||||||
|
version_info = version_info[0]
|
||||||
|
|
||||||
|
if isinstance(version_info, dict):
|
||||||
|
base_model = version_info.get("baseModel") or ""
|
||||||
|
return str(base_model) if base_model is not None else ""
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
self._logger.warning("Failed to resolve base model from checkpoint metadata: %s", exc)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeAnalysisHandler:
|
||||||
|
"""Analyze images to extract recipe metadata."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
||||||
|
recipe_scanner_getter: RecipeScannerGetter,
|
||||||
|
civitai_client_getter: CivitaiClientGetter,
|
||||||
|
logger: Logger,
|
||||||
|
analysis_service: RecipeAnalysisService,
|
||||||
|
) -> None:
|
||||||
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
||||||
|
self._recipe_scanner_getter = recipe_scanner_getter
|
||||||
|
self._civitai_client_getter = civitai_client_getter
|
||||||
|
self._logger = logger
|
||||||
|
self._analysis_service = analysis_service
|
||||||
|
|
||||||
|
async def analyze_uploaded_image(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
civitai_client = self._civitai_client_getter()
|
||||||
|
if recipe_scanner is None or civitai_client is None:
|
||||||
|
raise RuntimeError("Required services unavailable")
|
||||||
|
|
||||||
|
content_type = request.headers.get("Content-Type", "")
|
||||||
|
if "multipart/form-data" in content_type:
|
||||||
|
reader = await request.multipart()
|
||||||
|
field = await reader.next()
|
||||||
|
if field is None or field.name != "image":
|
||||||
|
raise RecipeValidationError("No image field found")
|
||||||
|
image_chunks = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = await field.read_chunk()
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
image_chunks.extend(chunk)
|
||||||
|
result = await self._analysis_service.analyze_uploaded_image(
|
||||||
|
image_bytes=bytes(image_chunks),
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
|
||||||
|
if "application/json" in content_type:
|
||||||
|
data = await request.json()
|
||||||
|
result = await self._analysis_service.analyze_remote_image(
|
||||||
|
url=data.get("url"),
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
|
||||||
|
raise RecipeValidationError("Unsupported content type")
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"error": str(exc), "loras": []}, status=400)
|
||||||
|
except RecipeDownloadError as exc:
|
||||||
|
return web.json_response({"error": str(exc), "loras": []}, status=400)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"error": str(exc), "loras": []}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error analyzing recipe image: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc), "loras": []}, status=500)
|
||||||
|
|
||||||
|
async def analyze_local_image(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
result = await self._analysis_service.analyze_local_image(
|
||||||
|
file_path=data.get("path"),
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"error": str(exc), "loras": []}, status=400)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"error": str(exc), "loras": []}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error analyzing local image: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc), "loras": []}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeSharingHandler:
|
||||||
|
"""Serve endpoints related to recipe sharing."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
ensure_dependencies_ready: EnsureDependenciesCallable,
|
||||||
|
recipe_scanner_getter: RecipeScannerGetter,
|
||||||
|
logger: Logger,
|
||||||
|
sharing_service: RecipeSharingService,
|
||||||
|
) -> None:
|
||||||
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
||||||
|
self._recipe_scanner_getter = recipe_scanner_getter
|
||||||
|
self._logger = logger
|
||||||
|
self._sharing_service = sharing_service
|
||||||
|
|
||||||
|
async def share_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
recipe_id = request.match_info["recipe_id"]
|
||||||
|
result = await self._sharing_service.share_recipe(
|
||||||
|
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error sharing recipe: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def download_shared_recipe(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
recipe_id = request.match_info["recipe_id"]
|
||||||
|
download_info = await self._sharing_service.prepare_download(
|
||||||
|
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
||||||
|
)
|
||||||
|
return web.FileResponse(
|
||||||
|
download_info.file_path,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{download_info.download_filename}"'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error downloading shared recipe: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
@@ -5,9 +5,9 @@ from typing import Dict
|
|||||||
from server import PromptServer # type: ignore
|
from server import PromptServer # type: ignore
|
||||||
|
|
||||||
from .base_model_routes import BaseModelRoutes
|
from .base_model_routes import BaseModelRoutes
|
||||||
|
from .model_route_registrar import ModelRouteRegistrar
|
||||||
from ..services.lora_service import LoraService
|
from ..services.lora_service import LoraService
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..utils.routes_common import ModelRouteUtils
|
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -17,42 +17,36 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize LoRA routes with LoRA service"""
|
"""Initialize LoRA routes with LoRA service"""
|
||||||
# Service will be initialized later via setup_routes
|
super().__init__()
|
||||||
self.service = None
|
|
||||||
self.civitai_client = None
|
|
||||||
self.template_name = "loras.html"
|
self.template_name = "loras.html"
|
||||||
|
|
||||||
async def initialize_services(self):
|
async def initialize_services(self):
|
||||||
"""Initialize services from ServiceRegistry"""
|
"""Initialize services from ServiceRegistry"""
|
||||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
self.service = LoraService(lora_scanner)
|
update_service = await ServiceRegistry.get_model_update_service()
|
||||||
self.civitai_client = await ServiceRegistry.get_civitai_client()
|
self.service = LoraService(lora_scanner, update_service=update_service)
|
||||||
|
self.set_model_update_service(update_service)
|
||||||
# Initialize parent with the service
|
|
||||||
super().__init__(self.service)
|
# Attach service dependencies
|
||||||
|
self.attach_service(self.service)
|
||||||
|
|
||||||
def setup_routes(self, app: web.Application):
|
def setup_routes(self, app: web.Application):
|
||||||
"""Setup LoRA routes"""
|
"""Setup LoRA routes"""
|
||||||
# Schedule service initialization on app startup
|
# Schedule service initialization on app startup
|
||||||
app.on_startup.append(lambda _: self.initialize_services())
|
app.on_startup.append(lambda _: self.initialize_services())
|
||||||
|
|
||||||
# Setup common routes with 'loras' prefix (includes page route)
|
# Setup common routes with 'loras' prefix (includes page route)
|
||||||
super().setup_routes(app, 'loras')
|
super().setup_routes(app, 'loras')
|
||||||
|
|
||||||
def setup_specific_routes(self, app: web.Application, prefix: str):
|
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
||||||
"""Setup LoRA-specific routes"""
|
"""Setup LoRA-specific routes"""
|
||||||
# LoRA-specific query routes
|
# LoRA-specific query routes
|
||||||
app.router.add_get(f'/api/{prefix}/letter-counts', self.get_letter_counts)
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/letter-counts', prefix, self.get_letter_counts)
|
||||||
app.router.add_get(f'/api/{prefix}/get-trigger-words', self.get_lora_trigger_words)
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/get-trigger-words', prefix, self.get_lora_trigger_words)
|
||||||
app.router.add_get(f'/api/{prefix}/usage-tips-by-path', self.get_lora_usage_tips_by_path)
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/usage-tips-by-path', prefix, self.get_lora_usage_tips_by_path)
|
||||||
|
|
||||||
# CivitAI integration with LoRA-specific validation
|
|
||||||
app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions_lora)
|
|
||||||
app.router.add_get(f'/api/{prefix}/civitai/model/version/{{modelVersionId}}', self.get_civitai_model_by_version)
|
|
||||||
app.router.add_get(f'/api/{prefix}/civitai/model/hash/{{hash}}', self.get_civitai_model_by_hash)
|
|
||||||
|
|
||||||
# ComfyUI integration
|
# ComfyUI integration
|
||||||
app.router.add_post(f'/api/{prefix}/get_trigger_words', self.get_trigger_words)
|
registrar.add_prefixed_route('POST', '/api/lm/{prefix}/get_trigger_words', prefix, self.get_trigger_words)
|
||||||
|
|
||||||
def _parse_specific_params(self, request: web.Request) -> Dict:
|
def _parse_specific_params(self, request: web.Request) -> Dict:
|
||||||
"""Parse LoRA-specific parameters"""
|
"""Parse LoRA-specific parameters"""
|
||||||
@@ -78,6 +72,15 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
||||||
|
"""Validate CivitAI model type for LoRA"""
|
||||||
|
from ..utils.constants import VALID_LORA_TYPES
|
||||||
|
return model_type.lower() in VALID_LORA_TYPES
|
||||||
|
|
||||||
|
def _get_expected_model_types(self) -> str:
|
||||||
|
"""Get expected model types string for error messages"""
|
||||||
|
return "LORA, LoCon, or DORA"
|
||||||
|
|
||||||
# LoRA-specific route handlers
|
# LoRA-specific route handlers
|
||||||
async def get_letter_counts(self, request: web.Request) -> web.Response:
|
async def get_letter_counts(self, request: web.Request) -> web.Response:
|
||||||
"""Get count of LoRAs for each letter of the alphabet"""
|
"""Get count of LoRAs for each letter of the alphabet"""
|
||||||
@@ -212,91 +215,6 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
# CivitAI integration methods
|
|
||||||
async def get_civitai_versions_lora(self, request: web.Request) -> web.Response:
|
|
||||||
"""Get available versions for a Civitai LoRA model with local availability info"""
|
|
||||||
try:
|
|
||||||
model_id = request.match_info['model_id']
|
|
||||||
response = await self.civitai_client.get_model_versions(model_id)
|
|
||||||
if not response or not response.get('modelVersions'):
|
|
||||||
return web.Response(status=404, text="Model not found")
|
|
||||||
|
|
||||||
versions = response.get('modelVersions', [])
|
|
||||||
model_type = response.get('type', '')
|
|
||||||
|
|
||||||
# Check model type - should be LORA, LoCon, or DORA
|
|
||||||
from ..utils.constants import VALID_LORA_TYPES
|
|
||||||
if model_type.lower() not in VALID_LORA_TYPES:
|
|
||||||
return web.json_response({
|
|
||||||
'error': f"Model type mismatch. Expected LORA or LoCon, got {model_type}"
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Check local availability for each version
|
|
||||||
for version in versions:
|
|
||||||
# Find the model file (type="Model") in the files list
|
|
||||||
model_file = next((file for file in version.get('files', [])
|
|
||||||
if file.get('type') == 'Model'), None)
|
|
||||||
|
|
||||||
if model_file:
|
|
||||||
sha256 = model_file.get('hashes', {}).get('SHA256')
|
|
||||||
if sha256:
|
|
||||||
# Set existsLocally and localPath at the version level
|
|
||||||
version['existsLocally'] = self.service.has_hash(sha256)
|
|
||||||
if version['existsLocally']:
|
|
||||||
version['localPath'] = self.service.get_path_by_hash(sha256)
|
|
||||||
|
|
||||||
# Also set the model file size at the version level for easier access
|
|
||||||
version['modelSizeKB'] = model_file.get('sizeKB')
|
|
||||||
else:
|
|
||||||
# No model file found in this version
|
|
||||||
version['existsLocally'] = False
|
|
||||||
|
|
||||||
return web.json_response(versions)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching LoRA model versions: {e}")
|
|
||||||
return web.Response(status=500, text=str(e))
|
|
||||||
|
|
||||||
async def get_civitai_model_by_version(self, request: web.Request) -> web.Response:
|
|
||||||
"""Get CivitAI model details by model version ID"""
|
|
||||||
try:
|
|
||||||
model_version_id = request.match_info.get('modelVersionId')
|
|
||||||
|
|
||||||
# Get model details from Civitai API
|
|
||||||
model, error_msg = await self.civitai_client.get_model_version_info(model_version_id)
|
|
||||||
|
|
||||||
if not model:
|
|
||||||
# Log warning for failed model retrieval
|
|
||||||
logger.warning(f"Failed to fetch model version {model_version_id}: {error_msg}")
|
|
||||||
|
|
||||||
# Determine status code based on error message
|
|
||||||
status_code = 404 if error_msg and "not found" in error_msg.lower() else 500
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
"success": False,
|
|
||||||
"error": error_msg or "Failed to fetch model information"
|
|
||||||
}, status=status_code)
|
|
||||||
|
|
||||||
return web.json_response(model)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching model details: {e}")
|
|
||||||
return web.json_response({
|
|
||||||
"success": False,
|
|
||||||
"error": str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
async def get_civitai_model_by_hash(self, request: web.Request) -> web.Response:
|
|
||||||
"""Get CivitAI model details by hash"""
|
|
||||||
try:
|
|
||||||
hash = request.match_info.get('hash')
|
|
||||||
model = await self.civitai_client.get_model_by_hash(hash)
|
|
||||||
return web.json_response(model)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching model details by hash: {e}")
|
|
||||||
return web.json_response({
|
|
||||||
"success": False,
|
|
||||||
"error": str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
async def get_trigger_words(self, request: web.Request) -> web.Response:
|
async def get_trigger_words(self, request: web.Request) -> web.Response:
|
||||||
"""Get trigger words for specified LoRA models"""
|
"""Get trigger words for specified LoRA models"""
|
||||||
try:
|
try:
|
||||||
@@ -313,11 +231,27 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||||
|
|
||||||
# Send update to all connected trigger word toggle nodes
|
# Send update to all connected trigger word toggle nodes
|
||||||
for node_id in node_ids:
|
for entry in node_ids:
|
||||||
PromptServer.instance.send_sync("trigger_word_update", {
|
node_identifier = entry
|
||||||
"id": node_id,
|
graph_identifier = None
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
node_identifier = entry.get("node_id")
|
||||||
|
graph_identifier = entry.get("graph_id")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_node_id = int(node_identifier)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed_node_id = node_identifier
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"id": parsed_node_id,
|
||||||
"message": trigger_words_text
|
"message": trigger_words_text
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if graph_identifier is not None:
|
||||||
|
payload["graph_id"] = str(graph_identifier)
|
||||||
|
|
||||||
|
PromptServer.instance.send_sync("trigger_word_update", payload)
|
||||||
|
|
||||||
return web.json_response({"success": True})
|
return web.json_response({"success": True})
|
||||||
|
|
||||||
|
|||||||
72
py/routes/misc_route_registrar.py
Normal file
72
py/routes/misc_route_registrar.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Route registrar for miscellaneous endpoints.
|
||||||
|
|
||||||
|
This module mirrors the model route registrar architecture so that
|
||||||
|
miscellaneous endpoints share a consistent registration flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Iterable, Mapping
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RouteDefinition:
|
||||||
|
"""Declarative definition for a HTTP route."""
|
||||||
|
|
||||||
|
method: str
|
||||||
|
path: str
|
||||||
|
handler_name: str
|
||||||
|
|
||||||
|
|
||||||
|
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||||
|
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
|
||||||
|
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
||||||
|
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
||||||
|
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||||
|
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
||||||
|
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
|
||||||
|
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
|
||||||
|
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
||||||
|
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
||||||
|
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
|
||||||
|
RouteDefinition("GET", "/api/lm/trained-words", "get_trained_words"),
|
||||||
|
RouteDefinition("GET", "/api/lm/model-example-files", "get_model_example_files"),
|
||||||
|
RouteDefinition("POST", "/api/lm/register-nodes", "register_nodes"),
|
||||||
|
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
||||||
|
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||||
|
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||||
|
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
|
||||||
|
RouteDefinition("POST", "/api/lm/download-metadata-archive", "download_metadata_archive"),
|
||||||
|
RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"),
|
||||||
|
RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"),
|
||||||
|
RouteDefinition("GET", "/api/lm/model-versions-status", "get_model_versions_status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MiscRouteRegistrar:
|
||||||
|
"""Bind miscellaneous route definitions to an aiohttp router."""
|
||||||
|
|
||||||
|
_METHOD_MAP = {
|
||||||
|
"GET": "add_get",
|
||||||
|
"POST": "add_post",
|
||||||
|
"PUT": "add_put",
|
||||||
|
"DELETE": "add_delete",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, app: web.Application) -> None:
|
||||||
|
self._app = app
|
||||||
|
|
||||||
|
def register_routes(
|
||||||
|
self,
|
||||||
|
handler_lookup: Mapping[str, Callable[[web.Request], object]],
|
||||||
|
*,
|
||||||
|
definitions: Iterable[RouteDefinition] = MISC_ROUTE_DEFINITIONS,
|
||||||
|
) -> None:
|
||||||
|
for definition in definitions:
|
||||||
|
self._bind(definition.method, definition.path, handler_lookup[definition.handler_name])
|
||||||
|
|
||||||
|
def _bind(self, method: str, path: str, handler: Callable) -> None:
|
||||||
|
add_method_name = self._METHOD_MAP[method.upper()]
|
||||||
|
add_method = getattr(self._app.router, add_method_name)
|
||||||
|
add_method(path, handler)
|
||||||
@@ -1,699 +1,135 @@
|
|||||||
|
"""Route controller for miscellaneous endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
from typing import Awaitable, Callable, Mapping
|
||||||
import threading
|
|
||||||
import asyncio
|
|
||||||
from server import PromptServer # type: ignore
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from ..services.settings_manager import settings
|
from server import PromptServer # type: ignore
|
||||||
|
|
||||||
|
from ..services.metadata_service import (
|
||||||
|
get_metadata_archive_manager,
|
||||||
|
get_metadata_provider,
|
||||||
|
update_metadata_providers,
|
||||||
|
)
|
||||||
|
from ..services.settings_manager import get_settings_manager
|
||||||
|
from ..services.downloader import get_downloader
|
||||||
from ..utils.usage_stats import UsageStats
|
from ..utils.usage_stats import UsageStats
|
||||||
from ..utils.lora_metadata import extract_trained_words
|
from .handlers.misc_handlers import (
|
||||||
from ..config import config
|
FileSystemHandler,
|
||||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS, NODE_TYPES, DEFAULT_NODE_COLOR
|
HealthCheckHandler,
|
||||||
from ..services.service_registry import ServiceRegistry
|
LoraCodeHandler,
|
||||||
import re
|
MetadataArchiveHandler,
|
||||||
|
MiscHandlerSet,
|
||||||
|
ModelExampleFilesHandler,
|
||||||
|
ModelLibraryHandler,
|
||||||
|
NodeRegistry,
|
||||||
|
NodeRegistryHandler,
|
||||||
|
SettingsHandler,
|
||||||
|
TrainedWordsHandler,
|
||||||
|
UsageStatsHandler,
|
||||||
|
build_service_registry_adapter,
|
||||||
|
)
|
||||||
|
from .misc_route_registrar import MiscRouteRegistrar
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
standalone_mode = 'nodes' not in sys.modules
|
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get(
|
||||||
|
"HF_HUB_DISABLE_TELEMETRY", "0"
|
||||||
|
) == "0"
|
||||||
|
|
||||||
# Node registry for tracking active workflow nodes
|
|
||||||
class NodeRegistry:
|
|
||||||
"""Thread-safe registry for tracking Lora nodes in active workflows"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
self._nodes = {} # node_id -> node_info
|
|
||||||
self._registry_updated = threading.Event()
|
|
||||||
|
|
||||||
def register_nodes(self, nodes):
|
|
||||||
"""Register multiple nodes at once, replacing existing registry"""
|
|
||||||
with self._lock:
|
|
||||||
# Clear existing registry
|
|
||||||
self._nodes.clear()
|
|
||||||
|
|
||||||
# Register all new nodes
|
|
||||||
for node in nodes:
|
|
||||||
node_id = node['node_id']
|
|
||||||
node_type = node.get('type', '')
|
|
||||||
|
|
||||||
# Convert node type name to integer
|
|
||||||
type_id = NODE_TYPES.get(node_type, 0) # 0 for unknown types
|
|
||||||
|
|
||||||
# Handle null bgcolor with default color
|
|
||||||
bgcolor = node.get('bgcolor')
|
|
||||||
if bgcolor is None:
|
|
||||||
bgcolor = DEFAULT_NODE_COLOR
|
|
||||||
|
|
||||||
self._nodes[node_id] = {
|
|
||||||
'id': node_id,
|
|
||||||
'bgcolor': bgcolor,
|
|
||||||
'title': node.get('title'),
|
|
||||||
'type': type_id,
|
|
||||||
'type_name': node_type
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(f"Registered {len(nodes)} nodes in registry")
|
|
||||||
|
|
||||||
# Signal that registry has been updated
|
|
||||||
self._registry_updated.set()
|
|
||||||
|
|
||||||
def get_registry(self):
|
|
||||||
"""Get current registry information"""
|
|
||||||
with self._lock:
|
|
||||||
return {
|
|
||||||
'nodes': dict(self._nodes), # Return a copy
|
|
||||||
'node_count': len(self._nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
def clear_registry(self):
|
|
||||||
"""Clear the entire registry"""
|
|
||||||
with self._lock:
|
|
||||||
self._nodes.clear()
|
|
||||||
logger.info("Node registry cleared")
|
|
||||||
|
|
||||||
def wait_for_update(self, timeout=1.0):
|
|
||||||
"""Wait for registry update with timeout"""
|
|
||||||
self._registry_updated.clear()
|
|
||||||
return self._registry_updated.wait(timeout)
|
|
||||||
|
|
||||||
# Global registry instance
|
|
||||||
node_registry = NodeRegistry()
|
|
||||||
|
|
||||||
class MiscRoutes:
|
class MiscRoutes:
|
||||||
"""Miscellaneous routes for various utility functions"""
|
"""Route controller that mirrors the model route architecture."""
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def setup_routes(app):
|
|
||||||
"""Register miscellaneous routes"""
|
|
||||||
app.router.add_post('/api/settings', MiscRoutes.update_settings)
|
|
||||||
|
|
||||||
# Add new route for clearing cache
|
|
||||||
app.router.add_post('/api/clear-cache', MiscRoutes.clear_cache)
|
|
||||||
|
|
||||||
app.router.add_get('/api/health-check', lambda request: web.json_response({'status': 'ok'}))
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
settings_service=None,
|
||||||
|
usage_stats_factory: Callable[[], UsageStats] = UsageStats,
|
||||||
|
prompt_server: type[PromptServer] = PromptServer,
|
||||||
|
service_registry_adapter=build_service_registry_adapter(),
|
||||||
|
metadata_provider_factory=get_metadata_provider,
|
||||||
|
metadata_archive_manager_factory=get_metadata_archive_manager,
|
||||||
|
metadata_provider_updater=update_metadata_providers,
|
||||||
|
downloader_factory=get_downloader,
|
||||||
|
registrar_factory=MiscRouteRegistrar,
|
||||||
|
handler_set_factory=MiscHandlerSet,
|
||||||
|
node_registry: NodeRegistry | None = None,
|
||||||
|
standalone_mode_flag: bool = standalone_mode,
|
||||||
|
) -> None:
|
||||||
|
self._settings = settings_service or get_settings_manager()
|
||||||
|
self._usage_stats_factory = usage_stats_factory
|
||||||
|
self._prompt_server = prompt_server
|
||||||
|
self._service_registry_adapter = service_registry_adapter
|
||||||
|
self._metadata_provider_factory = metadata_provider_factory
|
||||||
|
self._metadata_archive_manager_factory = metadata_archive_manager_factory
|
||||||
|
self._metadata_provider_updater = metadata_provider_updater
|
||||||
|
self._downloader_factory = downloader_factory
|
||||||
|
self._registrar_factory = registrar_factory
|
||||||
|
self._handler_set_factory = handler_set_factory
|
||||||
|
self._node_registry = node_registry or NodeRegistry()
|
||||||
|
self._standalone_mode = standalone_mode_flag
|
||||||
|
|
||||||
# Usage stats routes
|
self._handler_mapping: Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]] | None = None
|
||||||
app.router.add_post('/api/update-usage-stats', MiscRoutes.update_usage_stats)
|
|
||||||
app.router.add_get('/api/get-usage-stats', MiscRoutes.get_usage_stats)
|
|
||||||
|
|
||||||
# Lora code update endpoint
|
|
||||||
app.router.add_post('/api/update-lora-code', MiscRoutes.update_lora_code)
|
|
||||||
|
|
||||||
# Add new route for getting trained words
|
|
||||||
app.router.add_get('/api/trained-words', MiscRoutes.get_trained_words)
|
|
||||||
|
|
||||||
# Add new route for getting model example files
|
|
||||||
app.router.add_get('/api/model-example-files', MiscRoutes.get_model_example_files)
|
|
||||||
|
|
||||||
# Node registry endpoints
|
|
||||||
app.router.add_post('/api/register-nodes', MiscRoutes.register_nodes)
|
|
||||||
app.router.add_get('/api/get-registry', MiscRoutes.get_registry)
|
|
||||||
|
|
||||||
# Add new route for checking if a model exists in the library
|
|
||||||
app.router.add_get('/api/check-model-exists', MiscRoutes.check_model_exists)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def clear_cache(request):
|
def setup_routes(app: web.Application) -> None:
|
||||||
"""Clear all cache files from the cache folder"""
|
"""Entry point used by the application bootstrap."""
|
||||||
try:
|
controller = MiscRoutes()
|
||||||
# Get the cache folder path (relative to project directory)
|
controller.bind(app)
|
||||||
project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
cache_folder = os.path.join(project_dir, 'cache')
|
|
||||||
|
|
||||||
# Check if cache folder exists
|
|
||||||
if not os.path.exists(cache_folder):
|
|
||||||
logger.info("Cache folder does not exist, nothing to clear")
|
|
||||||
return web.json_response({'success': True, 'message': 'No cache folder found'})
|
|
||||||
|
|
||||||
# Get list of cache files before deleting for reporting
|
|
||||||
cache_files = [f for f in os.listdir(cache_folder) if os.path.isfile(os.path.join(cache_folder, f))]
|
|
||||||
deleted_files = []
|
|
||||||
|
|
||||||
# Delete each .msgpack file in the cache folder
|
|
||||||
for filename in cache_files:
|
|
||||||
if filename.endswith('.msgpack'):
|
|
||||||
file_path = os.path.join(cache_folder, filename)
|
|
||||||
try:
|
|
||||||
os.remove(file_path)
|
|
||||||
deleted_files.append(filename)
|
|
||||||
logger.info(f"Deleted cache file: {filename}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete {filename}: {e}")
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': f"Failed to delete {filename}: {str(e)}"
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'message': f"Successfully cleared {len(deleted_files)} cache files",
|
|
||||||
'deleted_files': deleted_files
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error clearing cache files: {e}", exc_info=True)
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
@staticmethod
|
def bind(self, app: web.Application) -> None:
|
||||||
async def update_settings(request):
|
registrar = self._registrar_factory(app)
|
||||||
"""Update application settings"""
|
registrar.register_routes(self._ensure_handler_mapping())
|
||||||
try:
|
|
||||||
data = await request.json()
|
|
||||||
|
|
||||||
# Validate and update settings
|
|
||||||
for key, value in data.items():
|
|
||||||
if value == settings.get(key):
|
|
||||||
# No change, skip
|
|
||||||
continue
|
|
||||||
# Special handling for example_images_path - verify path exists
|
|
||||||
if key == 'example_images_path' and value:
|
|
||||||
if not os.path.exists(value):
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': f"Path does not exist: {value}"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Path changed - server restart required for new path to take effect
|
|
||||||
old_path = settings.get('example_images_path')
|
|
||||||
if old_path != value:
|
|
||||||
logger.info(f"Example images path changed to {value} - server restart required")
|
|
||||||
|
|
||||||
# Save to settings
|
|
||||||
settings.set(key, value)
|
|
||||||
|
|
||||||
return web.json_response({'success': True})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating settings: {e}", exc_info=True)
|
|
||||||
return web.Response(status=500, text=str(e))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def update_usage_stats(request):
|
|
||||||
"""
|
|
||||||
Update usage statistics based on a prompt_id
|
|
||||||
|
|
||||||
Expects a JSON body with:
|
|
||||||
{
|
|
||||||
"prompt_id": "string"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Parse the request body
|
|
||||||
data = await request.json()
|
|
||||||
prompt_id = data.get('prompt_id')
|
|
||||||
|
|
||||||
if not prompt_id:
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Missing prompt_id'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Call the UsageStats to process this prompt_id synchronously
|
|
||||||
usage_stats = UsageStats()
|
|
||||||
await usage_stats.process_execution(prompt_id)
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update usage stats: {e}", exc_info=True)
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_usage_stats(request):
|
|
||||||
"""Get current usage statistics"""
|
|
||||||
try:
|
|
||||||
usage_stats = UsageStats()
|
|
||||||
stats = await usage_stats.get_stats()
|
|
||||||
|
|
||||||
# Add version information to help clients handle format changes
|
|
||||||
stats_response = {
|
|
||||||
'success': True,
|
|
||||||
'data': stats,
|
|
||||||
'format_version': 2 # Indicate this is the new format with history
|
|
||||||
}
|
|
||||||
|
|
||||||
return web.json_response(stats_response)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get usage stats: {e}", exc_info=True)
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def update_lora_code(request):
|
|
||||||
"""
|
|
||||||
Update Lora code in ComfyUI nodes
|
|
||||||
|
|
||||||
Expects a JSON body with:
|
|
||||||
{
|
|
||||||
"node_ids": [123, 456], # Optional - List of node IDs to update (for browser mode)
|
|
||||||
"lora_code": "<lora:modelname:1.0>", # The Lora code to send
|
|
||||||
"mode": "append" # or "replace" - whether to append or replace existing code
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Parse the request body
|
|
||||||
data = await request.json()
|
|
||||||
node_ids = data.get('node_ids')
|
|
||||||
lora_code = data.get('lora_code', '')
|
|
||||||
mode = data.get('mode', 'append')
|
|
||||||
|
|
||||||
if not lora_code:
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Missing lora_code parameter'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
# Desktop mode: no specific node_ids provided
|
|
||||||
if node_ids is None:
|
|
||||||
try:
|
|
||||||
# Send broadcast message with id=-1 to all Lora Loader nodes
|
|
||||||
PromptServer.instance.send_sync("lora_code_update", {
|
|
||||||
"id": -1,
|
|
||||||
"lora_code": lora_code,
|
|
||||||
"mode": mode
|
|
||||||
})
|
|
||||||
results.append({
|
|
||||||
'node_id': 'broadcast',
|
|
||||||
'success': True
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error broadcasting lora code: {e}")
|
|
||||||
results.append({
|
|
||||||
'node_id': 'broadcast',
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
# Browser mode: send to specific nodes
|
|
||||||
for node_id in node_ids:
|
|
||||||
try:
|
|
||||||
# Send the message to the frontend
|
|
||||||
PromptServer.instance.send_sync("lora_code_update", {
|
|
||||||
"id": node_id,
|
|
||||||
"lora_code": lora_code,
|
|
||||||
"mode": mode
|
|
||||||
})
|
|
||||||
results.append({
|
|
||||||
'node_id': node_id,
|
|
||||||
'success': True
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error sending lora code to node {node_id}: {e}")
|
|
||||||
results.append({
|
|
||||||
'node_id': node_id,
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
})
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'results': results
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update lora code: {e}", exc_info=True)
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
@staticmethod
|
def _ensure_handler_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||||
async def get_trained_words(request):
|
if self._handler_mapping is None:
|
||||||
"""
|
handler_set = self._create_handler_set()
|
||||||
Get trained words from a safetensors file, sorted by frequency
|
self._handler_mapping = handler_set.to_route_mapping()
|
||||||
|
return self._handler_mapping
|
||||||
Expects a query parameter:
|
|
||||||
file_path: Path to the safetensors file
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Get file path from query parameters
|
|
||||||
file_path = request.query.get('file_path')
|
|
||||||
|
|
||||||
if not file_path:
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Missing file_path parameter'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Check if file exists and is a safetensors file
|
|
||||||
if not os.path.exists(file_path):
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': f"File not found: {file_path}"
|
|
||||||
}, status=404)
|
|
||||||
|
|
||||||
if not file_path.lower().endswith('.safetensors'):
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'File is not a safetensors file'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Extract trained words and class_tokens
|
|
||||||
trained_words, class_tokens = await extract_trained_words(file_path)
|
|
||||||
|
|
||||||
# Return result with both trained words and class tokens
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'trained_words': trained_words,
|
|
||||||
'class_tokens': class_tokens
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get trained words: {e}", exc_info=True)
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
@staticmethod
|
def _create_handler_set(self) -> MiscHandlerSet:
|
||||||
async def get_model_example_files(request):
|
health = HealthCheckHandler()
|
||||||
"""
|
settings_handler = SettingsHandler(
|
||||||
Get list of example image files for a specific model based on file path
|
settings_service=self._settings,
|
||||||
|
metadata_provider_updater=self._metadata_provider_updater,
|
||||||
Expects:
|
downloader_factory=self._downloader_factory,
|
||||||
- file_path in query parameters
|
)
|
||||||
|
usage_stats = UsageStatsHandler(usage_stats_factory=self._usage_stats_factory)
|
||||||
Returns:
|
lora_code = LoraCodeHandler(prompt_server=self._prompt_server)
|
||||||
- List of image files with their paths as static URLs
|
trained_words = TrainedWordsHandler()
|
||||||
"""
|
model_examples = ModelExampleFilesHandler()
|
||||||
try:
|
metadata_archive = MetadataArchiveHandler(
|
||||||
# Get the model file path from query parameters
|
metadata_archive_manager_factory=self._metadata_archive_manager_factory,
|
||||||
file_path = request.query.get('file_path')
|
settings_service=self._settings,
|
||||||
|
metadata_provider_updater=self._metadata_provider_updater,
|
||||||
if not file_path:
|
)
|
||||||
return web.json_response({
|
filesystem = FileSystemHandler()
|
||||||
'success': False,
|
node_registry_handler = NodeRegistryHandler(
|
||||||
'error': 'Missing file_path parameter'
|
node_registry=self._node_registry,
|
||||||
}, status=400)
|
prompt_server=self._prompt_server,
|
||||||
|
standalone_mode=self._standalone_mode,
|
||||||
# Extract directory and base filename
|
)
|
||||||
model_dir = os.path.dirname(file_path)
|
model_library = ModelLibraryHandler(
|
||||||
model_filename = os.path.basename(file_path)
|
service_registry=self._service_registry_adapter,
|
||||||
model_name = os.path.splitext(model_filename)[0]
|
metadata_provider_factory=self._metadata_provider_factory,
|
||||||
|
)
|
||||||
# Check if the directory exists
|
|
||||||
if not os.path.exists(model_dir):
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Model directory not found',
|
|
||||||
'files': []
|
|
||||||
}, status=404)
|
|
||||||
|
|
||||||
# Look for files matching the pattern modelname.example.<index>.<ext>
|
|
||||||
files = []
|
|
||||||
pattern = f"{model_name}.example."
|
|
||||||
|
|
||||||
for file in os.listdir(model_dir):
|
|
||||||
file_lower = file.lower()
|
|
||||||
if file_lower.startswith(pattern.lower()):
|
|
||||||
file_full_path = os.path.join(model_dir, file)
|
|
||||||
if os.path.isfile(file_full_path):
|
|
||||||
# Check if the file is a supported media file
|
|
||||||
file_ext = os.path.splitext(file)[1].lower()
|
|
||||||
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
|
|
||||||
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
|
|
||||||
|
|
||||||
# Extract the index from the filename
|
|
||||||
try:
|
|
||||||
# Extract the part after '.example.' and before file extension
|
|
||||||
index_part = file[len(pattern):].split('.')[0]
|
|
||||||
# Try to parse it as an integer
|
|
||||||
index = int(index_part)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
# If we can't parse the index, use infinity to sort at the end
|
|
||||||
index = float('inf')
|
|
||||||
|
|
||||||
# Convert file path to static URL
|
|
||||||
static_url = config.get_preview_static_url(file_full_path)
|
|
||||||
|
|
||||||
files.append({
|
|
||||||
'name': file,
|
|
||||||
'path': static_url,
|
|
||||||
'extension': file_ext,
|
|
||||||
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'],
|
|
||||||
'index': index
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort files by their index for consistent ordering
|
|
||||||
files.sort(key=lambda x: x['index'])
|
|
||||||
# Remove the index field as it's only used for sorting
|
|
||||||
for file in files:
|
|
||||||
file.pop('index', None)
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'files': files
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get model example files: {e}", exc_info=True)
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
@staticmethod
|
return self._handler_set_factory(
|
||||||
async def register_nodes(request):
|
health=health,
|
||||||
"""
|
settings=settings_handler,
|
||||||
Register multiple Lora nodes at once
|
usage_stats=usage_stats,
|
||||||
|
lora_code=lora_code,
|
||||||
Expects a JSON body with:
|
trained_words=trained_words,
|
||||||
{
|
model_examples=model_examples,
|
||||||
"nodes": [
|
node_registry=node_registry_handler,
|
||||||
{
|
model_library=model_library,
|
||||||
"node_id": 123,
|
metadata_archive=metadata_archive,
|
||||||
"bgcolor": "#535",
|
filesystem=filesystem,
|
||||||
"title": "Lora Loader (LoraManager)"
|
)
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = await request.json()
|
|
||||||
|
|
||||||
# Validate required fields
|
|
||||||
nodes = data.get('nodes', [])
|
|
||||||
|
|
||||||
if not isinstance(nodes, list):
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'nodes must be a list'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Validate each node
|
|
||||||
for i, node in enumerate(nodes):
|
|
||||||
if not isinstance(node, dict):
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': f'Node {i} must be an object'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
node_id = node.get('node_id')
|
|
||||||
if node_id is None:
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': f'Node {i} missing node_id parameter'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Validate node_id is an integer
|
|
||||||
try:
|
|
||||||
node['node_id'] = int(node_id)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': f'Node {i} node_id must be an integer'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Register all nodes
|
|
||||||
node_registry.register_nodes(nodes)
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'message': f'{len(nodes)} nodes registered successfully'
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to register nodes: {e}", exc_info=True)
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_registry(request):
|
|
||||||
"""Get current node registry information by refreshing from frontend"""
|
|
||||||
try:
|
|
||||||
# Check if running in standalone mode
|
|
||||||
if standalone_mode:
|
|
||||||
logger.warning("Registry refresh not available in standalone mode")
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Standalone Mode Active',
|
|
||||||
'message': 'Cannot interact with ComfyUI in standalone mode.'
|
|
||||||
}, status=503)
|
|
||||||
|
|
||||||
# Send message to frontend to refresh registry
|
|
||||||
try:
|
|
||||||
PromptServer.instance.send_sync("lora_registry_refresh", {})
|
|
||||||
logger.debug("Sent registry refresh request to frontend")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send registry refresh message: {e}")
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Communication Error',
|
|
||||||
'message': f'Failed to communicate with ComfyUI frontend: {str(e)}'
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
# Wait for registry update with timeout
|
|
||||||
def wait_for_registry():
|
|
||||||
return node_registry.wait_for_update(timeout=1.0)
|
|
||||||
|
|
||||||
# Run the wait in a thread to avoid blocking the event loop
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
registry_updated = await loop.run_in_executor(None, wait_for_registry)
|
|
||||||
|
|
||||||
if not registry_updated:
|
|
||||||
logger.warning("Registry refresh timeout after 1 second")
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Timeout Error',
|
|
||||||
'message': 'Registry refresh timeout - ComfyUI frontend may not be responsive'
|
|
||||||
}, status=408)
|
|
||||||
|
|
||||||
# Get updated registry
|
|
||||||
registry_info = node_registry.get_registry()
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'data': registry_info
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get registry: {e}", exc_info=True)
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Internal Error',
|
|
||||||
'message': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def check_model_exists(request):
|
|
||||||
"""
|
|
||||||
Check if a model with specified modelId and optionally modelVersionId exists in the library
|
|
||||||
|
|
||||||
Expects query parameters:
|
|
||||||
- modelId: int - Civitai model ID (required)
|
|
||||||
- modelVersionId: int - Civitai model version ID (optional)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
- If modelVersionId is provided: JSON with a boolean 'exists' field
|
|
||||||
- If modelVersionId is not provided: JSON with a list of modelVersionIds that exist in the library
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Get the modelId and modelVersionId from query parameters
|
|
||||||
model_id_str = request.query.get('modelId')
|
|
||||||
model_version_id_str = request.query.get('modelVersionId')
|
|
||||||
|
|
||||||
# Validate modelId parameter (required)
|
|
||||||
if not model_id_str:
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Missing required parameter: modelId'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Convert modelId to integer
|
|
||||||
model_id = int(model_id_str)
|
|
||||||
except ValueError:
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Parameter modelId must be an integer'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Get all scanners
|
|
||||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
|
||||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
|
||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
|
||||||
|
|
||||||
# If modelVersionId is provided, check for specific version
|
|
||||||
if model_version_id_str:
|
|
||||||
try:
|
|
||||||
model_version_id = int(model_version_id_str)
|
|
||||||
except ValueError:
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Parameter modelVersionId must be an integer'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Check lora scanner first
|
|
||||||
exists = False
|
|
||||||
model_type = None
|
|
||||||
|
|
||||||
if await lora_scanner.check_model_version_exists(model_version_id):
|
__all__ = ["MiscRoutes"]
|
||||||
exists = True
|
|
||||||
model_type = 'lora'
|
|
||||||
elif checkpoint_scanner and await checkpoint_scanner.check_model_version_exists(model_version_id):
|
|
||||||
exists = True
|
|
||||||
model_type = 'checkpoint'
|
|
||||||
elif embedding_scanner and await embedding_scanner.check_model_version_exists(model_version_id):
|
|
||||||
exists = True
|
|
||||||
model_type = 'embedding'
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'exists': exists,
|
|
||||||
'modelType': model_type if exists else None
|
|
||||||
})
|
|
||||||
|
|
||||||
# If modelVersionId is not provided, return all version IDs for the model
|
|
||||||
else:
|
|
||||||
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
|
||||||
checkpoint_versions = []
|
|
||||||
embedding_versions = []
|
|
||||||
|
|
||||||
# 优先lora,其次checkpoint,最后embedding
|
|
||||||
if not lora_versions:
|
|
||||||
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id)
|
|
||||||
if not lora_versions and not checkpoint_versions:
|
|
||||||
embedding_versions = await embedding_scanner.get_model_versions_by_id(model_id)
|
|
||||||
|
|
||||||
model_type = None
|
|
||||||
versions = []
|
|
||||||
|
|
||||||
if lora_versions:
|
|
||||||
model_type = 'lora'
|
|
||||||
versions = lora_versions
|
|
||||||
elif checkpoint_versions:
|
|
||||||
model_type = 'checkpoint'
|
|
||||||
versions = checkpoint_versions
|
|
||||||
elif embedding_versions:
|
|
||||||
model_type = 'embedding'
|
|
||||||
versions = embedding_versions
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'modelId': model_id,
|
|
||||||
'modelType': model_type,
|
|
||||||
'versions': versions
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to check model existence: {e}", exc_info=True)
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|||||||
107
py/routes/model_route_registrar.py
Normal file
107
py/routes/model_route_registrar.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Route registrar for model endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Iterable, Mapping
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RouteDefinition:
|
||||||
|
"""Declarative definition for a HTTP route."""
|
||||||
|
|
||||||
|
method: str
|
||||||
|
path_template: str
|
||||||
|
handler_name: str
|
||||||
|
|
||||||
|
def build_path(self, prefix: str) -> str:
|
||||||
|
return self.path_template.replace("{prefix}", prefix)
|
||||||
|
|
||||||
|
|
||||||
|
COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/list", "get_models"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/delete", "delete_model"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/exclude", "exclude_model"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/fetch-civitai", "fetch_civitai"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/replace-preview", "replace_preview"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/save-metadata", "save_metadata"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/add-tags", "add_tags"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/rename", "rename_model"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/bulk-delete", "bulk_delete_models"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/verify-duplicates", "verify_duplicates"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/roots", "get_model_roots"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/folders", "get_folders"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/folder-tree", "get_folder_tree"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/unified-folder-tree", "get_unified_folder_tree"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/find-duplicates", "find_duplicate_models"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/find-filename-conflicts", "find_filename_conflicts"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/get-notes", "get_model_notes"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/preview-url", "get_model_preview_url"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/civitai-url", "get_model_civitai_url"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/metadata", "get_model_metadata"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/model-description", "get_model_description"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/version/{modelVersionId}", "get_civitai_model_by_version"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/hash/{hash}", "get_civitai_model_by_hash"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/updates/refresh", "refresh_model_updates"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/updates/fetch-missing-license", "fetch_missing_civitai_license_data"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/updates/ignore", "set_model_update_ignore"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/updates/ignore-version", "set_version_update_ignore"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/updates/status/{model_id}", "get_model_update_status"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/updates/versions/{model_id}", "get_model_versions"),
|
||||||
|
RouteDefinition("POST", "/api/lm/download-model", "download_model"),
|
||||||
|
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
|
||||||
|
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
|
||||||
|
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
|
||||||
|
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
|
||||||
|
RouteDefinition("GET", "/api/lm/download-progress/{download_id}", "get_download_progress"),
|
||||||
|
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRouteRegistrar:
|
||||||
|
"""Bind declarative definitions to an aiohttp router."""
|
||||||
|
|
||||||
|
_METHOD_MAP = {
|
||||||
|
"GET": "add_get",
|
||||||
|
"POST": "add_post",
|
||||||
|
"PUT": "add_put",
|
||||||
|
"DELETE": "add_delete",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, app: web.Application) -> None:
|
||||||
|
self._app = app
|
||||||
|
|
||||||
|
def register_common_routes(
|
||||||
|
self,
|
||||||
|
prefix: str,
|
||||||
|
handler_lookup: Mapping[str, Callable[[web.Request], object]],
|
||||||
|
*,
|
||||||
|
definitions: Iterable[RouteDefinition] = COMMON_ROUTE_DEFINITIONS,
|
||||||
|
) -> None:
|
||||||
|
for definition in definitions:
|
||||||
|
self._bind_route(definition.method, definition.build_path(prefix), handler_lookup[definition.handler_name])
|
||||||
|
|
||||||
|
def add_route(self, method: str, path: str, handler: Callable) -> None:
|
||||||
|
self._bind_route(method, path, handler)
|
||||||
|
|
||||||
|
def add_prefixed_route(self, method: str, path_template: str, prefix: str, handler: Callable) -> None:
|
||||||
|
self._bind_route(method, path_template.replace("{prefix}", prefix), handler)
|
||||||
|
|
||||||
|
def _bind_route(self, method: str, path: str, handler: Callable) -> None:
|
||||||
|
add_method_name = self._METHOD_MAP[method.upper()]
|
||||||
|
add_method = getattr(self._app.router, add_method_name)
|
||||||
|
add_method(path, handler)
|
||||||
25
py/routes/preview_routes.py
Normal file
25
py/routes/preview_routes.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Route controller for preview asset delivery."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from .handlers.preview_handlers import PreviewHandler
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewRoutes:
|
||||||
|
"""Register routes that expose preview assets."""
|
||||||
|
|
||||||
|
def __init__(self, *, handler: PreviewHandler | None = None) -> None:
|
||||||
|
self._handler = handler or PreviewHandler()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_routes(cls, app: web.Application) -> None:
|
||||||
|
controller = cls()
|
||||||
|
controller.register(app)
|
||||||
|
|
||||||
|
def register(self, app: web.Application) -> None:
|
||||||
|
app.router.add_get('/api/lm/previews', self._handler.serve_preview)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["PreviewRoutes"]
|
||||||
64
py/routes/recipe_route_registrar.py
Normal file
64
py/routes/recipe_route_registrar.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Route registrar for recipe endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Mapping
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RouteDefinition:
|
||||||
|
"""Declarative definition for a recipe HTTP route."""
|
||||||
|
|
||||||
|
method: str
|
||||||
|
path: str
|
||||||
|
handler_name: str
|
||||||
|
|
||||||
|
|
||||||
|
ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||||
|
RouteDefinition("GET", "/loras/recipes", "render_page"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes", "list_recipes"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}", "get_recipe"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/import-remote", "import_remote_recipe"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/analyze-image", "analyze_uploaded_image"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/analyze-local-image", "analyze_local_image"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
|
||||||
|
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
||||||
|
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeRouteRegistrar:
|
||||||
|
"""Bind declarative recipe definitions to an aiohttp router."""
|
||||||
|
|
||||||
|
_METHOD_MAP = {
|
||||||
|
"GET": "add_get",
|
||||||
|
"POST": "add_post",
|
||||||
|
"PUT": "add_put",
|
||||||
|
"DELETE": "add_delete",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, app: web.Application) -> None:
|
||||||
|
self._app = app
|
||||||
|
|
||||||
|
def register_routes(self, handler_lookup: Mapping[str, Callable[[web.Request], object]]) -> None:
|
||||||
|
for definition in ROUTE_DEFINITIONS:
|
||||||
|
handler = handler_lookup[definition.handler_name]
|
||||||
|
self._bind_route(definition.method, definition.path, handler)
|
||||||
|
|
||||||
|
def _bind_route(self, method: str, path: str, handler: Callable) -> None:
|
||||||
|
add_method_name = self._METHOD_MAP[method.upper()]
|
||||||
|
add_method = getattr(self._app.router, add_method_name)
|
||||||
|
add_method(path, handler)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,13 +8,32 @@ from collections import defaultdict, Counter
|
|||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..services.settings_manager import settings
|
from ..services.settings_manager import get_settings_manager
|
||||||
from ..services.server_i18n import server_i18n
|
from ..services.server_i18n import server_i18n
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..utils.usage_stats import UsageStats
|
from ..utils.usage_stats import UsageStats
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _SettingsProxy:
|
||||||
|
def __init__(self):
|
||||||
|
self._manager = None
|
||||||
|
|
||||||
|
def _resolve(self):
|
||||||
|
if self._manager is None:
|
||||||
|
self._manager = get_settings_manager()
|
||||||
|
return self._manager
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
return self._resolve().get(*args, **kwargs)
|
||||||
|
|
||||||
|
def __getattr__(self, item):
|
||||||
|
return getattr(self._resolve(), item)
|
||||||
|
|
||||||
|
|
||||||
|
settings = _SettingsProxy()
|
||||||
|
|
||||||
class StatsRoutes:
|
class StatsRoutes:
|
||||||
"""Route handlers for Statistics page and API endpoints"""
|
"""Route handlers for Statistics page and API endpoints"""
|
||||||
|
|
||||||
@@ -33,7 +52,13 @@ class StatsRoutes:
|
|||||||
self.lora_scanner = await ServiceRegistry.get_lora_scanner()
|
self.lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
self.checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
self.checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
self.embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
self.embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
self.usage_stats = UsageStats()
|
|
||||||
|
# Only initialize usage stats if we have valid paths configured
|
||||||
|
try:
|
||||||
|
self.usage_stats = UsageStats()
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning(f"Could not initialize usage statistics: {e}")
|
||||||
|
self.usage_stats = None
|
||||||
|
|
||||||
async def handle_stats_page(self, request: web.Request) -> web.Response:
|
async def handle_stats_page(self, request: web.Request) -> web.Response:
|
||||||
"""Handle GET /statistics request"""
|
"""Handle GET /statistics request"""
|
||||||
@@ -60,7 +85,9 @@ class StatsRoutes:
|
|||||||
is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing
|
is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing
|
||||||
|
|
||||||
# 获取用户语言设置
|
# 获取用户语言设置
|
||||||
user_language = settings.get('language', 'en')
|
settings_object = settings
|
||||||
|
user_language = settings_object.get('language', 'en')
|
||||||
|
settings_manager = settings_object if not isinstance(settings_object, _SettingsProxy) else settings_object._resolve()
|
||||||
|
|
||||||
# 设置服务端i18n语言
|
# 设置服务端i18n语言
|
||||||
server_i18n.set_locale(user_language)
|
server_i18n.set_locale(user_language)
|
||||||
@@ -73,7 +100,7 @@ class StatsRoutes:
|
|||||||
template = self.template_env.get_template('statistics.html')
|
template = self.template_env.get_template('statistics.html')
|
||||||
rendered = template.render(
|
rendered = template.render(
|
||||||
is_initializing=is_initializing,
|
is_initializing=is_initializing,
|
||||||
settings=settings,
|
settings=settings_manager,
|
||||||
request=request,
|
request=request,
|
||||||
t=server_i18n.get_translation,
|
t=server_i18n.get_translation,
|
||||||
)
|
)
|
||||||
@@ -501,12 +528,12 @@ class StatsRoutes:
|
|||||||
app.router.add_get('/statistics', self.handle_stats_page)
|
app.router.add_get('/statistics', self.handle_stats_page)
|
||||||
|
|
||||||
# Register API routes
|
# Register API routes
|
||||||
app.router.add_get('/api/stats/collection-overview', self.get_collection_overview)
|
app.router.add_get('/api/lm/stats/collection-overview', self.get_collection_overview)
|
||||||
app.router.add_get('/api/stats/usage-analytics', self.get_usage_analytics)
|
app.router.add_get('/api/lm/stats/usage-analytics', self.get_usage_analytics)
|
||||||
app.router.add_get('/api/stats/base-model-distribution', self.get_base_model_distribution)
|
app.router.add_get('/api/lm/stats/base-model-distribution', self.get_base_model_distribution)
|
||||||
app.router.add_get('/api/stats/tag-analytics', self.get_tag_analytics)
|
app.router.add_get('/api/lm/stats/tag-analytics', self.get_tag_analytics)
|
||||||
app.router.add_get('/api/stats/storage-analytics', self.get_storage_analytics)
|
app.router.add_get('/api/lm/stats/storage-analytics', self.get_storage_analytics)
|
||||||
app.router.add_get('/api/stats/insights', self.get_insights)
|
app.router.add_get('/api/lm/stats/insights', self.get_insights)
|
||||||
|
|
||||||
async def _on_startup(self, app):
|
async def _on_startup(self, app):
|
||||||
"""Initialize services when the app starts"""
|
"""Initialize services when the app starts"""
|
||||||
|
|||||||
@@ -1,26 +1,31 @@
|
|||||||
import os
|
import os
|
||||||
import aiohttp
|
|
||||||
import logging
|
import logging
|
||||||
import toml
|
import toml
|
||||||
import git
|
import git
|
||||||
import zipfile
|
import zipfile
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from aiohttp import web
|
import asyncio
|
||||||
|
from aiohttp import web, ClientError
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from ..utils.settings_paths import ensure_settings_file
|
||||||
|
from ..services.downloader import get_downloader
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
||||||
|
|
||||||
|
|
||||||
class UpdateRoutes:
|
class UpdateRoutes:
|
||||||
"""Routes for handling plugin update checks"""
|
"""Routes for handling plugin update checks"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def setup_routes(app):
|
def setup_routes(app):
|
||||||
"""Register update check routes"""
|
"""Register update check routes"""
|
||||||
app.router.add_get('/api/check-updates', UpdateRoutes.check_updates)
|
app.router.add_get('/api/lm/check-updates', UpdateRoutes.check_updates)
|
||||||
app.router.add_get('/api/version-info', UpdateRoutes.get_version_info)
|
app.router.add_get('/api/lm/version-info', UpdateRoutes.get_version_info)
|
||||||
app.router.add_post('/api/perform-update', UpdateRoutes.perform_update)
|
app.router.add_post('/api/lm/perform-update', UpdateRoutes.perform_update)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def check_updates(request):
|
async def check_updates(request):
|
||||||
@@ -64,6 +69,12 @@ class UpdateRoutes:
|
|||||||
'nightly': nightly
|
'nightly': nightly
|
||||||
})
|
})
|
||||||
|
|
||||||
|
except NETWORK_EXCEPTIONS as e:
|
||||||
|
logger.warning("Network unavailable during update check: %s", e)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Network unavailable for update check'
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to check for updates: {e}", exc_info=True)
|
logger.error(f"Failed to check for updates: {e}", exc_info=True)
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
@@ -112,7 +123,7 @@ class UpdateRoutes:
|
|||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||||
|
|
||||||
settings_path = os.path.join(plugin_root, 'settings.json')
|
settings_path = ensure_settings_file(logger)
|
||||||
settings_backup = None
|
settings_backup = None
|
||||||
if os.path.exists(settings_path):
|
if os.path.exists(settings_path):
|
||||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||||
@@ -155,51 +166,66 @@ class UpdateRoutes:
|
|||||||
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
|
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Download latest release ZIP from GitHub and replace plugin files.
|
Download latest release ZIP from GitHub and replace plugin files.
|
||||||
Skips settings.json. Writes extracted file list to .tracking.
|
Skips settings.json and civitai folder. Writes extracted file list to .tracking.
|
||||||
"""
|
"""
|
||||||
repo_owner = "willmiao"
|
repo_owner = "willmiao"
|
||||||
repo_name = "ComfyUI-Lora-Manager"
|
repo_name = "ComfyUI-Lora-Manager"
|
||||||
github_api = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
|
github_api = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
downloader = await get_downloader()
|
||||||
async with session.get(github_api) as resp:
|
|
||||||
if resp.status != 200:
|
# Get release info
|
||||||
logger.error(f"Failed to fetch release info: {resp.status}")
|
success, data = await downloader.make_request(
|
||||||
return False, ""
|
'GET',
|
||||||
data = await resp.json()
|
github_api,
|
||||||
zip_url = data.get("zipball_url")
|
use_auth=False
|
||||||
version = data.get("tag_name", "unknown")
|
)
|
||||||
|
if not success:
|
||||||
|
logger.error(f"Failed to fetch release info: {data}")
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
zip_url = data.get("zipball_url")
|
||||||
|
version = data.get("tag_name", "unknown")
|
||||||
|
|
||||||
# Download ZIP
|
# Download ZIP to temporary file
|
||||||
async with session.get(zip_url) as zip_resp:
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_zip:
|
||||||
if zip_resp.status != 200:
|
tmp_zip_path = tmp_zip.name
|
||||||
logger.error(f"Failed to download ZIP: {zip_resp.status}")
|
|
||||||
return False, ""
|
success, result = await downloader.download_file(
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_zip:
|
url=zip_url,
|
||||||
tmp_zip.write(await zip_resp.read())
|
save_path=tmp_zip_path,
|
||||||
zip_path = tmp_zip.name
|
use_auth=False,
|
||||||
|
allow_resume=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
logger.error(f"Failed to download ZIP: {result}")
|
||||||
|
return False, ""
|
||||||
|
|
||||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json'])
|
zip_path = tmp_zip_path
|
||||||
|
|
||||||
# Extract ZIP to temp dir
|
# Skip both settings.json, civitai and model cache folder
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache'])
|
||||||
|
|
||||||
|
# Extract ZIP to temp dir
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||||
zip_ref.extractall(tmp_dir)
|
zip_ref.extractall(tmp_dir)
|
||||||
# Find extracted folder (GitHub ZIP contains a root folder)
|
# Find extracted folder (GitHub ZIP contains a root folder)
|
||||||
extracted_root = next(os.scandir(tmp_dir)).path
|
extracted_root = next(os.scandir(tmp_dir)).path
|
||||||
|
|
||||||
# Copy files, skipping settings.json
|
# Copy files, skipping settings.json and civitai folder
|
||||||
for item in os.listdir(extracted_root):
|
for item in os.listdir(extracted_root):
|
||||||
|
if item == 'settings.json' or item == 'civitai':
|
||||||
|
continue
|
||||||
src = os.path.join(extracted_root, item)
|
src = os.path.join(extracted_root, item)
|
||||||
dst = os.path.join(plugin_root, item)
|
dst = os.path.join(plugin_root, item)
|
||||||
if os.path.isdir(src):
|
if os.path.isdir(src):
|
||||||
if os.path.exists(dst):
|
if os.path.exists(dst):
|
||||||
shutil.rmtree(dst)
|
shutil.rmtree(dst)
|
||||||
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json'))
|
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json', 'civitai'))
|
||||||
else:
|
else:
|
||||||
if item == 'settings.json':
|
|
||||||
continue
|
|
||||||
shutil.copy2(src, dst)
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
# Write .tracking file: list all files under extracted_root, relative to extracted_root
|
# Write .tracking file: list all files under extracted_root, relative to extracted_root
|
||||||
@@ -207,15 +233,22 @@ class UpdateRoutes:
|
|||||||
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
||||||
tracking_files = []
|
tracking_files = []
|
||||||
for root, dirs, files in os.walk(extracted_root):
|
for root, dirs, files in os.walk(extracted_root):
|
||||||
|
# Skip civitai folder and its contents
|
||||||
|
rel_root = os.path.relpath(root, extracted_root)
|
||||||
|
if rel_root == 'civitai' or rel_root.startswith('civitai' + os.sep):
|
||||||
|
continue
|
||||||
for file in files:
|
for file in files:
|
||||||
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
|
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
|
||||||
|
# Skip settings.json and any file under civitai
|
||||||
|
if rel_path == 'settings.json' or rel_path.startswith('civitai' + os.sep):
|
||||||
|
continue
|
||||||
tracking_files.append(rel_path.replace("\\", "/"))
|
tracking_files.append(rel_path.replace("\\", "/"))
|
||||||
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
||||||
file.write('\n'.join(tracking_files))
|
file.write('\n'.join(tracking_files))
|
||||||
|
|
||||||
os.remove(zip_path)
|
os.remove(zip_path)
|
||||||
logger.info(f"Updated plugin via ZIP to {version}")
|
logger.info(f"Updated plugin via ZIP to {version}")
|
||||||
return True, version
|
return True, version
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"ZIP update failed: {e}", exc_info=True)
|
logger.error(f"ZIP update failed: {e}", exc_info=True)
|
||||||
@@ -244,24 +277,27 @@ class UpdateRoutes:
|
|||||||
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main"
|
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
downloader = await get_downloader()
|
||||||
async with session.get(github_url, headers={'Accept': 'application/vnd.github+json'}) as response:
|
success, data = await downloader.make_request('GET', github_url, custom_headers={'Accept': 'application/vnd.github+json'})
|
||||||
if response.status != 200:
|
|
||||||
logger.warning(f"Failed to fetch GitHub commit: {response.status}")
|
if not success:
|
||||||
return "main", []
|
logger.warning(f"Failed to fetch GitHub commit: {data}")
|
||||||
|
return "main", []
|
||||||
data = await response.json()
|
|
||||||
commit_sha = data.get('sha', '')[:7] # Short hash
|
commit_sha = data.get('sha', '')[:7] # Short hash
|
||||||
commit_message = data.get('commit', {}).get('message', '')
|
commit_message = data.get('commit', {}).get('message', '')
|
||||||
|
|
||||||
# Format as "main-{short_hash}"
|
# Format as "main-{short_hash}"
|
||||||
version = f"main-{commit_sha}"
|
version = f"main-{commit_sha}"
|
||||||
|
|
||||||
# Use commit message as changelog
|
# Use commit message as changelog
|
||||||
changelog = [commit_message] if commit_message else []
|
changelog = [commit_message] if commit_message else []
|
||||||
|
|
||||||
return version, changelog
|
return version, changelog
|
||||||
|
|
||||||
|
except NETWORK_EXCEPTIONS as e:
|
||||||
|
logger.warning("Unable to reach GitHub for nightly version: %s", e)
|
||||||
|
return "main", []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching nightly version: {e}", exc_info=True)
|
logger.error(f"Error fetching nightly version: {e}", exc_info=True)
|
||||||
return "main", []
|
return "main", []
|
||||||
@@ -308,6 +344,11 @@ class UpdateRoutes:
|
|||||||
origin.fetch()
|
origin.fetch()
|
||||||
|
|
||||||
if nightly:
|
if nightly:
|
||||||
|
# Reset to discard any local changes
|
||||||
|
repo.git.reset('--hard')
|
||||||
|
# Clean untracked files
|
||||||
|
repo.git.clean('-fd')
|
||||||
|
|
||||||
# Switch to main branch and pull latest
|
# Switch to main branch and pull latest
|
||||||
main_branch = 'main'
|
main_branch = 'main'
|
||||||
if main_branch not in [branch.name for branch in repo.branches]:
|
if main_branch not in [branch.name for branch in repo.branches]:
|
||||||
@@ -321,6 +362,11 @@ class UpdateRoutes:
|
|||||||
new_version = f"main-{repo.head.commit.hexsha[:7]}"
|
new_version = f"main-{repo.head.commit.hexsha[:7]}"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
# Reset to discard any local changes
|
||||||
|
repo.git.reset('--hard')
|
||||||
|
# Clean untracked files
|
||||||
|
repo.git.clean('-fd')
|
||||||
|
|
||||||
# Get latest release tag
|
# Get latest release tag
|
||||||
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
||||||
if not tags:
|
if not tags:
|
||||||
@@ -410,23 +456,26 @@ class UpdateRoutes:
|
|||||||
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
|
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
downloader = await get_downloader()
|
||||||
async with session.get(github_url, headers={'Accept': 'application/vnd.github+json'}) as response:
|
success, data = await downloader.make_request('GET', github_url, custom_headers={'Accept': 'application/vnd.github+json'})
|
||||||
if response.status != 200:
|
|
||||||
logger.warning(f"Failed to fetch GitHub release: {response.status}")
|
if not success:
|
||||||
return "v0.0.0", []
|
logger.warning(f"Failed to fetch GitHub release: {data}")
|
||||||
|
return "v0.0.0", []
|
||||||
data = await response.json()
|
|
||||||
version = data.get('tag_name', '')
|
version = data.get('tag_name', '')
|
||||||
if not version.startswith('v'):
|
if not version.startswith('v'):
|
||||||
version = f"v{version}"
|
version = f"v{version}"
|
||||||
|
|
||||||
# Extract changelog from release notes
|
# Extract changelog from release notes
|
||||||
body = data.get('body', '')
|
body = data.get('body', '')
|
||||||
changelog = UpdateRoutes._parse_changelog(body)
|
changelog = UpdateRoutes._parse_changelog(body)
|
||||||
|
|
||||||
return version, changelog
|
return version, changelog
|
||||||
|
|
||||||
|
except NETWORK_EXCEPTIONS as e:
|
||||||
|
logger.warning("Unable to reach GitHub for release info: %s", e)
|
||||||
|
return "v0.0.0", []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching remote version: {e}", exc_info=True)
|
logger.error(f"Error fetching remote version: {e}", exc_info=True)
|
||||||
return "v0.0.0", []
|
return "v0.0.0", []
|
||||||
|
|||||||
@@ -1,102 +1,137 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, List, Optional, Type
|
import asyncio
|
||||||
|
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from ..utils.constants import VALID_LORA_TYPES
|
||||||
from ..utils.models import BaseModelMetadata
|
from ..utils.models import BaseModelMetadata
|
||||||
from ..utils.routes_common import ModelRouteUtils
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from ..utils.constants import NSFW_LEVELS
|
from .model_query import (
|
||||||
from .settings_manager import settings
|
FilterCriteria,
|
||||||
from ..utils.utils import fuzzy_match
|
ModelCacheRepository,
|
||||||
|
ModelFilterSet,
|
||||||
|
SearchStrategy,
|
||||||
|
SettingsProvider,
|
||||||
|
normalize_civitai_model_type,
|
||||||
|
resolve_civitai_model_type,
|
||||||
|
)
|
||||||
|
from .settings_manager import get_settings_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .model_update_service import ModelUpdateService
|
||||||
|
|
||||||
class BaseModelService(ABC):
|
class BaseModelService(ABC):
|
||||||
"""Base service class for all model types"""
|
"""Base service class for all model types"""
|
||||||
|
|
||||||
def __init__(self, model_type: str, scanner, metadata_class: Type[BaseModelMetadata]):
|
def __init__(
|
||||||
"""Initialize the service
|
self,
|
||||||
|
model_type: str,
|
||||||
|
scanner,
|
||||||
|
metadata_class: Type[BaseModelMetadata],
|
||||||
|
*,
|
||||||
|
cache_repository: Optional[ModelCacheRepository] = None,
|
||||||
|
filter_set: Optional[ModelFilterSet] = None,
|
||||||
|
search_strategy: Optional[SearchStrategy] = None,
|
||||||
|
settings_provider: Optional[SettingsProvider] = None,
|
||||||
|
update_service: Optional["ModelUpdateService"] = None,
|
||||||
|
):
|
||||||
|
"""Initialize the service.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_type: Type of model (lora, checkpoint, etc.)
|
model_type: Type of model (lora, checkpoint, etc.).
|
||||||
scanner: Model scanner instance
|
scanner: Model scanner instance.
|
||||||
metadata_class: Metadata class for this model type
|
metadata_class: Metadata class for this model type.
|
||||||
|
cache_repository: Custom repository for cache access (primarily for tests).
|
||||||
|
filter_set: Filter component controlling folder/tag/favorites logic.
|
||||||
|
search_strategy: Search component for fuzzy/text matching.
|
||||||
|
settings_provider: Settings object; defaults to the global settings manager.
|
||||||
|
update_service: Service used to determine whether models have remote updates available.
|
||||||
"""
|
"""
|
||||||
self.model_type = model_type
|
self.model_type = model_type
|
||||||
self.scanner = scanner
|
self.scanner = scanner
|
||||||
self.metadata_class = metadata_class
|
self.metadata_class = metadata_class
|
||||||
|
self.settings = settings_provider or get_settings_manager()
|
||||||
|
self.cache_repository = cache_repository or ModelCacheRepository(scanner)
|
||||||
|
self.filter_set = filter_set or ModelFilterSet(self.settings)
|
||||||
|
self.search_strategy = search_strategy or SearchStrategy()
|
||||||
|
self.update_service = update_service
|
||||||
|
|
||||||
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'name',
|
async def get_paginated_data(
|
||||||
folder: str = None, search: str = None, fuzzy_search: bool = False,
|
self,
|
||||||
base_models: list = None, tags: list = None,
|
page: int,
|
||||||
search_options: dict = None, hash_filters: dict = None,
|
page_size: int,
|
||||||
favorites_only: bool = False, **kwargs) -> Dict:
|
sort_by: str = 'name',
|
||||||
"""Get paginated and filtered model data
|
folder: str = None,
|
||||||
|
search: str = None,
|
||||||
Args:
|
fuzzy_search: bool = False,
|
||||||
page: Page number (1-based)
|
base_models: list = None,
|
||||||
page_size: Number of items per page
|
model_types: list = None,
|
||||||
sort_by: Sort criteria, e.g. 'name', 'name:asc', 'name:desc', 'date', 'date:asc', 'date:desc'
|
tags: Optional[Dict[str, str]] = None,
|
||||||
folder: Folder filter
|
search_options: dict = None,
|
||||||
search: Search term
|
hash_filters: dict = None,
|
||||||
fuzzy_search: Whether to use fuzzy search
|
favorites_only: bool = False,
|
||||||
base_models: List of base models to filter by
|
update_available_only: bool = False,
|
||||||
tags: List of tags to filter by
|
credit_required: Optional[bool] = None,
|
||||||
search_options: Search options dict
|
allow_selling_generated_content: Optional[bool] = None,
|
||||||
hash_filters: Hash filtering options
|
**kwargs,
|
||||||
favorites_only: Filter for favorites only
|
) -> Dict:
|
||||||
**kwargs: Additional model-specific filters
|
"""Get paginated and filtered model data"""
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict containing paginated results
|
|
||||||
"""
|
|
||||||
cache = await self.scanner.get_cached_data()
|
|
||||||
|
|
||||||
# Parse sort_by into sort_key and order
|
sort_params = self.cache_repository.parse_sort(sort_by)
|
||||||
if ':' in sort_by:
|
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
||||||
sort_key, order = sort_by.split(':', 1)
|
|
||||||
sort_key = sort_key.strip()
|
|
||||||
order = order.strip().lower()
|
|
||||||
if order not in ('asc', 'desc'):
|
|
||||||
order = 'asc'
|
|
||||||
else:
|
|
||||||
sort_key = sort_by.strip()
|
|
||||||
order = 'asc'
|
|
||||||
|
|
||||||
# Get default search options if not provided
|
|
||||||
if search_options is None:
|
|
||||||
search_options = {
|
|
||||||
'filename': True,
|
|
||||||
'modelname': True,
|
|
||||||
'tags': False,
|
|
||||||
'recursive': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get the base data set using new sort logic
|
|
||||||
filtered_data = await cache.get_sorted_data(sort_key, order)
|
|
||||||
|
|
||||||
# Apply hash filtering if provided (highest priority)
|
|
||||||
if hash_filters:
|
if hash_filters:
|
||||||
filtered_data = await self._apply_hash_filters(filtered_data, hash_filters)
|
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
|
||||||
|
else:
|
||||||
# Jump to pagination for hash filters
|
filtered_data = await self._apply_common_filters(
|
||||||
return self._paginate(filtered_data, page, page_size)
|
sorted_data,
|
||||||
|
folder=folder,
|
||||||
# Apply common filters
|
base_models=base_models,
|
||||||
filtered_data = await self._apply_common_filters(
|
model_types=model_types,
|
||||||
filtered_data, folder, base_models, tags, favorites_only, search_options
|
tags=tags,
|
||||||
)
|
favorites_only=favorites_only,
|
||||||
|
search_options=search_options,
|
||||||
# Apply search filtering
|
|
||||||
if search:
|
|
||||||
filtered_data = await self._apply_search_filters(
|
|
||||||
filtered_data, search, fuzzy_search, search_options
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply model-specific filters
|
if search:
|
||||||
filtered_data = await self._apply_specific_filters(filtered_data, **kwargs)
|
filtered_data = await self._apply_search_filters(
|
||||||
|
filtered_data,
|
||||||
return self._paginate(filtered_data, page, page_size)
|
search,
|
||||||
|
fuzzy_search,
|
||||||
|
search_options,
|
||||||
|
)
|
||||||
|
|
||||||
|
filtered_data = await self._apply_specific_filters(filtered_data, **kwargs)
|
||||||
|
|
||||||
|
# Apply license-based filters
|
||||||
|
if credit_required is not None:
|
||||||
|
filtered_data = await self._apply_credit_required_filter(filtered_data, credit_required)
|
||||||
|
|
||||||
|
if allow_selling_generated_content is not None:
|
||||||
|
filtered_data = await self._apply_allow_selling_filter(filtered_data, allow_selling_generated_content)
|
||||||
|
|
||||||
|
annotated_for_filter: Optional[List[Dict]] = None
|
||||||
|
if update_available_only:
|
||||||
|
annotated_for_filter = await self._annotate_update_flags(filtered_data)
|
||||||
|
filtered_data = [
|
||||||
|
item for item in annotated_for_filter
|
||||||
|
if item.get('update_available')
|
||||||
|
]
|
||||||
|
|
||||||
|
paginated = self._paginate(filtered_data, page, page_size)
|
||||||
|
|
||||||
|
if update_available_only:
|
||||||
|
# Items already include update flags thanks to the pre-filter annotation.
|
||||||
|
paginated['items'] = list(paginated['items'])
|
||||||
|
else:
|
||||||
|
paginated['items'] = await self._annotate_update_flags(
|
||||||
|
paginated['items'],
|
||||||
|
)
|
||||||
|
return paginated
|
||||||
|
|
||||||
|
|
||||||
async def _apply_hash_filters(self, data: List[Dict], hash_filters: Dict) -> List[Dict]:
|
async def _apply_hash_filters(self, data: List[Dict], hash_filters: Dict) -> List[Dict]:
|
||||||
"""Apply hash-based filtering"""
|
"""Apply hash-based filtering"""
|
||||||
@@ -120,118 +155,293 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def _apply_common_filters(self, data: List[Dict], folder: str = None,
|
async def _apply_common_filters(
|
||||||
base_models: list = None, tags: list = None,
|
self,
|
||||||
favorites_only: bool = False, search_options: dict = None) -> List[Dict]:
|
data: List[Dict],
|
||||||
|
folder: str = None,
|
||||||
|
base_models: list = None,
|
||||||
|
model_types: list = None,
|
||||||
|
tags: Optional[Dict[str, str]] = None,
|
||||||
|
favorites_only: bool = False,
|
||||||
|
search_options: dict = None,
|
||||||
|
) -> List[Dict]:
|
||||||
"""Apply common filters that work across all model types"""
|
"""Apply common filters that work across all model types"""
|
||||||
# Apply SFW filtering if enabled in settings
|
normalized_options = self.search_strategy.normalize_options(search_options)
|
||||||
if settings.get('show_only_sfw', False):
|
criteria = FilterCriteria(
|
||||||
data = [
|
folder=folder,
|
||||||
item for item in data
|
base_models=base_models,
|
||||||
if not item.get('preview_nsfw_level') or item.get('preview_nsfw_level') < NSFW_LEVELS['R']
|
model_types=model_types,
|
||||||
]
|
tags=tags,
|
||||||
|
favorites_only=favorites_only,
|
||||||
# Apply favorites filtering if enabled
|
search_options=normalized_options,
|
||||||
if favorites_only:
|
)
|
||||||
data = [
|
return self.filter_set.apply(data, criteria)
|
||||||
item for item in data
|
|
||||||
if item.get('favorite', False) is True
|
|
||||||
]
|
|
||||||
|
|
||||||
# Apply folder filtering
|
|
||||||
if folder is not None:
|
|
||||||
if search_options and search_options.get('recursive', True):
|
|
||||||
# Recursive folder filtering - include all subfolders
|
|
||||||
# Ensure we match exact folder or its subfolders by checking path boundaries
|
|
||||||
if folder == "":
|
|
||||||
# Empty folder means root - include all items
|
|
||||||
pass # Don't filter anything
|
|
||||||
else:
|
|
||||||
# Add trailing slash to ensure we match folder boundaries correctly
|
|
||||||
folder_with_separator = folder + "/"
|
|
||||||
data = [
|
|
||||||
item for item in data
|
|
||||||
if (item['folder'] == folder or
|
|
||||||
item['folder'].startswith(folder_with_separator))
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
# Exact folder filtering
|
|
||||||
data = [
|
|
||||||
item for item in data
|
|
||||||
if item['folder'] == folder
|
|
||||||
]
|
|
||||||
|
|
||||||
# Apply base model filtering
|
|
||||||
if base_models and len(base_models) > 0:
|
|
||||||
data = [
|
|
||||||
item for item in data
|
|
||||||
if item.get('base_model') in base_models
|
|
||||||
]
|
|
||||||
|
|
||||||
# Apply tag filtering
|
|
||||||
if tags and len(tags) > 0:
|
|
||||||
data = [
|
|
||||||
item for item in data
|
|
||||||
if any(tag in item.get('tags', []) for tag in tags)
|
|
||||||
]
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def _apply_search_filters(self, data: List[Dict], search: str,
|
async def _apply_search_filters(
|
||||||
fuzzy_search: bool, search_options: dict) -> List[Dict]:
|
self,
|
||||||
|
data: List[Dict],
|
||||||
|
search: str,
|
||||||
|
fuzzy_search: bool,
|
||||||
|
search_options: dict,
|
||||||
|
) -> List[Dict]:
|
||||||
"""Apply search filtering"""
|
"""Apply search filtering"""
|
||||||
search_results = []
|
normalized_options = self.search_strategy.normalize_options(search_options)
|
||||||
|
return self.search_strategy.apply(data, search, normalized_options, fuzzy_search)
|
||||||
for item in data:
|
|
||||||
# Search by file name
|
|
||||||
if search_options.get('filename', True):
|
|
||||||
if fuzzy_search:
|
|
||||||
if fuzzy_match(item.get('file_name', ''), search):
|
|
||||||
search_results.append(item)
|
|
||||||
continue
|
|
||||||
elif search.lower() in item.get('file_name', '').lower():
|
|
||||||
search_results.append(item)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Search by model name
|
|
||||||
if search_options.get('modelname', True):
|
|
||||||
if fuzzy_search:
|
|
||||||
if fuzzy_match(item.get('model_name', ''), search):
|
|
||||||
search_results.append(item)
|
|
||||||
continue
|
|
||||||
elif search.lower() in item.get('model_name', '').lower():
|
|
||||||
search_results.append(item)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Search by tags
|
|
||||||
if search_options.get('tags', False) and 'tags' in item:
|
|
||||||
if any((fuzzy_match(tag, search) if fuzzy_search else search.lower() in tag.lower())
|
|
||||||
for tag in item['tags']):
|
|
||||||
search_results.append(item)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Search by creator
|
|
||||||
civitai = item.get('civitai')
|
|
||||||
creator_username = ''
|
|
||||||
if civitai and isinstance(civitai, dict):
|
|
||||||
creator = civitai.get('creator')
|
|
||||||
if creator and isinstance(creator, dict):
|
|
||||||
creator_username = creator.get('username', '')
|
|
||||||
if search_options.get('creator', False) and creator_username:
|
|
||||||
if fuzzy_search:
|
|
||||||
if fuzzy_match(creator_username, search):
|
|
||||||
search_results.append(item)
|
|
||||||
continue
|
|
||||||
elif search.lower() in creator_username.lower():
|
|
||||||
search_results.append(item)
|
|
||||||
continue
|
|
||||||
|
|
||||||
return search_results
|
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
"""Apply model-specific filters - to be overridden by subclasses if needed"""
|
"""Apply model-specific filters - to be overridden by subclasses if needed"""
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
async def _apply_credit_required_filter(self, data: List[Dict], credit_required: bool) -> List[Dict]:
|
||||||
|
"""Apply credit required filtering based on license_flags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: List of model data items
|
||||||
|
credit_required:
|
||||||
|
- True: Return items where credit is required (allowNoCredit=False)
|
||||||
|
- False: Return items where credit is not required (allowNoCredit=True)
|
||||||
|
"""
|
||||||
|
filtered_data = []
|
||||||
|
for item in data:
|
||||||
|
license_flags = item.get("license_flags", 127) # Default to all permissions enabled
|
||||||
|
|
||||||
|
# Bit 0 represents allowNoCredit (1 = no credit required, 0 = credit required)
|
||||||
|
allow_no_credit = bool(license_flags & (1 << 0))
|
||||||
|
|
||||||
|
# If credit_required is True, we want items where allowNoCredit is False (credit required)
|
||||||
|
# If credit_required is False, we want items where allowNoCredit is True (no credit required)
|
||||||
|
if credit_required:
|
||||||
|
if not allow_no_credit: # Credit is required
|
||||||
|
filtered_data.append(item)
|
||||||
|
else:
|
||||||
|
if allow_no_credit: # Credit is not required
|
||||||
|
filtered_data.append(item)
|
||||||
|
|
||||||
|
return filtered_data
|
||||||
|
|
||||||
|
async def _apply_allow_selling_filter(self, data: List[Dict], allow_selling: bool) -> List[Dict]:
|
||||||
|
"""Apply allow selling generated content filtering based on license_flags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: List of model data items
|
||||||
|
allow_selling:
|
||||||
|
- True: Return items where selling generated content is allowed (allowCommercialUse contains Image)
|
||||||
|
- False: Return items where selling generated content is not allowed (allowCommercialUse does not contain Image)
|
||||||
|
"""
|
||||||
|
filtered_data = []
|
||||||
|
for item in data:
|
||||||
|
license_flags = item.get("license_flags", 127) # Default to all permissions enabled
|
||||||
|
|
||||||
|
# Bits 1-4 represent commercial use permissions
|
||||||
|
# Bit 1 specifically represents Image permission (allowCommercialUse contains Image)
|
||||||
|
has_image_permission = bool(license_flags & (1 << 1))
|
||||||
|
|
||||||
|
# If allow_selling is True, we want items where Image permission is granted
|
||||||
|
# If allow_selling is False, we want items where Image permission is not granted
|
||||||
|
if allow_selling:
|
||||||
|
if has_image_permission: # Selling generated content is allowed
|
||||||
|
filtered_data.append(item)
|
||||||
|
else:
|
||||||
|
if not has_image_permission: # Selling generated content is not allowed
|
||||||
|
filtered_data.append(item)
|
||||||
|
|
||||||
|
return filtered_data
|
||||||
|
|
||||||
|
async def _annotate_update_flags(
|
||||||
|
self,
|
||||||
|
items: List[Dict],
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""Attach an update_available flag to each response item.
|
||||||
|
|
||||||
|
Items without a civitai model id default to False.
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
|
||||||
|
annotated = [dict(item) for item in items]
|
||||||
|
|
||||||
|
if self.update_service is None:
|
||||||
|
for item in annotated:
|
||||||
|
item['update_available'] = False
|
||||||
|
return annotated
|
||||||
|
|
||||||
|
id_to_items: Dict[int, List[Dict]] = {}
|
||||||
|
ordered_ids: List[int] = []
|
||||||
|
for item in annotated:
|
||||||
|
model_id = self._extract_model_id(item)
|
||||||
|
if model_id is None:
|
||||||
|
item['update_available'] = False
|
||||||
|
continue
|
||||||
|
if model_id not in id_to_items:
|
||||||
|
id_to_items[model_id] = []
|
||||||
|
ordered_ids.append(model_id)
|
||||||
|
id_to_items[model_id].append(item)
|
||||||
|
|
||||||
|
if not ordered_ids:
|
||||||
|
return annotated
|
||||||
|
|
||||||
|
strategy_value = self.settings.get("update_flag_strategy")
|
||||||
|
if isinstance(strategy_value, str) and strategy_value.strip():
|
||||||
|
strategy = strategy_value.strip().lower()
|
||||||
|
else:
|
||||||
|
strategy = "same_base"
|
||||||
|
same_base_mode = strategy == "same_base"
|
||||||
|
|
||||||
|
records = None
|
||||||
|
resolved: Optional[Dict[int, bool]] = None
|
||||||
|
if same_base_mode:
|
||||||
|
record_method = getattr(self.update_service, "get_records_bulk", None)
|
||||||
|
if callable(record_method):
|
||||||
|
try:
|
||||||
|
records = await record_method(self.model_type, ordered_ids)
|
||||||
|
resolved = {
|
||||||
|
model_id: record.has_update()
|
||||||
|
for model_id, record in records.items()
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to resolve update records in bulk for %s models (%s): %s",
|
||||||
|
self.model_type,
|
||||||
|
ordered_ids,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
records = None
|
||||||
|
resolved = None
|
||||||
|
|
||||||
|
if resolved is None:
|
||||||
|
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
|
||||||
|
if callable(bulk_method):
|
||||||
|
try:
|
||||||
|
resolved = await bulk_method(self.model_type, ordered_ids)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to resolve update status in bulk for %s models (%s): %s",
|
||||||
|
self.model_type,
|
||||||
|
ordered_ids,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
resolved = None
|
||||||
|
|
||||||
|
if resolved is None:
|
||||||
|
tasks = [
|
||||||
|
self.update_service.has_update(self.model_type, model_id)
|
||||||
|
for model_id in ordered_ids
|
||||||
|
]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
resolved = {}
|
||||||
|
for model_id, result in zip(ordered_ids, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
logger.error(
|
||||||
|
"Failed to resolve update status for model %s (%s): %s",
|
||||||
|
model_id,
|
||||||
|
self.model_type,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
resolved[model_id] = bool(result)
|
||||||
|
|
||||||
|
for model_id, items_for_id in id_to_items.items():
|
||||||
|
default_flag = bool(resolved.get(model_id, False)) if resolved else False
|
||||||
|
record = records.get(model_id) if records else None
|
||||||
|
base_highest_versions = (
|
||||||
|
self._build_highest_local_versions_by_base(record) if same_base_mode and record else {}
|
||||||
|
)
|
||||||
|
for item in items_for_id:
|
||||||
|
if same_base_mode and record is not None:
|
||||||
|
base_model = self._extract_base_model(item)
|
||||||
|
normalized_base = self._normalize_base_model_name(base_model)
|
||||||
|
threshold_version = base_highest_versions.get(normalized_base) if normalized_base else None
|
||||||
|
if threshold_version is None:
|
||||||
|
threshold_version = self._extract_version_id(item)
|
||||||
|
flag = record.has_update_for_base(
|
||||||
|
threshold_version,
|
||||||
|
base_model,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flag = default_flag
|
||||||
|
item['update_available'] = flag
|
||||||
|
|
||||||
|
return annotated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_model_id(item: Dict) -> Optional[int]:
|
||||||
|
civitai = item.get('civitai') if isinstance(item, dict) else None
|
||||||
|
if not isinstance(civitai, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = civitai.get('modelId')
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_version_id(item: Dict) -> Optional[int]:
|
||||||
|
civitai = item.get('civitai') if isinstance(item, dict) else None
|
||||||
|
if not isinstance(civitai, dict):
|
||||||
|
return None
|
||||||
|
value = civitai.get('id')
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_base_model(item: Dict) -> Optional[str]:
|
||||||
|
value = item.get('base_model')
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
candidate = value.strip()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
candidate = str(value).strip()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return candidate if candidate else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_base_model_name(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Return a lowercased, trimmed base model name for comparison."""
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
candidate = value.strip()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
candidate = str(value).strip()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return candidate.lower() if candidate else None
|
||||||
|
|
||||||
|
def _build_highest_local_versions_by_base(self, record) -> Dict[str, int]:
|
||||||
|
"""Return the highest local version id known for each normalized base model."""
|
||||||
|
|
||||||
|
if record is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
highest_by_base: Dict[str, int] = {}
|
||||||
|
for version in getattr(record, "versions", []):
|
||||||
|
if not getattr(version, "is_in_library", False):
|
||||||
|
continue
|
||||||
|
normalized_base = self._normalize_base_model_name(getattr(version, "base_model", None))
|
||||||
|
if normalized_base is None:
|
||||||
|
continue
|
||||||
|
version_id = getattr(version, "version_id", None)
|
||||||
|
if version_id is None:
|
||||||
|
continue
|
||||||
|
current_max = highest_by_base.get(normalized_base)
|
||||||
|
if current_max is None or version_id > current_max:
|
||||||
|
highest_by_base[normalized_base] = version_id
|
||||||
|
|
||||||
|
return highest_by_base
|
||||||
|
|
||||||
def _paginate(self, data: List[Dict], page: int, page_size: int) -> Dict:
|
def _paginate(self, data: List[Dict], page: int, page_size: int) -> Dict:
|
||||||
"""Apply pagination to filtered data"""
|
"""Apply pagination to filtered data"""
|
||||||
total_items = len(data)
|
total_items = len(data)
|
||||||
@@ -259,6 +469,25 @@ class BaseModelService(ABC):
|
|||||||
async def get_base_models(self, limit: int = 20) -> List[Dict]:
|
async def get_base_models(self, limit: int = 20) -> List[Dict]:
|
||||||
"""Get base models sorted by frequency"""
|
"""Get base models sorted by frequency"""
|
||||||
return await self.scanner.get_base_models(limit)
|
return await self.scanner.get_base_models(limit)
|
||||||
|
|
||||||
|
async def get_model_types(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
"""Get counts of normalized CivitAI model types present in the cache."""
|
||||||
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
type_counts: Dict[str, int] = {}
|
||||||
|
for entry in cache.raw_data:
|
||||||
|
normalized_type = normalize_civitai_model_type(resolve_civitai_model_type(entry))
|
||||||
|
if not normalized_type or normalized_type not in VALID_LORA_TYPES:
|
||||||
|
continue
|
||||||
|
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
||||||
|
|
||||||
|
sorted_types = sorted(
|
||||||
|
[{"type": model_type, "count": count} for model_type, count in type_counts.items()],
|
||||||
|
key=lambda value: value["count"],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return sorted_types[:limit]
|
||||||
|
|
||||||
def has_hash(self, sha256: str) -> bool:
|
def has_hash(self, sha256: str) -> bool:
|
||||||
"""Check if a model with given hash exists"""
|
"""Check if a model with given hash exists"""
|
||||||
@@ -284,6 +513,18 @@ class BaseModelService(ABC):
|
|||||||
"""Get model root directories"""
|
"""Get model root directories"""
|
||||||
return self.scanner.get_model_roots()
|
return self.scanner.get_model_roots()
|
||||||
|
|
||||||
|
def filter_civitai_data(self, data: Dict, minimal: bool = False) -> Dict:
|
||||||
|
"""Filter relevant fields from CivitAI data"""
|
||||||
|
if not data:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
fields = ["id", "modelId", "name", "trainedWords"] if minimal else [
|
||||||
|
"id", "modelId", "name", "createdAt", "updatedAt",
|
||||||
|
"publishedAt", "trainedWords", "baseModel", "description",
|
||||||
|
"model", "images", "customImages", "creator"
|
||||||
|
]
|
||||||
|
return {k: data[k] for k in fields if k in data}
|
||||||
|
|
||||||
async def get_folder_tree(self, model_root: str) -> Dict:
|
async def get_folder_tree(self, model_root: str) -> Dict:
|
||||||
"""Get hierarchical folder tree for a specific model root"""
|
"""Get hierarchical folder tree for a specific model root"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
@@ -363,7 +604,7 @@ class BaseModelService(ABC):
|
|||||||
from ..config import config
|
from ..config import config
|
||||||
return config.get_preview_static_url(preview_url)
|
return config.get_preview_static_url(preview_url)
|
||||||
|
|
||||||
return None
|
return '/loras_static/images/no-preview.png'
|
||||||
|
|
||||||
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
|
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
|
||||||
"""Get the Civitai URL for a model file"""
|
"""Get the Civitai URL for a model file"""
|
||||||
@@ -389,31 +630,73 @@ class BaseModelService(ABC):
|
|||||||
return {'civitai_url': None, 'model_id': None, 'version_id': None}
|
return {'civitai_url': None, 'model_id': None, 'version_id': None}
|
||||||
|
|
||||||
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
||||||
"""Get filtered CivitAI metadata for a model by file path"""
|
"""Load full metadata for a single model.
|
||||||
cache = await self.scanner.get_cached_data()
|
|
||||||
|
Listing/search endpoints return lightweight cache entries; this method performs
|
||||||
for model in cache.raw_data:
|
a lazy read of the on-disk metadata snapshot when callers need full detail.
|
||||||
if model.get('file_path') == file_path:
|
"""
|
||||||
return ModelRouteUtils.filter_civitai_data(model.get("civitai", {}))
|
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.metadata_class)
|
||||||
|
if should_skip or metadata is None:
|
||||||
return None
|
return None
|
||||||
|
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
||||||
|
|
||||||
|
|
||||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||||
"""Get model description by file path"""
|
"""Return the stored modelDescription field for a model."""
|
||||||
cache = await self.scanner.get_cached_data()
|
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.metadata_class)
|
||||||
|
if should_skip or metadata is None:
|
||||||
for model in cache.raw_data:
|
return None
|
||||||
if model.get('file_path') == file_path:
|
return metadata.modelDescription or ''
|
||||||
return model.get('modelDescription', '')
|
|
||||||
|
@staticmethod
|
||||||
return None
|
def _parse_search_tokens(search_term: str) -> tuple[List[str], List[str]]:
|
||||||
|
"""Split a search string into include and exclude tokens."""
|
||||||
|
include_terms: List[str] = []
|
||||||
|
exclude_terms: List[str] = []
|
||||||
|
|
||||||
|
for raw_term in search_term.split():
|
||||||
|
term = raw_term.strip()
|
||||||
|
if not term:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if term.startswith("-") and len(term) > 1:
|
||||||
|
exclude_terms.append(term[1:].lower())
|
||||||
|
else:
|
||||||
|
include_terms.append(term.lower())
|
||||||
|
|
||||||
|
return include_terms, exclude_terms
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _relative_path_matches_tokens(
|
||||||
|
path_lower: str, include_terms: List[str], exclude_terms: List[str]
|
||||||
|
) -> bool:
|
||||||
|
"""Determine whether a relative path string satisfies include/exclude tokens."""
|
||||||
|
if any(term and term in path_lower for term in exclude_terms):
|
||||||
|
return False
|
||||||
|
|
||||||
|
for term in include_terms:
|
||||||
|
if term and term not in path_lower:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple:
|
||||||
|
"""Sort paths by how well they satisfy the include tokens."""
|
||||||
|
path_lower = relative_path.lower()
|
||||||
|
prefix_hits = sum(1 for term in include_terms if term and path_lower.startswith(term))
|
||||||
|
match_positions = [path_lower.find(term) for term in include_terms if term and term in path_lower]
|
||||||
|
first_match_index = min(match_positions) if match_positions else 0
|
||||||
|
|
||||||
|
return (-prefix_hits, first_match_index, len(relative_path), path_lower)
|
||||||
|
|
||||||
|
|
||||||
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]:
|
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]:
|
||||||
"""Search model relative file paths for autocomplete functionality"""
|
"""Search model relative file paths for autocomplete functionality"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
include_terms, exclude_terms = self._parse_search_tokens(search_term)
|
||||||
|
|
||||||
matching_paths = []
|
matching_paths = []
|
||||||
search_lower = search_term.lower()
|
|
||||||
|
|
||||||
# Get model roots for path calculation
|
# Get model roots for path calculation
|
||||||
model_roots = self.scanner.get_model_roots()
|
model_roots = self.scanner.get_model_roots()
|
||||||
@@ -435,17 +718,19 @@ class BaseModelService(ABC):
|
|||||||
relative_path = normalized_file[len(normalized_root):].lstrip(os.sep)
|
relative_path = normalized_file[len(normalized_root):].lstrip(os.sep)
|
||||||
break
|
break
|
||||||
|
|
||||||
if relative_path and search_lower in relative_path.lower():
|
if not relative_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
relative_lower = relative_path.lower()
|
||||||
|
if self._relative_path_matches_tokens(relative_lower, include_terms, exclude_terms):
|
||||||
matching_paths.append(relative_path)
|
matching_paths.append(relative_path)
|
||||||
|
|
||||||
if len(matching_paths) >= limit * 2: # Get more for better sorting
|
if len(matching_paths) >= limit * 2: # Get more for better sorting
|
||||||
break
|
break
|
||||||
|
|
||||||
# Sort by relevance (exact matches first, then by length)
|
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically)
|
||||||
matching_paths.sort(key=lambda x: (
|
matching_paths.sort(
|
||||||
not x.lower().startswith(search_lower), # Exact prefix matches first
|
key=lambda relative: self._relative_path_sort_key(relative, include_terms)
|
||||||
len(x), # Then by length (shorter first)
|
)
|
||||||
x.lower() # Then alphabetically
|
|
||||||
))
|
|
||||||
|
|
||||||
return matching_paths[:limit]
|
return matching_paths[:limit]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from ..utils.models import CheckpointMetadata
|
from ..utils.models import CheckpointMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
@@ -21,14 +21,33 @@ class CheckpointScanner(ModelScanner):
|
|||||||
hash_index=ModelHashIndex()
|
hash_index=ModelHashIndex()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _resolve_model_type(self, root_path: Optional[str]) -> Optional[str]:
|
||||||
|
if not root_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if config.checkpoints_roots and root_path in config.checkpoints_roots:
|
||||||
|
return "checkpoint"
|
||||||
|
|
||||||
|
if config.unet_roots and root_path in config.unet_roots:
|
||||||
|
return "diffusion_model"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def adjust_metadata(self, metadata, file_path, root_path):
|
def adjust_metadata(self, metadata, file_path, root_path):
|
||||||
if hasattr(metadata, "model_type"):
|
if hasattr(metadata, "model_type"):
|
||||||
if root_path in config.checkpoints_roots:
|
model_type = self._resolve_model_type(root_path)
|
||||||
metadata.model_type = "checkpoint"
|
if model_type:
|
||||||
elif root_path in config.unet_roots:
|
metadata.model_type = model_type
|
||||||
metadata.model_type = "diffusion_model"
|
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
|
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
model_type = self._resolve_model_type(
|
||||||
|
self._find_root_for_file(entry.get("file_path"))
|
||||||
|
)
|
||||||
|
if model_type:
|
||||||
|
entry["model_type"] = model_type
|
||||||
|
return entry
|
||||||
|
|
||||||
def get_model_roots(self) -> List[str]:
|
def get_model_roots(self) -> List[str]:
|
||||||
"""Get checkpoint root directories"""
|
"""Get checkpoint root directories"""
|
||||||
return config.base_models_roots
|
return config.base_models_roots
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from ..utils.models import CheckpointMetadata
|
from ..utils.models import CheckpointMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.routes_common import ModelRouteUtils
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class CheckpointService(BaseModelService):
|
class CheckpointService(BaseModelService):
|
||||||
"""Checkpoint-specific service implementation"""
|
"""Checkpoint-specific service implementation"""
|
||||||
|
|
||||||
def __init__(self, scanner):
|
def __init__(self, scanner, update_service=None):
|
||||||
"""Initialize Checkpoint service
|
"""Initialize Checkpoint service
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
scanner: Checkpoint scanner instance
|
scanner: Checkpoint scanner instance
|
||||||
|
update_service: Optional service for remote update tracking.
|
||||||
"""
|
"""
|
||||||
super().__init__("checkpoint", scanner, CheckpointMetadata)
|
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, checkpoint_data: Dict) -> Dict:
|
async def format_response(self, checkpoint_data: Dict) -> Dict:
|
||||||
"""Format Checkpoint data for API response"""
|
"""Format Checkpoint data for API response"""
|
||||||
@@ -38,7 +38,8 @@ class CheckpointService(BaseModelService):
|
|||||||
"notes": checkpoint_data.get("notes", ""),
|
"notes": checkpoint_data.get("notes", ""),
|
||||||
"model_type": checkpoint_data.get("model_type", "checkpoint"),
|
"model_type": checkpoint_data.get("model_type", "checkpoint"),
|
||||||
"favorite": checkpoint_data.get("favorite", False),
|
"favorite": checkpoint_data.get("favorite", False),
|
||||||
"civitai": ModelRouteUtils.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||||
|
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
@@ -47,4 +48,4 @@ class CheckpointService(BaseModelService):
|
|||||||
|
|
||||||
def find_duplicate_filenames(self) -> Dict:
|
def find_duplicate_filenames(self) -> Dict:
|
||||||
"""Find Checkpoints with conflicting filenames"""
|
"""Find Checkpoints with conflicting filenames"""
|
||||||
return self.scanner._hash_index.get_duplicate_filenames()
|
return self.scanner._hash_index.get_duplicate_filenames()
|
||||||
|
|||||||
431
py/services/civarchive_client.py
Normal file
431
py/services/civarchive_client.py
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Optional, Dict, Tuple, List
|
||||||
|
from .model_metadata_provider import CivArchiveModelMetadataProvider, ModelMetadataProviderManager
|
||||||
|
from .downloader import get_downloader
|
||||||
|
from .errors import RateLimitError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class CivArchiveClient:
|
||||||
|
_instance = None
|
||||||
|
_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_instance(cls):
|
||||||
|
"""Get singleton instance of CivArchiveClient"""
|
||||||
|
async with cls._lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
|
||||||
|
# Register this client as a metadata provider
|
||||||
|
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||||
|
provider_manager.register_provider('civarchive', CivArchiveModelMetadataProvider(cls._instance), False)
|
||||||
|
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Check if already initialized for singleton pattern
|
||||||
|
if hasattr(self, '_initialized'):
|
||||||
|
return
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
self.base_url = "https://civarchive.com/api"
|
||||||
|
|
||||||
|
async def _request_json(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
params: Optional[Dict[str, str]] = None
|
||||||
|
) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
"""Call CivArchive API and return JSON payload"""
|
||||||
|
success, payload = await self._make_request(path, params=params)
|
||||||
|
if not success:
|
||||||
|
error = payload if isinstance(payload, str) else "Request failed"
|
||||||
|
return None, error
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None, "Invalid response structure"
|
||||||
|
return payload, None
|
||||||
|
|
||||||
|
async def _make_request(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: Optional[Dict[str, str]] = None,
|
||||||
|
) -> Tuple[bool, Dict | str]:
|
||||||
|
"""Wrapper around downloader.make_request that surfaces rate limits."""
|
||||||
|
|
||||||
|
downloader = await get_downloader()
|
||||||
|
kwargs: Dict[str, Dict[str, str]] = {}
|
||||||
|
if params:
|
||||||
|
safe_params = {str(key): str(value) for key, value in params.items() if value is not None}
|
||||||
|
if safe_params:
|
||||||
|
kwargs["params"] = safe_params
|
||||||
|
|
||||||
|
success, payload = await downloader.make_request(
|
||||||
|
"GET",
|
||||||
|
f"{self.base_url}{path}",
|
||||||
|
use_auth=False,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
if not success and isinstance(payload, RateLimitError):
|
||||||
|
if payload.provider is None:
|
||||||
|
payload.provider = "civarchive_api"
|
||||||
|
raise payload
|
||||||
|
return success, payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_payload(payload: Dict) -> Dict:
|
||||||
|
"""Unwrap CivArchive responses that wrap content under a data key"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {}
|
||||||
|
data = payload.get("data")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
return payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_context(payload: Dict) -> Tuple[Dict, Dict, List[Dict]]:
|
||||||
|
"""Separate version payload from surrounding model context"""
|
||||||
|
data = CivArchiveClient._normalize_payload(payload)
|
||||||
|
context: Dict = {}
|
||||||
|
fallback_files: List[Dict] = []
|
||||||
|
version: Dict = {}
|
||||||
|
|
||||||
|
for key, value in data.items():
|
||||||
|
if key in {"version", "model"}:
|
||||||
|
continue
|
||||||
|
context[key] = value
|
||||||
|
|
||||||
|
if isinstance(data.get("version"), dict):
|
||||||
|
version = data["version"]
|
||||||
|
|
||||||
|
model_block = data.get("model")
|
||||||
|
if isinstance(model_block, dict):
|
||||||
|
for key, value in model_block.items():
|
||||||
|
if key == "version":
|
||||||
|
if not version and isinstance(value, dict):
|
||||||
|
version = value
|
||||||
|
continue
|
||||||
|
context.setdefault(key, value)
|
||||||
|
fallback_files = fallback_files or model_block.get("files") or []
|
||||||
|
|
||||||
|
fallback_files = fallback_files or data.get("files") or []
|
||||||
|
return context, version, fallback_files
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_list(value) -> List:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
return [value]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_model_info(context: Dict) -> Dict:
|
||||||
|
tags = context.get("tags")
|
||||||
|
if not isinstance(tags, list):
|
||||||
|
tags = list(tags) if isinstance(tags, (set, tuple)) else ([] if tags is None else [tags])
|
||||||
|
return {
|
||||||
|
"name": context.get("name"),
|
||||||
|
"type": context.get("type"),
|
||||||
|
"nsfw": bool(context.get("is_nsfw", context.get("nsfw", False))),
|
||||||
|
"description": context.get("description"),
|
||||||
|
"tags": tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_creator_info(context: Dict) -> Dict:
|
||||||
|
username = context.get("creator_username") or context.get("username") or ""
|
||||||
|
image = context.get("creator_image") or context.get("creator_avatar") or ""
|
||||||
|
creator: Dict[str, Optional[str]] = {
|
||||||
|
"username": username,
|
||||||
|
"image": image,
|
||||||
|
}
|
||||||
|
if context.get("creator_name"):
|
||||||
|
creator["name"] = context["creator_name"]
|
||||||
|
if context.get("creator_url"):
|
||||||
|
creator["url"] = context["creator_url"]
|
||||||
|
return creator
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _transform_file_entry(file_data: Dict) -> Dict:
|
||||||
|
mirrors = file_data.get("mirrors") or []
|
||||||
|
if not isinstance(mirrors, list):
|
||||||
|
mirrors = [mirrors]
|
||||||
|
available_mirror = next(
|
||||||
|
(mirror for mirror in mirrors if isinstance(mirror, dict) and mirror.get("deletedAt") is None),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
download_url = file_data.get("downloadUrl")
|
||||||
|
if not download_url and available_mirror:
|
||||||
|
download_url = available_mirror.get("url")
|
||||||
|
name = file_data.get("name")
|
||||||
|
if not name and available_mirror:
|
||||||
|
name = available_mirror.get("filename")
|
||||||
|
|
||||||
|
transformed: Dict = {
|
||||||
|
"id": file_data.get("id"),
|
||||||
|
"sizeKB": file_data.get("sizeKB"),
|
||||||
|
"name": name,
|
||||||
|
"type": file_data.get("type"),
|
||||||
|
"downloadUrl": download_url,
|
||||||
|
"primary": True,
|
||||||
|
# TODO: for some reason is_primary is false in CivArchive response, need to figure this out,
|
||||||
|
# "primary": bool(file_data.get("is_primary", file_data.get("primary", False))),
|
||||||
|
"mirrors": mirrors,
|
||||||
|
}
|
||||||
|
|
||||||
|
sha256 = file_data.get("sha256")
|
||||||
|
if sha256:
|
||||||
|
transformed["hashes"] = {"SHA256": str(sha256).upper()}
|
||||||
|
elif isinstance(file_data.get("hashes"), dict):
|
||||||
|
transformed["hashes"] = file_data["hashes"]
|
||||||
|
|
||||||
|
if "metadata" in file_data:
|
||||||
|
transformed["metadata"] = file_data["metadata"]
|
||||||
|
|
||||||
|
if file_data.get("modelVersionId") is not None:
|
||||||
|
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
||||||
|
elif file_data.get("model_version_id") is not None:
|
||||||
|
transformed["modelVersionId"] = file_data.get("model_version_id")
|
||||||
|
|
||||||
|
if file_data.get("modelId") is not None:
|
||||||
|
transformed["modelId"] = file_data.get("modelId")
|
||||||
|
elif file_data.get("model_id") is not None:
|
||||||
|
transformed["modelId"] = file_data.get("model_id")
|
||||||
|
|
||||||
|
return transformed
|
||||||
|
|
||||||
|
def _transform_files(
|
||||||
|
self,
|
||||||
|
files: Optional[List[Dict]],
|
||||||
|
fallback_files: Optional[List[Dict]] = None
|
||||||
|
) -> List[Dict]:
|
||||||
|
candidates: List[Dict] = []
|
||||||
|
if isinstance(files, list) and files:
|
||||||
|
candidates = files
|
||||||
|
elif isinstance(fallback_files, list):
|
||||||
|
candidates = fallback_files
|
||||||
|
|
||||||
|
transformed_files: List[Dict] = []
|
||||||
|
for file_data in candidates:
|
||||||
|
if isinstance(file_data, dict):
|
||||||
|
transformed_files.append(self._transform_file_entry(file_data))
|
||||||
|
return transformed_files
|
||||||
|
|
||||||
|
def _transform_version(
|
||||||
|
self,
|
||||||
|
context: Dict,
|
||||||
|
version: Dict,
|
||||||
|
fallback_files: Optional[List[Dict]] = None
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
if not version:
|
||||||
|
return None
|
||||||
|
|
||||||
|
version_copy = deepcopy(version)
|
||||||
|
version_copy.pop("model", None)
|
||||||
|
version_copy.pop("creator", None)
|
||||||
|
|
||||||
|
if "trigger" in version_copy:
|
||||||
|
triggers = version_copy.pop("trigger")
|
||||||
|
if isinstance(triggers, list):
|
||||||
|
version_copy["trainedWords"] = triggers
|
||||||
|
elif triggers is None:
|
||||||
|
version_copy["trainedWords"] = []
|
||||||
|
else:
|
||||||
|
version_copy["trainedWords"] = [triggers]
|
||||||
|
|
||||||
|
if "trainedWords" in version_copy and isinstance(version_copy["trainedWords"], str):
|
||||||
|
version_copy["trainedWords"] = [version_copy["trainedWords"]]
|
||||||
|
|
||||||
|
if "nsfw_level" in version_copy:
|
||||||
|
version_copy["nsfwLevel"] = version_copy.pop("nsfw_level")
|
||||||
|
elif "nsfwLevel" not in version_copy and context.get("nsfw_level") is not None:
|
||||||
|
version_copy["nsfwLevel"] = context.get("nsfw_level")
|
||||||
|
|
||||||
|
stats_keys = ["downloadCount", "ratingCount", "rating"]
|
||||||
|
stats = {key: version_copy.pop(key) for key in stats_keys if key in version_copy}
|
||||||
|
if stats:
|
||||||
|
version_copy["stats"] = stats
|
||||||
|
|
||||||
|
version_copy["files"] = self._transform_files(version_copy.get("files"), fallback_files)
|
||||||
|
version_copy["images"] = self._ensure_list(version_copy.get("images"))
|
||||||
|
|
||||||
|
version_copy["model"] = self._build_model_info(context)
|
||||||
|
version_copy["creator"] = self._build_creator_info(context)
|
||||||
|
|
||||||
|
version_copy["source"] = "civarchive"
|
||||||
|
version_copy["is_deleted"] = bool(context.get("deletedAt")) or bool(version.get("deletedAt"))
|
||||||
|
|
||||||
|
return version_copy
|
||||||
|
|
||||||
|
async def _resolve_version_from_files(self, payload: Dict) -> Optional[Dict]:
|
||||||
|
"""Fallback to fetch version data when only file metadata is available"""
|
||||||
|
data = self._normalize_payload(payload)
|
||||||
|
files = data.get("files") or payload.get("files") or []
|
||||||
|
if not isinstance(files, list):
|
||||||
|
files = [files]
|
||||||
|
for file_data in files:
|
||||||
|
if not isinstance(file_data, dict):
|
||||||
|
continue
|
||||||
|
model_id = file_data.get("model_id") or file_data.get("modelId")
|
||||||
|
version_id = file_data.get("model_version_id") or file_data.get("modelVersionId")
|
||||||
|
if model_id is None or version_id is None:
|
||||||
|
continue
|
||||||
|
resolved = await self.get_model_version(model_id, version_id)
|
||||||
|
if resolved:
|
||||||
|
return resolved
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
"""Find model by SHA256 hash value using CivArchive API"""
|
||||||
|
try:
|
||||||
|
payload, error = await self._request_json(f"/sha256/{model_hash.lower()}")
|
||||||
|
if error:
|
||||||
|
if "not found" in error.lower():
|
||||||
|
return None, "Model not found"
|
||||||
|
return None, error
|
||||||
|
|
||||||
|
context, version_data, fallback_files = self._split_context(payload)
|
||||||
|
transformed = self._transform_version(context, version_data, fallback_files)
|
||||||
|
if transformed:
|
||||||
|
return transformed, None
|
||||||
|
|
||||||
|
resolved = await self._resolve_version_from_files(payload)
|
||||||
|
if resolved:
|
||||||
|
return resolved, None
|
||||||
|
|
||||||
|
logger.error("Error fetching version of CivArchive model by hash %s", model_hash[:10])
|
||||||
|
return None, "No version data found"
|
||||||
|
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching CivArchive model by hash {model_hash[:10]}: {e}")
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||||
|
"""Get all versions of a model using CivArchive API"""
|
||||||
|
try:
|
||||||
|
payload, error = await self._request_json(f"/models/{model_id}")
|
||||||
|
if error or payload is None:
|
||||||
|
if error and "not found" in error.lower():
|
||||||
|
return None
|
||||||
|
logger.error(f"Error fetching CivArchive model versions for {model_id}: {error}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = self._normalize_payload(payload)
|
||||||
|
context, version_data, fallback_files = self._split_context(payload)
|
||||||
|
|
||||||
|
versions_meta = data.get("versions") or []
|
||||||
|
transformed_versions: List[Dict] = []
|
||||||
|
for meta in versions_meta:
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
continue
|
||||||
|
version_id = meta.get("id")
|
||||||
|
if version_id is None:
|
||||||
|
continue
|
||||||
|
target_model_id = meta.get("modelId") or model_id
|
||||||
|
version = await self.get_model_version(target_model_id, version_id)
|
||||||
|
if version:
|
||||||
|
transformed_versions.append(version)
|
||||||
|
|
||||||
|
# Ensure the primary version is included even if versions list was empty
|
||||||
|
primary_version = self._transform_version(context, version_data, fallback_files)
|
||||||
|
if primary_version:
|
||||||
|
transformed_versions.insert(0, primary_version)
|
||||||
|
|
||||||
|
ordered_versions: List[Dict] = []
|
||||||
|
seen_ids = set()
|
||||||
|
for version in transformed_versions:
|
||||||
|
version_id = version.get("id")
|
||||||
|
if version_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(version_id)
|
||||||
|
ordered_versions.append(version)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"modelVersions": ordered_versions,
|
||||||
|
"type": context.get("type", ""),
|
||||||
|
"name": context.get("name", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching CivArchive model versions for {model_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
|
"""Get specific model version using CivArchive API
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_id: The model ID (required)
|
||||||
|
version_id: Optional specific version ID to filter to
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[Dict]: The model version data or None if not found
|
||||||
|
"""
|
||||||
|
if model_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = {"modelVersionId": version_id} if version_id is not None else None
|
||||||
|
payload, error = await self._request_json(f"/models/{model_id}", params=params)
|
||||||
|
if error or payload is None:
|
||||||
|
if error and "not found" in error.lower():
|
||||||
|
return None
|
||||||
|
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {error}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
context, version_data, fallback_files = self._split_context(payload)
|
||||||
|
|
||||||
|
if not version_data:
|
||||||
|
return await self._resolve_version_from_files(payload)
|
||||||
|
|
||||||
|
if version_id is not None:
|
||||||
|
raw_id = version_data.get("id")
|
||||||
|
if raw_id != version_id:
|
||||||
|
logger.warning(
|
||||||
|
"Requested version %s doesn't match default version %s for model %s",
|
||||||
|
version_id,
|
||||||
|
raw_id,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
actual_model_id = version_data.get("modelId")
|
||||||
|
context_model_id = context.get("id")
|
||||||
|
# CivArchive can respond with data for a different model id while already
|
||||||
|
# returning the fully resolved model context. Only follow the redirect when
|
||||||
|
# the context itself still points to the original (wrong) model.
|
||||||
|
if (
|
||||||
|
actual_model_id is not None
|
||||||
|
and str(actual_model_id) != str(model_id)
|
||||||
|
and (context_model_id is None or str(context_model_id) != str(actual_model_id))
|
||||||
|
):
|
||||||
|
return await self.get_model_version(actual_model_id, version_id)
|
||||||
|
|
||||||
|
return self._transform_version(context, version_data, fallback_files)
|
||||||
|
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
""" Fetch model version metadata using a known bogus model lookup
|
||||||
|
CivArchive lacks a direct version lookup API, this uses a workaround (which we handle in the main model request now)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
version_id: The model version ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[Optional[Dict], Optional[str]]: (version_data, error_message)
|
||||||
|
"""
|
||||||
|
version = await self.get_model_version(1, version_id)
|
||||||
|
if version is None:
|
||||||
|
return None, "Model not found"
|
||||||
|
return version, None
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
from datetime import datetime
|
|
||||||
import aiohttp
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from email.parser import Parser
|
import copy
|
||||||
from typing import Optional, Dict, Tuple, List
|
import logging
|
||||||
from urllib.parse import unquote
|
import os
|
||||||
|
from typing import Any, Optional, Dict, Tuple, List, Sequence
|
||||||
|
from .model_metadata_provider import CivitaiModelMetadataProvider, ModelMetadataProviderManager
|
||||||
|
from .downloader import get_downloader
|
||||||
|
from .errors import RateLimitError, ResourceNotFoundError
|
||||||
|
from ..utils.civitai_utils import resolve_license_payload
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -19,6 +20,11 @@ class CivitaiClient:
|
|||||||
async with cls._lock:
|
async with cls._lock:
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = cls()
|
cls._instance = cls()
|
||||||
|
|
||||||
|
# Register this client as a metadata provider
|
||||||
|
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||||
|
provider_manager.register_provider('civitai', CivitaiModelMetadataProvider(cls._instance), True)
|
||||||
|
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -28,80 +34,49 @@ class CivitaiClient:
|
|||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
|
||||||
self.base_url = "https://civitai.com/api/v1"
|
self.base_url = "https://civitai.com/api/v1"
|
||||||
self.headers = {
|
|
||||||
'User-Agent': 'ComfyUI-LoRA-Manager/1.0'
|
async def _make_request(
|
||||||
}
|
self,
|
||||||
self._session = None
|
method: str,
|
||||||
self._session_created_at = None
|
url: str,
|
||||||
# Adjust chunk size based on storage type - consider making this configurable
|
*,
|
||||||
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better HDD throughput
|
use_auth: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> Tuple[bool, Dict | str]:
|
||||||
|
"""Wrapper around downloader.make_request that surfaces rate limits."""
|
||||||
|
|
||||||
|
downloader = await get_downloader()
|
||||||
|
success, result = await downloader.make_request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
use_auth=use_auth,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
if not success and isinstance(result, RateLimitError):
|
||||||
|
if result.provider is None:
|
||||||
|
result.provider = "civitai_api"
|
||||||
|
raise result
|
||||||
|
return success, result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
|
||||||
|
"""Remove Comfy-specific metadata from model version images."""
|
||||||
|
if not isinstance(model_version, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
images = model_version.get("images")
|
||||||
|
if not isinstance(images, list):
|
||||||
|
return
|
||||||
|
|
||||||
|
for image in images:
|
||||||
|
if not isinstance(image, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
meta = image.get("meta")
|
||||||
|
if isinstance(meta, dict) and "comfy" in meta:
|
||||||
|
meta.pop("comfy", None)
|
||||||
|
|
||||||
@property
|
async def download_file(self, url: str, save_dir: str, default_filename: str, progress_callback=None) -> Tuple[bool, str]:
|
||||||
async def session(self) -> aiohttp.ClientSession:
|
|
||||||
"""Lazy initialize the session"""
|
|
||||||
if self._session is None:
|
|
||||||
# Optimize TCP connection parameters
|
|
||||||
connector = aiohttp.TCPConnector(
|
|
||||||
ssl=True,
|
|
||||||
limit=8, # Increase from 3 to 8 for better parallelism
|
|
||||||
ttl_dns_cache=300, # Enable DNS caching with reasonable timeout
|
|
||||||
force_close=False, # Keep connections for reuse
|
|
||||||
enable_cleanup_closed=True
|
|
||||||
)
|
|
||||||
trust_env = True # Allow using system environment proxy settings
|
|
||||||
# Configure timeout parameters - increase read timeout for large files and remove sock_read timeout
|
|
||||||
timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=None)
|
|
||||||
self._session = aiohttp.ClientSession(
|
|
||||||
connector=connector,
|
|
||||||
trust_env=trust_env,
|
|
||||||
timeout=timeout
|
|
||||||
)
|
|
||||||
self._session_created_at = datetime.now()
|
|
||||||
return self._session
|
|
||||||
|
|
||||||
async def _ensure_fresh_session(self):
|
|
||||||
"""Refresh session if it's been open too long"""
|
|
||||||
if self._session is not None:
|
|
||||||
if not hasattr(self, '_session_created_at') or \
|
|
||||||
(datetime.now() - self._session_created_at).total_seconds() > 300: # 5 minutes
|
|
||||||
await self.close()
|
|
||||||
self._session = None
|
|
||||||
|
|
||||||
return await self.session
|
|
||||||
|
|
||||||
def _parse_content_disposition(self, header: str) -> str:
|
|
||||||
"""Parse filename from content-disposition header"""
|
|
||||||
if not header:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Handle quoted filenames
|
|
||||||
if 'filename="' in header:
|
|
||||||
start = header.index('filename="') + 10
|
|
||||||
end = header.index('"', start)
|
|
||||||
return unquote(header[start:end])
|
|
||||||
|
|
||||||
# Fallback to original parsing
|
|
||||||
disposition = Parser().parsestr(f'Content-Disposition: {header}')
|
|
||||||
filename = disposition.get_param('filename')
|
|
||||||
if filename:
|
|
||||||
return unquote(filename)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_request_headers(self) -> dict:
|
|
||||||
"""Get request headers with optional API key"""
|
|
||||||
headers = {
|
|
||||||
'User-Agent': 'ComfyUI-LoRA-Manager/1.0',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
|
|
||||||
from .settings_manager import settings
|
|
||||||
api_key = settings.get('civitai_api_key')
|
|
||||||
if (api_key):
|
|
||||||
headers['Authorization'] = f'Bearer {api_key}'
|
|
||||||
|
|
||||||
return headers
|
|
||||||
|
|
||||||
async def _download_file(self, url: str, save_dir: str, default_filename: str, progress_callback=None) -> Tuple[bool, str]:
|
|
||||||
"""Download file with resumable downloads and retry mechanism
|
"""Download file with resumable downloads and retry mechanism
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -113,302 +88,335 @@ class CivitaiClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple[bool, str]: (success, save_path or error message)
|
Tuple[bool, str]: (success, save_path or error message)
|
||||||
"""
|
"""
|
||||||
max_retries = 5
|
downloader = await get_downloader()
|
||||||
retry_count = 0
|
|
||||||
base_delay = 2.0 # Base delay for exponential backoff
|
|
||||||
|
|
||||||
# Initial setup
|
|
||||||
session = await self._ensure_fresh_session()
|
|
||||||
save_path = os.path.join(save_dir, default_filename)
|
save_path = os.path.join(save_dir, default_filename)
|
||||||
part_path = save_path + '.part'
|
|
||||||
|
|
||||||
# Get existing file size for resume
|
# Use unified downloader with CivitAI authentication
|
||||||
resume_offset = 0
|
success, result = await downloader.download_file(
|
||||||
if os.path.exists(part_path):
|
url=url,
|
||||||
resume_offset = os.path.getsize(part_path)
|
save_path=save_path,
|
||||||
logger.info(f"Resuming download from offset {resume_offset} bytes")
|
progress_callback=progress_callback,
|
||||||
|
use_auth=True, # Enable CivitAI authentication
|
||||||
|
allow_resume=True
|
||||||
|
)
|
||||||
|
|
||||||
total_size = 0
|
return success, result
|
||||||
filename = default_filename
|
|
||||||
|
|
||||||
while retry_count <= max_retries:
|
|
||||||
try:
|
|
||||||
headers = self._get_request_headers()
|
|
||||||
|
|
||||||
# Add Range header for resume if we have partial data
|
|
||||||
if resume_offset > 0:
|
|
||||||
headers['Range'] = f'bytes={resume_offset}-'
|
|
||||||
|
|
||||||
# Add Range header to allow resumable downloads
|
|
||||||
headers['Accept-Encoding'] = 'identity' # Disable compression for better chunked downloads
|
|
||||||
|
|
||||||
logger.debug(f"Download attempt {retry_count + 1}/{max_retries + 1} from: {url}")
|
|
||||||
if resume_offset > 0:
|
|
||||||
logger.debug(f"Requesting range from byte {resume_offset}")
|
|
||||||
|
|
||||||
async with session.get(url, headers=headers, allow_redirects=True) as response:
|
|
||||||
# Handle different response codes
|
|
||||||
if response.status == 200:
|
|
||||||
# Full content response
|
|
||||||
if resume_offset > 0:
|
|
||||||
# Server doesn't support ranges, restart from beginning
|
|
||||||
logger.warning("Server doesn't support range requests, restarting download")
|
|
||||||
resume_offset = 0
|
|
||||||
if os.path.exists(part_path):
|
|
||||||
os.remove(part_path)
|
|
||||||
elif response.status == 206:
|
|
||||||
# Partial content response (resume successful)
|
|
||||||
content_range = response.headers.get('Content-Range')
|
|
||||||
if content_range:
|
|
||||||
# Parse total size from Content-Range header (e.g., "bytes 1024-2047/2048")
|
|
||||||
range_parts = content_range.split('/')
|
|
||||||
if len(range_parts) == 2:
|
|
||||||
total_size = int(range_parts[1])
|
|
||||||
logger.info(f"Successfully resumed download from byte {resume_offset}")
|
|
||||||
elif response.status == 416:
|
|
||||||
# Range not satisfiable - file might be complete or corrupted
|
|
||||||
if os.path.exists(part_path):
|
|
||||||
part_size = os.path.getsize(part_path)
|
|
||||||
logger.warning(f"Range not satisfiable. Part file size: {part_size}")
|
|
||||||
# Try to get actual file size
|
|
||||||
head_response = await session.head(url, headers=self._get_request_headers())
|
|
||||||
if head_response.status == 200:
|
|
||||||
actual_size = int(head_response.headers.get('content-length', 0))
|
|
||||||
if part_size == actual_size:
|
|
||||||
# File is complete, just rename it
|
|
||||||
os.rename(part_path, save_path)
|
|
||||||
if progress_callback:
|
|
||||||
await progress_callback(100)
|
|
||||||
return True, save_path
|
|
||||||
# Remove corrupted part file and restart
|
|
||||||
os.remove(part_path)
|
|
||||||
resume_offset = 0
|
|
||||||
continue
|
|
||||||
elif response.status == 401:
|
|
||||||
logger.warning(f"Unauthorized access to resource: {url} (Status 401)")
|
|
||||||
return False, "Invalid or missing CivitAI API key, or early access restriction."
|
|
||||||
elif response.status == 403:
|
|
||||||
logger.warning(f"Forbidden access to resource: {url} (Status 403)")
|
|
||||||
return False, "Access forbidden: You don't have permission to download this file."
|
|
||||||
else:
|
|
||||||
logger.error(f"Download failed for {url} with status {response.status}")
|
|
||||||
return False, f"Download failed with status {response.status}"
|
|
||||||
|
|
||||||
# Get total file size for progress calculation (if not set from Content-Range)
|
|
||||||
if total_size == 0:
|
|
||||||
total_size = int(response.headers.get('content-length', 0))
|
|
||||||
if response.status == 206:
|
|
||||||
# For partial content, add the offset to get total file size
|
|
||||||
total_size += resume_offset
|
|
||||||
|
|
||||||
current_size = resume_offset
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
last_progress_report_time = datetime.now()
|
|
||||||
|
|
||||||
# Stream download to file with progress updates using larger buffer
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
mode = 'ab' if resume_offset > 0 else 'wb'
|
|
||||||
with open(part_path, mode) as f:
|
|
||||||
async for chunk in response.content.iter_chunked(self.chunk_size):
|
|
||||||
if chunk:
|
|
||||||
# Run blocking file write in executor
|
|
||||||
await loop.run_in_executor(None, f.write, chunk)
|
|
||||||
current_size += len(chunk)
|
|
||||||
|
|
||||||
# Limit progress update frequency to reduce overhead
|
|
||||||
now = datetime.now()
|
|
||||||
time_diff = (now - last_progress_report_time).total_seconds()
|
|
||||||
|
|
||||||
if progress_callback and total_size and time_diff >= 1.0:
|
|
||||||
progress = (current_size / total_size) * 100
|
|
||||||
await progress_callback(progress)
|
|
||||||
last_progress_report_time = now
|
|
||||||
|
|
||||||
# Download completed successfully
|
|
||||||
# Verify file size if total_size was provided
|
|
||||||
final_size = os.path.getsize(part_path)
|
|
||||||
if total_size > 0 and final_size != total_size:
|
|
||||||
logger.warning(f"File size mismatch. Expected: {total_size}, Got: {final_size}")
|
|
||||||
# Don't treat this as fatal error, rename anyway
|
|
||||||
|
|
||||||
# Atomically rename .part to final file with retries
|
|
||||||
max_rename_attempts = 5
|
|
||||||
rename_attempt = 0
|
|
||||||
rename_success = False
|
|
||||||
|
|
||||||
while rename_attempt < max_rename_attempts and not rename_success:
|
|
||||||
try:
|
|
||||||
os.rename(part_path, save_path)
|
|
||||||
rename_success = True
|
|
||||||
except PermissionError as e:
|
|
||||||
rename_attempt += 1
|
|
||||||
if rename_attempt < max_rename_attempts:
|
|
||||||
logger.info(f"File still in use, retrying rename in 2 seconds (attempt {rename_attempt}/{max_rename_attempts})")
|
|
||||||
await asyncio.sleep(2) # Wait before retrying
|
|
||||||
else:
|
|
||||||
logger.error(f"Failed to rename file after {max_rename_attempts} attempts: {e}")
|
|
||||||
return False, f"Failed to finalize download: {str(e)}"
|
|
||||||
|
|
||||||
# Ensure 100% progress is reported
|
|
||||||
if progress_callback:
|
|
||||||
await progress_callback(100)
|
|
||||||
|
|
||||||
return True, save_path
|
|
||||||
|
|
||||||
except (aiohttp.ClientError, aiohttp.ClientPayloadError,
|
|
||||||
aiohttp.ServerDisconnectedError, asyncio.TimeoutError) as e:
|
|
||||||
retry_count += 1
|
|
||||||
logger.warning(f"Network error during download (attempt {retry_count}/{max_retries + 1}): {e}")
|
|
||||||
|
|
||||||
if retry_count <= max_retries:
|
|
||||||
# Calculate delay with exponential backoff
|
|
||||||
delay = base_delay * (2 ** (retry_count - 1))
|
|
||||||
logger.info(f"Retrying in {delay} seconds...")
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
|
|
||||||
# Update resume offset for next attempt
|
|
||||||
if os.path.exists(part_path):
|
|
||||||
resume_offset = os.path.getsize(part_path)
|
|
||||||
logger.info(f"Will resume from byte {resume_offset}")
|
|
||||||
|
|
||||||
# Refresh session to get new connection
|
|
||||||
await self.close()
|
|
||||||
session = await self._ensure_fresh_session()
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
logger.error(f"Max retries exceeded for download: {e}")
|
|
||||||
return False, f"Network error after {max_retries + 1} attempts: {str(e)}"
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Unexpected download error: {e}")
|
|
||||||
return False, str(e)
|
|
||||||
|
|
||||||
return False, f"Download failed after {max_retries + 1} attempts"
|
|
||||||
|
|
||||||
async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
|
|
||||||
try:
|
try:
|
||||||
session = await self._ensure_fresh_session()
|
success, version = await self._make_request(
|
||||||
async with session.get(f"{self.base_url}/model-versions/by-hash/{model_hash}") as response:
|
'GET',
|
||||||
if response.status == 200:
|
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||||
return await response.json()
|
use_auth=True
|
||||||
return None
|
)
|
||||||
except Exception as e:
|
if not success:
|
||||||
logger.error(f"API Error: {str(e)}")
|
message = str(version)
|
||||||
return None
|
if "not found" in message.lower():
|
||||||
|
return None, "Model not found"
|
||||||
|
|
||||||
|
logger.error("Failed to fetch model info for %s: %s", model_hash[:10], message)
|
||||||
|
return None, message
|
||||||
|
|
||||||
|
model_id = version.get('modelId')
|
||||||
|
if model_id:
|
||||||
|
model_data = await self._fetch_model_data(model_id)
|
||||||
|
if model_data:
|
||||||
|
self._enrich_version_with_model_data(version, model_data)
|
||||||
|
|
||||||
|
self._remove_comfy_metadata(version)
|
||||||
|
return version, None
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("API Error: %s", exc)
|
||||||
|
return None, str(exc)
|
||||||
|
|
||||||
async def download_preview_image(self, image_url: str, save_path: str):
|
async def download_preview_image(self, image_url: str, save_path: str):
|
||||||
try:
|
try:
|
||||||
session = await self._ensure_fresh_session()
|
downloader = await get_downloader()
|
||||||
async with session.get(image_url) as response:
|
success, content, headers = await downloader.download_to_memory(
|
||||||
if response.status == 200:
|
image_url,
|
||||||
content = await response.read()
|
use_auth=False # Preview images don't need auth
|
||||||
with open(save_path, 'wb') as f:
|
)
|
||||||
f.write(content)
|
if success:
|
||||||
return True
|
# Ensure directory exists
|
||||||
return False
|
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||||
|
with open(save_path, 'wb') as f:
|
||||||
|
f.write(content)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Download Error: {str(e)}")
|
logger.error(f"Download Error: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str) -> List[Dict]:
|
@staticmethod
|
||||||
|
def _extract_error_message(payload: Any) -> str:
|
||||||
|
"""Return a human-readable error message from an API payload."""
|
||||||
|
|
||||||
|
def _from_value(value: Any) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key in ("message", "error", "detail", "details"):
|
||||||
|
if key in value:
|
||||||
|
candidate = _from_value(value[key])
|
||||||
|
if candidate:
|
||||||
|
return candidate
|
||||||
|
if isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
candidate = _from_value(item)
|
||||||
|
if candidate:
|
||||||
|
return candidate
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return _from_value(payload)
|
||||||
|
|
||||||
|
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||||
"""Get all versions of a model with local availability info"""
|
"""Get all versions of a model with local availability info"""
|
||||||
try:
|
try:
|
||||||
session = await self._ensure_fresh_session() # Use fresh session
|
success, result = await self._make_request(
|
||||||
async with session.get(f"{self.base_url}/models/{model_id}") as response:
|
'GET',
|
||||||
if response.status != 200:
|
f"{self.base_url}/models/{model_id}",
|
||||||
return None
|
use_auth=True
|
||||||
data = await response.json()
|
)
|
||||||
|
if success:
|
||||||
# Also return model type along with versions
|
# Also return model type along with versions
|
||||||
return {
|
return {
|
||||||
'modelVersions': data.get('modelVersions', []),
|
'modelVersions': result.get('modelVersions', []),
|
||||||
'type': data.get('type', '')
|
'type': result.get('type', ''),
|
||||||
|
'name': result.get('name', '')
|
||||||
}
|
}
|
||||||
|
message = self._extract_error_message(result)
|
||||||
|
if message and 'not found' in message.lower():
|
||||||
|
raise ResourceNotFoundError(f"Resource not found for model {model_id}")
|
||||||
|
if message:
|
||||||
|
raise RuntimeError(message)
|
||||||
|
return None
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
except ResourceNotFoundError as exc:
|
||||||
|
logger.info("Model %s is no longer available on Civitai: %s", model_id, exc)
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching model versions: {e}")
|
logger.error("Error fetching model versions: %s", e, exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_model_versions_bulk(
|
||||||
|
self, model_ids: Sequence[int]
|
||||||
|
) -> Optional[Dict[int, Dict]]:
|
||||||
|
"""Fetch model metadata for multiple ids using the batch API."""
|
||||||
|
|
||||||
|
deduped: Dict[int, None] = {}
|
||||||
|
for raw_id in model_ids:
|
||||||
|
try:
|
||||||
|
normalized = int(raw_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
deduped.setdefault(normalized, None)
|
||||||
|
|
||||||
|
normalized_ids = [str(model_id) for model_id in deduped.keys()]
|
||||||
|
if not normalized_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
query = ",".join(normalized_ids)
|
||||||
|
success, result = await self._make_request(
|
||||||
|
'GET',
|
||||||
|
f"{self.base_url}/models",
|
||||||
|
use_auth=True,
|
||||||
|
params={'ids': query},
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
return None
|
||||||
|
|
||||||
|
items = result.get('items') if isinstance(result, dict) else None
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
payload: Dict[int, Dict] = {}
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
model_id = item.get('id')
|
||||||
|
try:
|
||||||
|
normalized_id = int(model_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
payload[normalized_id] = {
|
||||||
|
'modelVersions': item.get('modelVersions', []),
|
||||||
|
'type': item.get('type', ''),
|
||||||
|
'name': item.get('name', ''),
|
||||||
|
'allowNoCredit': item.get('allowNoCredit'),
|
||||||
|
'allowCommercialUse': item.get('allowCommercialUse'),
|
||||||
|
'allowDerivatives': item.get('allowDerivatives'),
|
||||||
|
'allowDifferentLicense': item.get('allowDifferentLicense'),
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Error fetching model versions in bulk: {exc}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
"""Get specific model version with additional metadata
|
"""Get specific model version with additional metadata."""
|
||||||
|
|
||||||
Args:
|
|
||||||
model_id: The Civitai model ID (optional if version_id is provided)
|
|
||||||
version_id: Optional specific version ID to retrieve
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Optional[Dict]: The model version data with additional fields or None if not found
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
session = await self._ensure_fresh_session()
|
|
||||||
headers = self._get_request_headers()
|
|
||||||
|
|
||||||
# Case 1: Only version_id is provided
|
|
||||||
if model_id is None and version_id is not None:
|
if model_id is None and version_id is not None:
|
||||||
# First get the version info to extract model_id
|
return await self._get_version_by_id_only(version_id)
|
||||||
async with session.get(f"{self.base_url}/model-versions/{version_id}", headers=headers) as response:
|
|
||||||
if response.status != 200:
|
if model_id is not None:
|
||||||
return None
|
return await self._get_version_with_model_id(model_id, version_id)
|
||||||
|
|
||||||
version = await response.json()
|
logger.error("Either model_id or version_id must be provided")
|
||||||
model_id = version.get('modelId')
|
return None
|
||||||
|
|
||||||
if not model_id:
|
except RateLimitError:
|
||||||
logger.error(f"No modelId found in version {version_id}")
|
raise
|
||||||
return None
|
|
||||||
|
|
||||||
# Now get the model data for additional metadata
|
|
||||||
async with session.get(f"{self.base_url}/models/{model_id}") as response:
|
|
||||||
if response.status != 200:
|
|
||||||
return version # Return version without additional metadata
|
|
||||||
|
|
||||||
model_data = await response.json()
|
|
||||||
|
|
||||||
# Enrich version with model data
|
|
||||||
version['model']['description'] = model_data.get("description")
|
|
||||||
version['model']['tags'] = model_data.get("tags", [])
|
|
||||||
version['creator'] = model_data.get("creator")
|
|
||||||
|
|
||||||
return version
|
|
||||||
|
|
||||||
# Case 2: model_id is provided (with or without version_id)
|
|
||||||
elif model_id is not None:
|
|
||||||
# Step 1: Get model data to find version_id if not provided and get additional metadata
|
|
||||||
async with session.get(f"{self.base_url}/models/{model_id}") as response:
|
|
||||||
if response.status != 200:
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = await response.json()
|
|
||||||
model_versions = data.get('modelVersions', [])
|
|
||||||
|
|
||||||
# Step 2: Determine the version_id to use
|
|
||||||
target_version_id = version_id
|
|
||||||
if target_version_id is None:
|
|
||||||
target_version_id = model_versions[0].get('id')
|
|
||||||
|
|
||||||
# Step 3: Get detailed version info using the version_id
|
|
||||||
async with session.get(f"{self.base_url}/model-versions/{target_version_id}", headers=headers) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
return None
|
|
||||||
|
|
||||||
version = await response.json()
|
|
||||||
|
|
||||||
# Step 4: Enrich version_info with model data
|
|
||||||
# Add description and tags from model data
|
|
||||||
version['model']['description'] = data.get("description")
|
|
||||||
version['model']['tags'] = data.get("tags", [])
|
|
||||||
|
|
||||||
# Add creator from model data
|
|
||||||
version['creator'] = data.get("creator")
|
|
||||||
|
|
||||||
return version
|
|
||||||
|
|
||||||
# Case 3: Neither model_id nor version_id provided
|
|
||||||
else:
|
|
||||||
logger.error("Either model_id or version_id must be provided")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching model version: {e}")
|
logger.error(f"Error fetching model version: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def _get_version_by_id_only(self, version_id: int) -> Optional[Dict]:
|
||||||
|
version = await self._fetch_version_by_id(version_id)
|
||||||
|
if version is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
model_id = version.get('modelId')
|
||||||
|
if not model_id:
|
||||||
|
logger.error(f"No modelId found in version {version_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
model_data = await self._fetch_model_data(model_id)
|
||||||
|
if model_data:
|
||||||
|
self._enrich_version_with_model_data(version, model_data)
|
||||||
|
|
||||||
|
self._remove_comfy_metadata(version)
|
||||||
|
return version
|
||||||
|
|
||||||
|
async def _get_version_with_model_id(self, model_id: int, version_id: Optional[int]) -> Optional[Dict]:
|
||||||
|
model_data = await self._fetch_model_data(model_id)
|
||||||
|
if not model_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
target_version = self._select_target_version(model_data, model_id, version_id)
|
||||||
|
if target_version is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
target_version_id = target_version.get('id')
|
||||||
|
version = await self._fetch_version_by_id(target_version_id) if target_version_id else None
|
||||||
|
|
||||||
|
if version is None:
|
||||||
|
model_hash = self._extract_primary_model_hash(target_version)
|
||||||
|
if model_hash:
|
||||||
|
version = await self._fetch_version_by_hash(model_hash)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"No primary model hash found for model {model_id} version {target_version_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if version is None:
|
||||||
|
version = self._build_version_from_model_data(target_version, model_id, model_data)
|
||||||
|
|
||||||
|
self._enrich_version_with_model_data(version, model_data)
|
||||||
|
self._remove_comfy_metadata(version)
|
||||||
|
return version
|
||||||
|
|
||||||
|
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
|
||||||
|
success, data = await self._make_request(
|
||||||
|
'GET',
|
||||||
|
f"{self.base_url}/models/{model_id}",
|
||||||
|
use_auth=True
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
return data
|
||||||
|
logger.warning(f"Failed to fetch model data for model {model_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _fetch_version_by_id(self, version_id: Optional[int]) -> Optional[Dict]:
|
||||||
|
if version_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
success, version = await self._make_request(
|
||||||
|
'GET',
|
||||||
|
f"{self.base_url}/model-versions/{version_id}",
|
||||||
|
use_auth=True
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
return version
|
||||||
|
|
||||||
|
logger.warning(f"Failed to fetch version by id {version_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict]:
|
||||||
|
if not model_hash:
|
||||||
|
return None
|
||||||
|
|
||||||
|
success, version = await self._make_request(
|
||||||
|
'GET',
|
||||||
|
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||||
|
use_auth=True
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
return version
|
||||||
|
|
||||||
|
logger.warning(f"Failed to fetch version by hash {model_hash}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _select_target_version(self, model_data: Dict, model_id: int, version_id: Optional[int]) -> Optional[Dict]:
|
||||||
|
model_versions = model_data.get('modelVersions', [])
|
||||||
|
if not model_versions:
|
||||||
|
logger.warning(f"No model versions found for model {model_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if version_id is not None:
|
||||||
|
target_version = next(
|
||||||
|
(item for item in model_versions if item.get('id') == version_id),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
if target_version is None:
|
||||||
|
logger.warning(
|
||||||
|
f"Version {version_id} not found for model {model_id}, defaulting to first version"
|
||||||
|
)
|
||||||
|
return model_versions[0]
|
||||||
|
return target_version
|
||||||
|
|
||||||
|
return model_versions[0]
|
||||||
|
|
||||||
|
def _extract_primary_model_hash(self, version_entry: Dict) -> Optional[str]:
|
||||||
|
for file_info in version_entry.get('files', []):
|
||||||
|
if file_info.get('type') == 'Model' and file_info.get('primary'):
|
||||||
|
hashes = file_info.get('hashes', {})
|
||||||
|
model_hash = hashes.get('SHA256')
|
||||||
|
if model_hash:
|
||||||
|
return model_hash
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_version_from_model_data(self, version_entry: Dict, model_id: int, model_data: Dict) -> Dict:
|
||||||
|
version = copy.deepcopy(version_entry)
|
||||||
|
version.pop('index', None)
|
||||||
|
version['modelId'] = model_id
|
||||||
|
version['model'] = {
|
||||||
|
'name': model_data.get('name'),
|
||||||
|
'type': model_data.get('type'),
|
||||||
|
'nsfw': model_data.get('nsfw'),
|
||||||
|
'poi': model_data.get('poi')
|
||||||
|
}
|
||||||
|
return version
|
||||||
|
|
||||||
|
def _enrich_version_with_model_data(self, version: Dict, model_data: Dict) -> None:
|
||||||
|
model_info = version.get('model')
|
||||||
|
if not isinstance(model_info, dict):
|
||||||
|
model_info = {}
|
||||||
|
version['model'] = model_info
|
||||||
|
|
||||||
|
model_info['description'] = model_data.get("description")
|
||||||
|
model_info['tags'] = model_data.get("tags", [])
|
||||||
|
version['creator'] = model_data.get("creator")
|
||||||
|
|
||||||
|
license_payload = resolve_license_payload(model_data)
|
||||||
|
for field, value in license_payload.items():
|
||||||
|
model_info[field] = value
|
||||||
|
|
||||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
"""Fetch model version metadata from Civitai
|
"""Fetch model version metadata from Civitai
|
||||||
|
|
||||||
@@ -421,119 +429,39 @@ class CivitaiClient:
|
|||||||
- An error message if there was an error, or None on success
|
- An error message if there was an error, or None on success
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
session = await self._ensure_fresh_session()
|
|
||||||
url = f"{self.base_url}/model-versions/{version_id}"
|
url = f"{self.base_url}/model-versions/{version_id}"
|
||||||
headers = self._get_request_headers()
|
|
||||||
|
|
||||||
logger.debug(f"Resolving DNS for model version info: {url}")
|
logger.debug(f"Resolving DNS for model version info: {url}")
|
||||||
async with session.get(url, headers=headers) as response:
|
success, result = await self._make_request(
|
||||||
if response.status == 200:
|
'GET',
|
||||||
logger.debug(f"Successfully fetched model version info for: {version_id}")
|
url,
|
||||||
return await response.json(), None
|
use_auth=True
|
||||||
|
)
|
||||||
# Handle specific error cases
|
|
||||||
if response.status == 404:
|
if success:
|
||||||
# Try to parse the error message
|
logger.debug(f"Successfully fetched model version info for: {version_id}")
|
||||||
try:
|
self._remove_comfy_metadata(result)
|
||||||
error_data = await response.json()
|
return result, None
|
||||||
error_msg = error_data.get('error', f"Model not found (status 404)")
|
|
||||||
logger.warning(f"Model version not found: {version_id} - {error_msg}")
|
# Handle specific error cases
|
||||||
return None, error_msg
|
if "not found" in str(result):
|
||||||
except:
|
error_msg = f"Model not found"
|
||||||
return None, "Model not found (status 404)"
|
logger.warning(f"Model version not found: {version_id} - {error_msg}")
|
||||||
|
return None, error_msg
|
||||||
# Other error cases
|
|
||||||
logger.error(f"Failed to fetch model info for {version_id} (status {response.status})")
|
# Other error cases
|
||||||
return None, f"Failed to fetch model info (status {response.status})"
|
logger.error(f"Failed to fetch model info for {version_id}: {result}")
|
||||||
|
return None, str(result)
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error fetching model version info: {e}"
|
error_msg = f"Error fetching model version info: {e}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return None, error_msg
|
return None, error_msg
|
||||||
|
|
||||||
async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]:
|
|
||||||
"""Fetch model metadata (description, tags, and creator info) from Civitai API
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model_id: The Civitai model ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple[Optional[Dict], int]: A tuple containing:
|
|
||||||
- A dictionary with model metadata or None if not found
|
|
||||||
- The HTTP status code from the request
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
session = await self._ensure_fresh_session()
|
|
||||||
headers = self._get_request_headers()
|
|
||||||
url = f"{self.base_url}/models/{model_id}"
|
|
||||||
|
|
||||||
async with session.get(url, headers=headers) as response:
|
|
||||||
status_code = response.status
|
|
||||||
|
|
||||||
if status_code != 200:
|
|
||||||
logger.warning(f"Failed to fetch model metadata: Status {status_code}")
|
|
||||||
return None, status_code
|
|
||||||
|
|
||||||
data = await response.json()
|
|
||||||
|
|
||||||
# Extract relevant metadata
|
|
||||||
metadata = {
|
|
||||||
"description": data.get("description") or "No model description available",
|
|
||||||
"tags": data.get("tags", []),
|
|
||||||
"creator": {
|
|
||||||
"username": data.get("creator", {}).get("username"),
|
|
||||||
"image": data.get("creator", {}).get("image")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata["description"] or metadata["tags"] or metadata["creator"]["username"]:
|
|
||||||
return metadata, status_code
|
|
||||||
else:
|
|
||||||
logger.warning(f"No metadata found for model {model_id}")
|
|
||||||
return None, status_code
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching model metadata: {e}", exc_info=True)
|
|
||||||
return None, 0
|
|
||||||
|
|
||||||
# Keep old method for backward compatibility, delegating to the new one
|
|
||||||
async def get_model_description(self, model_id: str) -> Optional[str]:
|
|
||||||
"""Fetch the model description from Civitai API (Legacy method)"""
|
|
||||||
metadata, _ = await self.get_model_metadata(model_id)
|
|
||||||
return metadata.get("description") if metadata else None
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
"""Close the session if it exists"""
|
|
||||||
if self._session is not None:
|
|
||||||
await self._session.close()
|
|
||||||
self._session = None
|
|
||||||
|
|
||||||
async def _get_hash_from_civitai(self, model_version_id: str) -> Optional[str]:
|
|
||||||
"""Get hash from Civitai API"""
|
|
||||||
try:
|
|
||||||
session = await self._ensure_fresh_session()
|
|
||||||
if not session:
|
|
||||||
return None
|
|
||||||
|
|
||||||
version_info = await session.get(f"{self.base_url}/model-versions/{model_version_id}")
|
|
||||||
|
|
||||||
if not version_info or not version_info.json().get('files'):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Get hash from the first file
|
|
||||||
for file_info in version_info.json().get('files', []):
|
|
||||||
if file_info.get('hashes', {}).get('SHA256'):
|
|
||||||
# Convert hash to lowercase to standardize
|
|
||||||
hash_value = file_info['hashes']['SHA256'].lower()
|
|
||||||
return hash_value
|
|
||||||
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting hash from Civitai: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_image_info(self, image_id: str) -> Optional[Dict]:
|
async def get_image_info(self, image_id: str) -> Optional[Dict]:
|
||||||
"""Fetch image information from Civitai API
|
"""Fetch image information from Civitai API
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_id: The Civitai image ID
|
image_id: The Civitai image ID
|
||||||
|
|
||||||
@@ -541,23 +469,62 @@ class CivitaiClient:
|
|||||||
Optional[Dict]: The image data or None if not found
|
Optional[Dict]: The image data or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
session = await self._ensure_fresh_session()
|
|
||||||
headers = self._get_request_headers()
|
|
||||||
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||||
|
|
||||||
logger.debug(f"Fetching image info for ID: {image_id}")
|
logger.debug(f"Fetching image info for ID: {image_id}")
|
||||||
async with session.get(url, headers=headers) as response:
|
success, result = await self._make_request(
|
||||||
if response.status == 200:
|
'GET',
|
||||||
data = await response.json()
|
url,
|
||||||
if data and "items" in data and len(data["items"]) > 0:
|
use_auth=True
|
||||||
logger.debug(f"Successfully fetched image info for ID: {image_id}")
|
)
|
||||||
return data["items"][0]
|
|
||||||
logger.warning(f"No image found with ID: {image_id}")
|
if success:
|
||||||
return None
|
if result and "items" in result and len(result["items"]) > 0:
|
||||||
|
logger.debug(f"Successfully fetched image info for ID: {image_id}")
|
||||||
logger.error(f"Failed to fetch image info for ID: {image_id} (status {response.status})")
|
return result["items"][0]
|
||||||
|
logger.warning(f"No image found with ID: {image_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
logger.error(f"Failed to fetch image info for ID: {image_id}: {result}")
|
||||||
|
return None
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error fetching image info: {e}"
|
error_msg = f"Error fetching image info: {e}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||||
|
"""Fetch all models for a specific Civitai user."""
|
||||||
|
if not username:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{self.base_url}/models?username={username}"
|
||||||
|
success, result = await self._make_request(
|
||||||
|
'GET',
|
||||||
|
url,
|
||||||
|
use_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
logger.error("Failed to fetch models for %s: %s", username, result)
|
||||||
|
return None
|
||||||
|
|
||||||
|
items = result.get("items") if isinstance(result, dict) else None
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
for model in items:
|
||||||
|
versions = model.get("modelVersions")
|
||||||
|
if not isinstance(versions, list):
|
||||||
|
continue
|
||||||
|
for version in versions:
|
||||||
|
self._remove_comfy_metadata(version)
|
||||||
|
|
||||||
|
return items
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Error fetching models for %s: %s", username, exc)
|
||||||
|
return None
|
||||||
|
|||||||
178
py/services/download_coordinator.py
Normal file
178
py/services/download_coordinator.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""Service wrapper for coordinating download lifecycle events."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from .downloader import DownloadProgress
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadCoordinator:
|
||||||
|
"""Manage download scheduling, cancellation and introspection."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
ws_manager,
|
||||||
|
download_manager_factory: Callable[[], Awaitable],
|
||||||
|
) -> None:
|
||||||
|
self._ws_manager = ws_manager
|
||||||
|
self._download_manager_factory = download_manager_factory
|
||||||
|
|
||||||
|
async def schedule_download(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Schedule a download using the provided payload."""
|
||||||
|
|
||||||
|
download_manager = await self._download_manager_factory()
|
||||||
|
|
||||||
|
download_id = payload.get("download_id") or self._ws_manager.generate_download_id()
|
||||||
|
payload.setdefault("download_id", download_id)
|
||||||
|
|
||||||
|
async def progress_callback(progress: Any, snapshot: Optional[DownloadProgress] = None) -> None:
|
||||||
|
percent = 0.0
|
||||||
|
metrics: Optional[DownloadProgress] = None
|
||||||
|
|
||||||
|
if isinstance(progress, DownloadProgress):
|
||||||
|
metrics = progress
|
||||||
|
percent = progress.percent_complete
|
||||||
|
elif isinstance(snapshot, DownloadProgress):
|
||||||
|
metrics = snapshot
|
||||||
|
percent = snapshot.percent_complete
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
percent = float(progress)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
percent = 0.0
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"status": "progress",
|
||||||
|
"progress": round(percent),
|
||||||
|
"download_id": download_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if metrics is not None:
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"bytes_downloaded": metrics.bytes_downloaded,
|
||||||
|
"total_bytes": metrics.total_bytes,
|
||||||
|
"bytes_per_second": metrics.bytes_per_second,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._ws_manager.broadcast_download_progress(
|
||||||
|
download_id,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
model_id = self._parse_optional_int(payload.get("model_id"), "model_id")
|
||||||
|
model_version_id = self._parse_optional_int(
|
||||||
|
payload.get("model_version_id"), "model_version_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
if model_id is None and model_version_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Missing required parameter: Please provide either 'model_id' or 'model_version_id'"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await download_manager.download_from_civitai(
|
||||||
|
model_id=model_id,
|
||||||
|
model_version_id=model_version_id,
|
||||||
|
save_dir=payload.get("model_root"),
|
||||||
|
relative_path=payload.get("relative_path", ""),
|
||||||
|
use_default_paths=payload.get("use_default_paths", False),
|
||||||
|
progress_callback=progress_callback,
|
||||||
|
download_id=download_id,
|
||||||
|
source=payload.get("source"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result["download_id"] = download_id
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def cancel_download(self, download_id: str) -> Dict[str, Any]:
|
||||||
|
"""Cancel an active download and emit a broadcast event."""
|
||||||
|
|
||||||
|
download_manager = await self._download_manager_factory()
|
||||||
|
result = await download_manager.cancel_download(download_id)
|
||||||
|
|
||||||
|
await self._ws_manager.broadcast_download_progress(
|
||||||
|
download_id,
|
||||||
|
{
|
||||||
|
"status": "cancelled",
|
||||||
|
"progress": 0,
|
||||||
|
"download_id": download_id,
|
||||||
|
"message": "Download cancelled by user",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
||||||
|
"""Pause an active download and notify listeners."""
|
||||||
|
|
||||||
|
download_manager = await self._download_manager_factory()
|
||||||
|
result = await download_manager.pause_download(download_id)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
cached_progress = self._ws_manager.get_download_progress(download_id) or {}
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"status": "paused",
|
||||||
|
"progress": cached_progress.get("progress", 0),
|
||||||
|
"download_id": download_id,
|
||||||
|
"message": "Download paused by user",
|
||||||
|
}
|
||||||
|
|
||||||
|
for field in ("bytes_downloaded", "total_bytes", "bytes_per_second"):
|
||||||
|
if field in cached_progress:
|
||||||
|
payload[field] = cached_progress[field]
|
||||||
|
|
||||||
|
payload["bytes_per_second"] = 0.0
|
||||||
|
|
||||||
|
await self._ws_manager.broadcast_download_progress(download_id, payload)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def resume_download(self, download_id: str) -> Dict[str, Any]:
|
||||||
|
"""Resume a paused download and notify listeners."""
|
||||||
|
|
||||||
|
download_manager = await self._download_manager_factory()
|
||||||
|
result = await download_manager.resume_download(download_id)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
cached_progress = self._ws_manager.get_download_progress(download_id) or {}
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"status": "downloading",
|
||||||
|
"progress": cached_progress.get("progress", 0),
|
||||||
|
"download_id": download_id,
|
||||||
|
"message": "Download resumed by user",
|
||||||
|
}
|
||||||
|
|
||||||
|
for field in ("bytes_downloaded", "total_bytes"):
|
||||||
|
if field in cached_progress:
|
||||||
|
payload[field] = cached_progress[field]
|
||||||
|
|
||||||
|
payload["bytes_per_second"] = cached_progress.get("bytes_per_second", 0.0)
|
||||||
|
|
||||||
|
await self._ws_manager.broadcast_download_progress(download_id, payload)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def list_active_downloads(self) -> Dict[str, Any]:
|
||||||
|
"""Return the active download map from the underlying manager."""
|
||||||
|
|
||||||
|
download_manager = await self._download_manager_factory()
|
||||||
|
return await download_manager.get_active_downloads()
|
||||||
|
|
||||||
|
def _parse_optional_int(self, value: Any, field: str) -> Optional[int]:
|
||||||
|
"""Parse an optional integer from user input."""
|
||||||
|
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"Invalid {field}: Must be an integer") from exc
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
850
py/services/downloader.py
Normal file
850
py/services/downloader.py
Normal file
@@ -0,0 +1,850 @@
|
|||||||
|
"""
|
||||||
|
Unified download manager for all HTTP/HTTPS downloads in the application.
|
||||||
|
|
||||||
|
This module provides a centralized download service with:
|
||||||
|
- Singleton pattern for global session management
|
||||||
|
- Support for authenticated downloads (e.g., CivitAI API key)
|
||||||
|
- Resumable downloads with automatic retry
|
||||||
|
- Progress tracking and callbacks
|
||||||
|
- Optimized connection pooling and timeouts
|
||||||
|
- Unified error handling and logging
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
from typing import Optional, Dict, Tuple, Callable, Union, Awaitable
|
||||||
|
from ..services.settings_manager import get_settings_manager
|
||||||
|
from .errors import RateLimitError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DownloadProgress:
|
||||||
|
"""Snapshot of a download transfer at a moment in time."""
|
||||||
|
|
||||||
|
percent_complete: float
|
||||||
|
bytes_downloaded: int
|
||||||
|
total_bytes: Optional[int]
|
||||||
|
bytes_per_second: float
|
||||||
|
timestamp: float
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadStreamControl:
|
||||||
|
"""Synchronize pause/resume requests and reconnect hints for a download."""
|
||||||
|
|
||||||
|
def __init__(self, *, stall_timeout: Optional[float] = None) -> None:
|
||||||
|
self._event = asyncio.Event()
|
||||||
|
self._event.set()
|
||||||
|
self._reconnect_requested = False
|
||||||
|
self.last_progress_timestamp: Optional[float] = None
|
||||||
|
self.stall_timeout: float = float(stall_timeout) if stall_timeout is not None else 120.0
|
||||||
|
|
||||||
|
def is_set(self) -> bool:
|
||||||
|
return self._event.is_set()
|
||||||
|
|
||||||
|
def is_paused(self) -> bool:
|
||||||
|
return not self._event.is_set()
|
||||||
|
|
||||||
|
def set(self) -> None:
|
||||||
|
self._event.set()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._event.clear()
|
||||||
|
|
||||||
|
async def wait(self) -> None:
|
||||||
|
await self._event.wait()
|
||||||
|
|
||||||
|
def pause(self) -> None:
|
||||||
|
self.clear()
|
||||||
|
|
||||||
|
def resume(self, *, force_reconnect: bool = False) -> None:
|
||||||
|
if force_reconnect:
|
||||||
|
self._reconnect_requested = True
|
||||||
|
self.set()
|
||||||
|
|
||||||
|
def request_reconnect(self) -> None:
|
||||||
|
self._reconnect_requested = True
|
||||||
|
self.set()
|
||||||
|
|
||||||
|
def has_reconnect_request(self) -> bool:
|
||||||
|
return self._reconnect_requested
|
||||||
|
|
||||||
|
def consume_reconnect_request(self) -> bool:
|
||||||
|
reconnect = self._reconnect_requested
|
||||||
|
self._reconnect_requested = False
|
||||||
|
return reconnect
|
||||||
|
|
||||||
|
def mark_progress(self, timestamp: Optional[float] = None) -> None:
|
||||||
|
self.last_progress_timestamp = timestamp or datetime.now().timestamp()
|
||||||
|
self._reconnect_requested = False
|
||||||
|
|
||||||
|
def time_since_last_progress(self, *, now: Optional[float] = None) -> Optional[float]:
|
||||||
|
if self.last_progress_timestamp is None:
|
||||||
|
return None
|
||||||
|
reference = now if now is not None else datetime.now().timestamp()
|
||||||
|
return max(0.0, reference - self.last_progress_timestamp)
|
||||||
|
|
||||||
|
def update_stall_timeout(self, stall_timeout: float) -> None:
|
||||||
|
self.stall_timeout = float(stall_timeout)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadRestartRequested(Exception):
|
||||||
|
"""Raised when a caller explicitly requests a fresh HTTP stream."""
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadStalledError(Exception):
|
||||||
|
"""Raised when download progress stalls beyond the configured timeout."""
|
||||||
|
|
||||||
|
|
||||||
|
class Downloader:
|
||||||
|
"""Unified downloader for all HTTP/HTTPS downloads in the application."""
|
||||||
|
|
||||||
|
_instance = None
|
||||||
|
_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_instance(cls):
|
||||||
|
"""Get singleton instance of Downloader"""
|
||||||
|
async with cls._lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the downloader with optimal settings"""
|
||||||
|
# Check if already initialized for singleton pattern
|
||||||
|
if hasattr(self, '_initialized'):
|
||||||
|
return
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
# Session management
|
||||||
|
self._session = None
|
||||||
|
self._session_created_at = None
|
||||||
|
self._proxy_url = None # Store proxy URL for current session
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better throughput
|
||||||
|
self.max_retries = 5
|
||||||
|
self.base_delay = 2.0 # Base delay for exponential backoff
|
||||||
|
self.session_timeout = 300 # 5 minutes
|
||||||
|
self.stall_timeout = self._resolve_stall_timeout()
|
||||||
|
|
||||||
|
# Default headers
|
||||||
|
self.default_headers = {
|
||||||
|
'User-Agent': 'ComfyUI-LoRA-Manager/1.0',
|
||||||
|
# Explicitly request uncompressed payloads so aiohttp doesn't need optional
|
||||||
|
# decoders (e.g. zstandard) that may be missing in runtime environments.
|
||||||
|
'Accept-Encoding': 'identity',
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
async def session(self) -> aiohttp.ClientSession:
|
||||||
|
"""Get or create the global aiohttp session with optimized settings"""
|
||||||
|
if self._session is None or self._should_refresh_session():
|
||||||
|
await self._create_session()
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
@property
|
||||||
|
def proxy_url(self) -> Optional[str]:
|
||||||
|
"""Get the current proxy URL (initialize if needed)"""
|
||||||
|
if not hasattr(self, '_proxy_url'):
|
||||||
|
self._proxy_url = None
|
||||||
|
return self._proxy_url
|
||||||
|
|
||||||
|
def _resolve_stall_timeout(self) -> float:
|
||||||
|
"""Determine the stall timeout from settings or environment."""
|
||||||
|
default_timeout = 120.0
|
||||||
|
settings_timeout = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
settings_timeout = settings_manager.get('download_stall_timeout_seconds')
|
||||||
|
except Exception as exc: # pragma: no cover - defensive guard
|
||||||
|
logger.debug("Failed to read stall timeout from settings: %s", exc)
|
||||||
|
|
||||||
|
raw_value = (
|
||||||
|
settings_timeout
|
||||||
|
if settings_timeout not in (None, "")
|
||||||
|
else os.environ.get('COMFYUI_DOWNLOAD_STALL_TIMEOUT')
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
timeout_value = float(raw_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout_value = default_timeout
|
||||||
|
|
||||||
|
return max(30.0, timeout_value)
|
||||||
|
|
||||||
|
def _should_refresh_session(self) -> bool:
|
||||||
|
"""Check if session should be refreshed"""
|
||||||
|
if self._session is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not hasattr(self, '_session_created_at') or self._session_created_at is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Refresh if session is older than timeout
|
||||||
|
if (datetime.now() - self._session_created_at).total_seconds() > self.session_timeout:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _create_session(self):
|
||||||
|
"""Create a new aiohttp session with optimized settings"""
|
||||||
|
# Close existing session if any
|
||||||
|
if self._session is not None:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
# Check for app-level proxy settings
|
||||||
|
proxy_url = None
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
if settings_manager.get('proxy_enabled', False):
|
||||||
|
proxy_host = settings_manager.get('proxy_host', '').strip()
|
||||||
|
proxy_port = settings_manager.get('proxy_port', '').strip()
|
||||||
|
proxy_type = settings_manager.get('proxy_type', 'http').lower()
|
||||||
|
proxy_username = settings_manager.get('proxy_username', '').strip()
|
||||||
|
proxy_password = settings_manager.get('proxy_password', '').strip()
|
||||||
|
|
||||||
|
if proxy_host and proxy_port:
|
||||||
|
# Build proxy URL
|
||||||
|
if proxy_username and proxy_password:
|
||||||
|
proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
|
||||||
|
else:
|
||||||
|
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
|
||||||
|
|
||||||
|
logger.debug(f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}")
|
||||||
|
logger.debug("Proxy mode: app-level proxy is active.")
|
||||||
|
else:
|
||||||
|
logger.debug("Proxy mode: system-level proxy (trust_env) will be used if configured in environment.")
|
||||||
|
# Optimize TCP connection parameters
|
||||||
|
connector = aiohttp.TCPConnector(
|
||||||
|
ssl=True,
|
||||||
|
limit=8, # Concurrent connections
|
||||||
|
ttl_dns_cache=300, # DNS cache timeout
|
||||||
|
force_close=False, # Keep connections for reuse
|
||||||
|
enable_cleanup_closed=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure timeout parameters
|
||||||
|
timeout = aiohttp.ClientTimeout(
|
||||||
|
total=None, # No total timeout for large downloads
|
||||||
|
connect=60, # Connection timeout
|
||||||
|
sock_read=300 # 5 minute socket read timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
self._session = aiohttp.ClientSession(
|
||||||
|
connector=connector,
|
||||||
|
trust_env=proxy_url is None, # Only use system proxy if no app-level proxy is set
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store proxy URL for use in requests
|
||||||
|
self._proxy_url = proxy_url
|
||||||
|
self._session_created_at = datetime.now()
|
||||||
|
|
||||||
|
logger.debug("Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s", bool(proxy_url), proxy_url is None)
|
||||||
|
|
||||||
|
def _get_auth_headers(self, use_auth: bool = False) -> Dict[str, str]:
|
||||||
|
"""Get headers with optional authentication"""
|
||||||
|
headers = self.default_headers.copy()
|
||||||
|
|
||||||
|
if use_auth:
|
||||||
|
# Add CivitAI API key if available
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
api_key = settings_manager.get('civitai_api_key')
|
||||||
|
if api_key:
|
||||||
|
headers['Authorization'] = f'Bearer {api_key}'
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
async def download_file(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
save_path: str,
|
||||||
|
progress_callback: Optional[Callable[..., Awaitable[None]]] = None,
|
||||||
|
use_auth: bool = False,
|
||||||
|
custom_headers: Optional[Dict[str, str]] = None,
|
||||||
|
allow_resume: bool = True,
|
||||||
|
pause_event: Optional[DownloadStreamControl] = None,
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Download a file with resumable downloads and retry mechanism
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Download URL
|
||||||
|
save_path: Full path where the file should be saved
|
||||||
|
progress_callback: Optional callback for progress updates (0-100)
|
||||||
|
use_auth: Whether to include authentication headers (e.g., CivitAI API key)
|
||||||
|
custom_headers: Additional headers to include in request
|
||||||
|
allow_resume: Whether to support resumable downloads
|
||||||
|
pause_event: Optional stream control used to pause/resume and request reconnects
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[bool, str]: (success, save_path or error message)
|
||||||
|
"""
|
||||||
|
retry_count = 0
|
||||||
|
part_path = save_path + '.part' if allow_resume else save_path
|
||||||
|
|
||||||
|
# Prepare headers
|
||||||
|
headers = self._get_auth_headers(use_auth)
|
||||||
|
if custom_headers:
|
||||||
|
headers.update(custom_headers)
|
||||||
|
|
||||||
|
# Get existing file size for resume
|
||||||
|
resume_offset = 0
|
||||||
|
if allow_resume and os.path.exists(part_path):
|
||||||
|
resume_offset = os.path.getsize(part_path)
|
||||||
|
logger.info(f"Resuming download from offset {resume_offset} bytes")
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
while retry_count <= self.max_retries:
|
||||||
|
try:
|
||||||
|
session = await self.session
|
||||||
|
# Debug log for proxy mode at request time
|
||||||
|
if self.proxy_url:
|
||||||
|
logger.debug(f"[download_file] Using app-level proxy: {self.proxy_url}")
|
||||||
|
else:
|
||||||
|
logger.debug("[download_file] Using system-level proxy (trust_env) if configured.")
|
||||||
|
|
||||||
|
# Add Range header for resume if we have partial data
|
||||||
|
request_headers = headers.copy()
|
||||||
|
if allow_resume and resume_offset > 0:
|
||||||
|
request_headers['Range'] = f'bytes={resume_offset}-'
|
||||||
|
|
||||||
|
# Disable compression for better chunked downloads
|
||||||
|
request_headers['Accept-Encoding'] = 'identity'
|
||||||
|
|
||||||
|
logger.debug(f"Download attempt {retry_count + 1}/{self.max_retries + 1} from: {url}")
|
||||||
|
if resume_offset > 0:
|
||||||
|
logger.debug(f"Requesting range from byte {resume_offset}")
|
||||||
|
|
||||||
|
async with session.get(url, headers=request_headers, allow_redirects=True, proxy=self.proxy_url) as response:
|
||||||
|
# Handle different response codes
|
||||||
|
if response.status == 200:
|
||||||
|
# Full content response
|
||||||
|
if resume_offset > 0:
|
||||||
|
# Server doesn't support ranges, restart from beginning
|
||||||
|
logger.warning("Server doesn't support range requests, restarting download")
|
||||||
|
resume_offset = 0
|
||||||
|
if os.path.exists(part_path):
|
||||||
|
os.remove(part_path)
|
||||||
|
elif response.status == 206:
|
||||||
|
# Partial content response (resume successful)
|
||||||
|
content_range = response.headers.get('Content-Range')
|
||||||
|
if content_range:
|
||||||
|
# Parse total size from Content-Range header (e.g., "bytes 1024-2047/2048")
|
||||||
|
range_parts = content_range.split('/')
|
||||||
|
if len(range_parts) == 2:
|
||||||
|
total_size = int(range_parts[1])
|
||||||
|
logger.info(f"Successfully resumed download from byte {resume_offset}")
|
||||||
|
elif response.status == 416:
|
||||||
|
# Range not satisfiable - file might be complete or corrupted
|
||||||
|
if allow_resume and os.path.exists(part_path):
|
||||||
|
part_size = os.path.getsize(part_path)
|
||||||
|
logger.warning(f"Range not satisfiable. Part file size: {part_size}")
|
||||||
|
# Try to get actual file size
|
||||||
|
head_response = await session.head(url, headers=headers, proxy=self.proxy_url)
|
||||||
|
if head_response.status == 200:
|
||||||
|
actual_size = int(head_response.headers.get('content-length', 0))
|
||||||
|
if part_size == actual_size:
|
||||||
|
# File is complete, just rename it
|
||||||
|
if allow_resume:
|
||||||
|
os.rename(part_path, save_path)
|
||||||
|
if progress_callback:
|
||||||
|
await self._dispatch_progress_callback(
|
||||||
|
progress_callback,
|
||||||
|
DownloadProgress(
|
||||||
|
percent_complete=100.0,
|
||||||
|
bytes_downloaded=part_size,
|
||||||
|
total_bytes=actual_size,
|
||||||
|
bytes_per_second=0.0,
|
||||||
|
timestamp=datetime.now().timestamp(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return True, save_path
|
||||||
|
# Remove corrupted part file and restart
|
||||||
|
os.remove(part_path)
|
||||||
|
resume_offset = 0
|
||||||
|
continue
|
||||||
|
elif response.status == 401:
|
||||||
|
logger.warning(f"Unauthorized access to resource: {url} (Status 401)")
|
||||||
|
return False, "Invalid or missing API key, or early access restriction."
|
||||||
|
elif response.status == 403:
|
||||||
|
logger.warning(f"Forbidden access to resource: {url} (Status 403)")
|
||||||
|
return False, "Access forbidden: You don't have permission to download this file."
|
||||||
|
elif response.status == 404:
|
||||||
|
logger.warning(f"Resource not found: {url} (Status 404)")
|
||||||
|
return False, "File not found - the download link may be invalid or expired."
|
||||||
|
else:
|
||||||
|
logger.error(f"Download failed for {url} with status {response.status}")
|
||||||
|
return False, f"Download failed with status {response.status}"
|
||||||
|
|
||||||
|
# Get total file size for progress calculation (if not set from Content-Range)
|
||||||
|
if total_size == 0:
|
||||||
|
total_size = int(response.headers.get('content-length', 0))
|
||||||
|
if response.status == 206:
|
||||||
|
# For partial content, add the offset to get total file size
|
||||||
|
total_size += resume_offset
|
||||||
|
|
||||||
|
current_size = resume_offset
|
||||||
|
last_progress_report_time = datetime.now()
|
||||||
|
progress_samples: deque[tuple[datetime, int]] = deque()
|
||||||
|
progress_samples.append((last_progress_report_time, current_size))
|
||||||
|
|
||||||
|
# Ensure directory exists
|
||||||
|
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||||
|
|
||||||
|
# Stream download to file with progress updates
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
mode = 'ab' if (allow_resume and resume_offset > 0) else 'wb'
|
||||||
|
control = pause_event
|
||||||
|
|
||||||
|
if control is not None:
|
||||||
|
control.update_stall_timeout(self.stall_timeout)
|
||||||
|
|
||||||
|
with open(part_path, mode) as f:
|
||||||
|
while True:
|
||||||
|
active_stall_timeout = control.stall_timeout if control else self.stall_timeout
|
||||||
|
|
||||||
|
if control is not None:
|
||||||
|
if control.is_paused():
|
||||||
|
await control.wait()
|
||||||
|
resume_time = datetime.now()
|
||||||
|
last_progress_report_time = resume_time
|
||||||
|
if control.consume_reconnect_request():
|
||||||
|
raise DownloadRestartRequested(
|
||||||
|
"Reconnect requested after resume"
|
||||||
|
)
|
||||||
|
elif control.consume_reconnect_request():
|
||||||
|
raise DownloadRestartRequested("Reconnect requested")
|
||||||
|
|
||||||
|
try:
|
||||||
|
chunk = await asyncio.wait_for(
|
||||||
|
response.content.read(self.chunk_size),
|
||||||
|
timeout=active_stall_timeout,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Download stalled for %.1f seconds without progress from %s",
|
||||||
|
active_stall_timeout,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
raise DownloadStalledError(
|
||||||
|
f"No data received for {active_stall_timeout:.1f} seconds"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Run blocking file write in executor
|
||||||
|
await loop.run_in_executor(None, f.write, chunk)
|
||||||
|
current_size += len(chunk)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
if control is not None:
|
||||||
|
control.mark_progress(timestamp=now.timestamp())
|
||||||
|
|
||||||
|
# Limit progress update frequency to reduce overhead
|
||||||
|
time_diff = (now - last_progress_report_time).total_seconds()
|
||||||
|
|
||||||
|
if progress_callback and time_diff >= 1.0:
|
||||||
|
progress_samples.append((now, current_size))
|
||||||
|
cutoff = now - timedelta(seconds=5)
|
||||||
|
while progress_samples and progress_samples[0][0] < cutoff:
|
||||||
|
progress_samples.popleft()
|
||||||
|
|
||||||
|
percent = (current_size / total_size) * 100 if total_size else 0.0
|
||||||
|
bytes_per_second = 0.0
|
||||||
|
if len(progress_samples) >= 2:
|
||||||
|
first_time, first_bytes = progress_samples[0]
|
||||||
|
last_time, last_bytes = progress_samples[-1]
|
||||||
|
elapsed = (last_time - first_time).total_seconds()
|
||||||
|
if elapsed > 0:
|
||||||
|
bytes_per_second = (last_bytes - first_bytes) / elapsed
|
||||||
|
|
||||||
|
progress_snapshot = DownloadProgress(
|
||||||
|
percent_complete=percent,
|
||||||
|
bytes_downloaded=current_size,
|
||||||
|
total_bytes=total_size or None,
|
||||||
|
bytes_per_second=bytes_per_second,
|
||||||
|
timestamp=now.timestamp(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._dispatch_progress_callback(progress_callback, progress_snapshot)
|
||||||
|
last_progress_report_time = now
|
||||||
|
|
||||||
|
# Download completed successfully
|
||||||
|
# Verify file size integrity before finalizing
|
||||||
|
final_size = os.path.getsize(part_path) if os.path.exists(part_path) else 0
|
||||||
|
expected_size = total_size if total_size > 0 else None
|
||||||
|
|
||||||
|
integrity_error: Optional[str] = None
|
||||||
|
if final_size <= 0:
|
||||||
|
integrity_error = "Downloaded file is empty"
|
||||||
|
elif expected_size is not None and final_size != expected_size:
|
||||||
|
integrity_error = (
|
||||||
|
f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if integrity_error is not None:
|
||||||
|
logger.error(
|
||||||
|
"Download integrity check failed for %s: %s",
|
||||||
|
save_path,
|
||||||
|
integrity_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove the corrupted payload so future attempts start fresh
|
||||||
|
if os.path.exists(part_path):
|
||||||
|
try:
|
||||||
|
os.remove(part_path)
|
||||||
|
except OSError as remove_error:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to delete corrupted download %s: %s",
|
||||||
|
part_path,
|
||||||
|
remove_error,
|
||||||
|
)
|
||||||
|
if part_path != save_path and os.path.exists(save_path):
|
||||||
|
try:
|
||||||
|
os.remove(save_path)
|
||||||
|
except OSError as remove_error:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to delete target file %s after integrity error: %s",
|
||||||
|
save_path,
|
||||||
|
remove_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
retry_count += 1
|
||||||
|
if retry_count <= self.max_retries:
|
||||||
|
delay = self.base_delay * (2 ** (retry_count - 1))
|
||||||
|
logger.info(
|
||||||
|
"Retrying download in %s seconds due to integrity check failure",
|
||||||
|
delay,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
resume_offset = 0
|
||||||
|
total_size = 0
|
||||||
|
await self._create_session()
|
||||||
|
continue
|
||||||
|
|
||||||
|
return False, integrity_error
|
||||||
|
|
||||||
|
# Atomically rename .part to final file (only if using resume)
|
||||||
|
if allow_resume and part_path != save_path:
|
||||||
|
max_rename_attempts = 5
|
||||||
|
rename_attempt = 0
|
||||||
|
rename_success = False
|
||||||
|
|
||||||
|
while rename_attempt < max_rename_attempts and not rename_success:
|
||||||
|
try:
|
||||||
|
# If the destination file exists, remove it first (Windows safe)
|
||||||
|
if os.path.exists(save_path):
|
||||||
|
os.remove(save_path)
|
||||||
|
|
||||||
|
os.rename(part_path, save_path)
|
||||||
|
rename_success = True
|
||||||
|
except PermissionError as e:
|
||||||
|
rename_attempt += 1
|
||||||
|
if rename_attempt < max_rename_attempts:
|
||||||
|
logger.info(f"File still in use, retrying rename in 2 seconds (attempt {rename_attempt}/{max_rename_attempts})")
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to rename file after {max_rename_attempts} attempts: {e}")
|
||||||
|
return False, f"Failed to finalize download: {str(e)}"
|
||||||
|
|
||||||
|
final_size = os.path.getsize(save_path)
|
||||||
|
|
||||||
|
# Ensure 100% progress is reported
|
||||||
|
if progress_callback:
|
||||||
|
final_snapshot = DownloadProgress(
|
||||||
|
percent_complete=100.0,
|
||||||
|
bytes_downloaded=final_size,
|
||||||
|
total_bytes=total_size or final_size,
|
||||||
|
bytes_per_second=0.0,
|
||||||
|
timestamp=datetime.now().timestamp(),
|
||||||
|
)
|
||||||
|
await self._dispatch_progress_callback(progress_callback, final_snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
return True, save_path
|
||||||
|
|
||||||
|
except (
|
||||||
|
aiohttp.ClientError,
|
||||||
|
aiohttp.ClientPayloadError,
|
||||||
|
aiohttp.ServerDisconnectedError,
|
||||||
|
asyncio.TimeoutError,
|
||||||
|
DownloadStalledError,
|
||||||
|
DownloadRestartRequested,
|
||||||
|
) as e:
|
||||||
|
retry_count += 1
|
||||||
|
logger.warning(f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}")
|
||||||
|
|
||||||
|
if retry_count <= self.max_retries:
|
||||||
|
# Calculate delay with exponential backoff
|
||||||
|
delay = self.base_delay * (2 ** (retry_count - 1))
|
||||||
|
logger.info(f"Retrying in {delay} seconds...")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
# Update resume offset for next attempt
|
||||||
|
if allow_resume and os.path.exists(part_path):
|
||||||
|
resume_offset = os.path.getsize(part_path)
|
||||||
|
logger.info(f"Will resume from byte {resume_offset}")
|
||||||
|
|
||||||
|
# Refresh session to get new connection
|
||||||
|
await self._create_session()
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.error(f"Max retries exceeded for download: {e}")
|
||||||
|
return False, f"Network error after {self.max_retries + 1} attempts: {str(e)}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected download error: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
return False, f"Download failed after {self.max_retries + 1} attempts"
|
||||||
|
|
||||||
|
async def _dispatch_progress_callback(
|
||||||
|
self,
|
||||||
|
progress_callback: Callable[..., Awaitable[None]],
|
||||||
|
snapshot: DownloadProgress,
|
||||||
|
) -> None:
|
||||||
|
"""Invoke a progress callback while preserving backward compatibility."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = progress_callback(snapshot, snapshot)
|
||||||
|
except TypeError:
|
||||||
|
result = progress_callback(snapshot.percent_complete)
|
||||||
|
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
elif hasattr(result, "__await__"):
|
||||||
|
await result
|
||||||
|
|
||||||
|
async def download_to_memory(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
use_auth: bool = False,
|
||||||
|
custom_headers: Optional[Dict[str, str]] = None,
|
||||||
|
return_headers: bool = False
|
||||||
|
) -> Tuple[bool, Union[bytes, str], Optional[Dict]]:
|
||||||
|
"""
|
||||||
|
Download a file to memory (for small files like preview images)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Download URL
|
||||||
|
use_auth: Whether to include authentication headers
|
||||||
|
custom_headers: Additional headers to include in request
|
||||||
|
return_headers: Whether to return response headers along with content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[bool, Union[bytes, str], Optional[Dict]]: (success, content or error message, response headers if requested)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session = await self.session
|
||||||
|
# Debug log for proxy mode at request time
|
||||||
|
if self.proxy_url:
|
||||||
|
logger.debug(f"[download_to_memory] Using app-level proxy: {self.proxy_url}")
|
||||||
|
else:
|
||||||
|
logger.debug("[download_to_memory] Using system-level proxy (trust_env) if configured.")
|
||||||
|
|
||||||
|
# Prepare headers
|
||||||
|
headers = self._get_auth_headers(use_auth)
|
||||||
|
if custom_headers:
|
||||||
|
headers.update(custom_headers)
|
||||||
|
|
||||||
|
async with session.get(url, headers=headers, proxy=self.proxy_url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
content = await response.read()
|
||||||
|
if return_headers:
|
||||||
|
return True, content, dict(response.headers)
|
||||||
|
else:
|
||||||
|
return True, content, None
|
||||||
|
elif response.status == 401:
|
||||||
|
error_msg = "Unauthorized access - invalid or missing API key"
|
||||||
|
return False, error_msg, None
|
||||||
|
elif response.status == 403:
|
||||||
|
error_msg = "Access forbidden"
|
||||||
|
return False, error_msg, None
|
||||||
|
elif response.status == 404:
|
||||||
|
error_msg = "File not found"
|
||||||
|
return False, error_msg, None
|
||||||
|
else:
|
||||||
|
error_msg = f"Download failed with status {response.status}"
|
||||||
|
return False, error_msg, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error downloading to memory from {url}: {e}")
|
||||||
|
return False, str(e), None
|
||||||
|
|
||||||
|
async def get_response_headers(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
use_auth: bool = False,
|
||||||
|
custom_headers: Optional[Dict[str, str]] = None
|
||||||
|
) -> Tuple[bool, Union[Dict, str]]:
|
||||||
|
"""
|
||||||
|
Get response headers without downloading the full content
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to check
|
||||||
|
use_auth: Whether to include authentication headers
|
||||||
|
custom_headers: Additional headers to include in request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[bool, Union[Dict, str]]: (success, headers dict or error message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session = await self.session
|
||||||
|
# Debug log for proxy mode at request time
|
||||||
|
if self.proxy_url:
|
||||||
|
logger.debug(f"[get_response_headers] Using app-level proxy: {self.proxy_url}")
|
||||||
|
else:
|
||||||
|
logger.debug("[get_response_headers] Using system-level proxy (trust_env) if configured.")
|
||||||
|
|
||||||
|
# Prepare headers
|
||||||
|
headers = self._get_auth_headers(use_auth)
|
||||||
|
if custom_headers:
|
||||||
|
headers.update(custom_headers)
|
||||||
|
|
||||||
|
async with session.head(url, headers=headers, proxy=self.proxy_url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return True, dict(response.headers)
|
||||||
|
else:
|
||||||
|
return False, f"Head request failed with status {response.status}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting headers from {url}: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
async def make_request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
use_auth: bool = False,
|
||||||
|
custom_headers: Optional[Dict[str, str]] = None,
|
||||||
|
**kwargs
|
||||||
|
) -> Tuple[bool, Union[Dict, str]]:
|
||||||
|
"""
|
||||||
|
Make a generic HTTP request and return JSON response
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP method (GET, POST, etc.)
|
||||||
|
url: Request URL
|
||||||
|
use_auth: Whether to include authentication headers
|
||||||
|
custom_headers: Additional headers to include in request
|
||||||
|
**kwargs: Additional arguments for aiohttp request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[bool, Union[Dict, str]]: (success, response data or error message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session = await self.session
|
||||||
|
# Debug log for proxy mode at request time
|
||||||
|
if self.proxy_url:
|
||||||
|
logger.debug(f"[make_request] Using app-level proxy: {self.proxy_url}")
|
||||||
|
else:
|
||||||
|
logger.debug("[make_request] Using system-level proxy (trust_env) if configured.")
|
||||||
|
|
||||||
|
# Prepare headers
|
||||||
|
headers = self._get_auth_headers(use_auth)
|
||||||
|
if custom_headers:
|
||||||
|
headers.update(custom_headers)
|
||||||
|
|
||||||
|
# Add proxy to kwargs if not already present
|
||||||
|
if 'proxy' not in kwargs:
|
||||||
|
kwargs['proxy'] = self.proxy_url
|
||||||
|
|
||||||
|
async with session.request(method, url, headers=headers, **kwargs) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
# Try to parse as JSON, fall back to text
|
||||||
|
try:
|
||||||
|
data = await response.json()
|
||||||
|
return True, data
|
||||||
|
except:
|
||||||
|
text = await response.text()
|
||||||
|
return True, text
|
||||||
|
elif response.status == 401:
|
||||||
|
return False, "Unauthorized access - invalid or missing API key"
|
||||||
|
elif response.status == 403:
|
||||||
|
return False, "Access forbidden"
|
||||||
|
elif response.status == 404:
|
||||||
|
return False, "Resource not found"
|
||||||
|
elif response.status == 429:
|
||||||
|
retry_after = self._extract_retry_after(response.headers)
|
||||||
|
error_msg = "Request rate limited"
|
||||||
|
logger.warning(
|
||||||
|
"Rate limit encountered for %s %s; retry_after=%s",
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
retry_after,
|
||||||
|
)
|
||||||
|
return False, RateLimitError(
|
||||||
|
error_msg,
|
||||||
|
retry_after=retry_after,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return False, f"Request failed with status {response.status}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error making {method} request to {url}: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the HTTP session"""
|
||||||
|
if self._session is not None:
|
||||||
|
await self._session.close()
|
||||||
|
self._session = None
|
||||||
|
self._session_created_at = None
|
||||||
|
self._proxy_url = None
|
||||||
|
logger.debug("Closed HTTP session")
|
||||||
|
|
||||||
|
async def refresh_session(self):
|
||||||
|
"""Force refresh the HTTP session (useful when proxy settings change)"""
|
||||||
|
await self._create_session()
|
||||||
|
logger.info("HTTP session refreshed due to settings change")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_retry_after(headers) -> Optional[float]:
|
||||||
|
"""Parse the Retry-After header into seconds."""
|
||||||
|
if not headers:
|
||||||
|
return None
|
||||||
|
|
||||||
|
header_value = headers.get("Retry-After")
|
||||||
|
if not header_value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
header_value = header_value.strip()
|
||||||
|
if not header_value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if header_value.isdigit():
|
||||||
|
try:
|
||||||
|
seconds = float(header_value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return max(0.0, seconds)
|
||||||
|
|
||||||
|
try:
|
||||||
|
retry_datetime = parsedate_to_datetime(header_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if retry_datetime.tzinfo is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
delta = retry_datetime - datetime.now(tz=retry_datetime.tzinfo)
|
||||||
|
return max(0.0, delta.total_seconds())
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance accessor
|
||||||
|
async def get_downloader() -> Downloader:
|
||||||
|
"""Get the global downloader instance"""
|
||||||
|
return await Downloader.get_instance()
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from ..utils.models import EmbeddingMetadata
|
from ..utils.models import EmbeddingMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.routes_common import ModelRouteUtils
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class EmbeddingService(BaseModelService):
|
class EmbeddingService(BaseModelService):
|
||||||
"""Embedding-specific service implementation"""
|
"""Embedding-specific service implementation"""
|
||||||
|
|
||||||
def __init__(self, scanner):
|
def __init__(self, scanner, update_service=None):
|
||||||
"""Initialize Embedding service
|
"""Initialize Embedding service
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
scanner: Embedding scanner instance
|
scanner: Embedding scanner instance
|
||||||
|
update_service: Optional service for remote update tracking.
|
||||||
"""
|
"""
|
||||||
super().__init__("embedding", scanner, EmbeddingMetadata)
|
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, embedding_data: Dict) -> Dict:
|
async def format_response(self, embedding_data: Dict) -> Dict:
|
||||||
"""Format Embedding data for API response"""
|
"""Format Embedding data for API response"""
|
||||||
@@ -38,7 +38,8 @@ class EmbeddingService(BaseModelService):
|
|||||||
"notes": embedding_data.get("notes", ""),
|
"notes": embedding_data.get("notes", ""),
|
||||||
"model_type": embedding_data.get("model_type", "embedding"),
|
"model_type": embedding_data.get("model_type", "embedding"),
|
||||||
"favorite": embedding_data.get("favorite", False),
|
"favorite": embedding_data.get("favorite", False),
|
||||||
"civitai": ModelRouteUtils.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
"update_available": bool(embedding_data.get("update_available", False)),
|
||||||
|
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
27
py/services/errors.py
Normal file
27
py/services/errors.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Common service-level exception types."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(RuntimeError):
|
||||||
|
"""Raised when a remote provider rejects a request due to rate limiting."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
retry_after: Optional[float] = None,
|
||||||
|
provider: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.retry_after = retry_after
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceNotFoundError(RuntimeError):
|
||||||
|
"""Raised when a remote resource is permanently missing."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
297
py/services/example_images_cleanup_service.py
Normal file
297
py/services/example_images_cleanup_service.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"""Service for cleaning up example image folders."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
from .service_registry import ServiceRegistry
|
||||||
|
from .settings_manager import get_settings_manager
|
||||||
|
from ..utils.example_images_paths import iter_library_roots
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CleanupResult:
|
||||||
|
"""Structured result returned from cleanup operations."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
checked_folders: int
|
||||||
|
moved_empty_folders: int
|
||||||
|
moved_orphaned_folders: int
|
||||||
|
skipped_non_hash: int
|
||||||
|
move_failures: int
|
||||||
|
errors: List[str]
|
||||||
|
deleted_root: str | None
|
||||||
|
partial_success: bool
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, object]:
|
||||||
|
"""Convert the dataclass to a serialisable dictionary."""
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"success": self.success,
|
||||||
|
"checked_folders": self.checked_folders,
|
||||||
|
"moved_empty_folders": self.moved_empty_folders,
|
||||||
|
"moved_orphaned_folders": self.moved_orphaned_folders,
|
||||||
|
"moved_total": self.moved_empty_folders + self.moved_orphaned_folders,
|
||||||
|
"skipped_non_hash": self.skipped_non_hash,
|
||||||
|
"move_failures": self.move_failures,
|
||||||
|
"errors": self.errors,
|
||||||
|
"deleted_root": self.deleted_root,
|
||||||
|
"partial_success": self.partial_success,
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleImagesCleanupService:
|
||||||
|
"""Encapsulates logic for cleaning example image folders."""
|
||||||
|
|
||||||
|
DELETED_FOLDER_NAME = "_deleted"
|
||||||
|
|
||||||
|
def __init__(self, deleted_folder_name: str | None = None) -> None:
|
||||||
|
self._deleted_folder_name = deleted_folder_name or self.DELETED_FOLDER_NAME
|
||||||
|
|
||||||
|
async def cleanup_example_image_folders(self) -> Dict[str, object]:
|
||||||
|
"""Clean empty or orphaned example image folders by moving them under a deleted bucket."""
|
||||||
|
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
example_images_path = settings_manager.get("example_images_path")
|
||||||
|
if not example_images_path:
|
||||||
|
logger.debug("Cleanup skipped: example images path not configured")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Example images path is not configured.",
|
||||||
|
"error_code": "path_not_configured",
|
||||||
|
}
|
||||||
|
|
||||||
|
base_root = Path(example_images_path)
|
||||||
|
if not base_root.exists():
|
||||||
|
logger.debug("Cleanup skipped: example images path missing -> %s", base_root)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Example images path does not exist.",
|
||||||
|
"error_code": "path_not_found",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
|
except Exception as exc: # pragma: no cover - defensive guard
|
||||||
|
logger.error("Failed to acquire scanners for cleanup: %s", exc, exc_info=True)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Failed to load model scanners: {exc}",
|
||||||
|
"error_code": "scanner_initialization_failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
checked_folders = 0
|
||||||
|
moved_empty = 0
|
||||||
|
moved_orphaned = 0
|
||||||
|
skipped_non_hash = 0
|
||||||
|
move_failures = 0
|
||||||
|
errors: List[str] = []
|
||||||
|
|
||||||
|
resolved_base = base_root.resolve()
|
||||||
|
library_paths: List[Tuple[str, Path]] = []
|
||||||
|
processed_paths = {resolved_base}
|
||||||
|
|
||||||
|
for library_name, library_path in iter_library_roots():
|
||||||
|
if not library_path:
|
||||||
|
continue
|
||||||
|
library_root = Path(library_path)
|
||||||
|
try:
|
||||||
|
resolved = library_root.resolve()
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
if resolved in processed_paths:
|
||||||
|
continue
|
||||||
|
if not library_root.exists():
|
||||||
|
logger.debug(
|
||||||
|
"Skipping cleanup for library '%s': folder missing (%s)",
|
||||||
|
library_name,
|
||||||
|
library_root,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
processed_paths.add(resolved)
|
||||||
|
library_paths.append((library_name, library_root))
|
||||||
|
|
||||||
|
deleted_roots: List[Path] = []
|
||||||
|
|
||||||
|
# Build list of (label, root) pairs including the base root for legacy layouts
|
||||||
|
cleanup_targets: List[Tuple[str, Path]] = [("__base__", base_root)] + library_paths
|
||||||
|
|
||||||
|
library_root_set = {root.resolve() for _, root in library_paths}
|
||||||
|
|
||||||
|
for label, root_path in cleanup_targets:
|
||||||
|
deleted_bucket = root_path / self._deleted_folder_name
|
||||||
|
deleted_bucket.mkdir(exist_ok=True)
|
||||||
|
deleted_roots.append(deleted_bucket)
|
||||||
|
|
||||||
|
for entry in os.scandir(root_path):
|
||||||
|
if not entry.is_dir(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if entry.name == self._deleted_folder_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry_path = Path(entry.path)
|
||||||
|
|
||||||
|
if label == "__base__":
|
||||||
|
try:
|
||||||
|
resolved_entry = entry_path.resolve()
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
if resolved_entry in library_root_set:
|
||||||
|
# Skip library-specific folders tracked separately
|
||||||
|
continue
|
||||||
|
|
||||||
|
checked_folders += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._is_folder_empty(entry_path):
|
||||||
|
if await self._remove_empty_folder(entry_path):
|
||||||
|
moved_empty += 1
|
||||||
|
else:
|
||||||
|
move_failures += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not self._is_hash_folder(entry.name):
|
||||||
|
skipped_non_hash += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
hash_exists = (
|
||||||
|
lora_scanner.has_hash(entry.name)
|
||||||
|
or checkpoint_scanner.has_hash(entry.name)
|
||||||
|
or embedding_scanner.has_hash(entry.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hash_exists:
|
||||||
|
if await self._move_folder(entry_path, deleted_bucket):
|
||||||
|
moved_orphaned += 1
|
||||||
|
else:
|
||||||
|
move_failures += 1
|
||||||
|
|
||||||
|
except Exception as exc: # pragma: no cover - filesystem guard
|
||||||
|
move_failures += 1
|
||||||
|
error_message = f"{entry.name}: {exc}"
|
||||||
|
errors.append(error_message)
|
||||||
|
logger.error(
|
||||||
|
"Error processing example images folder %s: %s",
|
||||||
|
entry_path,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
partial_success = move_failures > 0 and (moved_empty > 0 or moved_orphaned > 0)
|
||||||
|
success = move_failures == 0 and not errors
|
||||||
|
|
||||||
|
result = CleanupResult(
|
||||||
|
success=success,
|
||||||
|
checked_folders=checked_folders,
|
||||||
|
moved_empty_folders=moved_empty,
|
||||||
|
moved_orphaned_folders=moved_orphaned,
|
||||||
|
skipped_non_hash=skipped_non_hash,
|
||||||
|
move_failures=move_failures,
|
||||||
|
errors=errors,
|
||||||
|
deleted_root=str(deleted_roots[0]) if deleted_roots else None,
|
||||||
|
partial_success=partial_success,
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = result.to_dict()
|
||||||
|
summary["deleted_roots"] = [str(path) for path in deleted_roots]
|
||||||
|
if success:
|
||||||
|
logger.info(
|
||||||
|
"Example images cleanup complete: checked=%s, moved_empty=%s, moved_orphaned=%s",
|
||||||
|
checked_folders,
|
||||||
|
moved_empty,
|
||||||
|
moved_orphaned,
|
||||||
|
)
|
||||||
|
elif partial_success:
|
||||||
|
logger.warning(
|
||||||
|
"Example images cleanup partially complete: moved=%s, failures=%s",
|
||||||
|
summary["moved_total"],
|
||||||
|
move_failures,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Example images cleanup failed: move_failures=%s, errors=%s",
|
||||||
|
move_failures,
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_folder_empty(folder_path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
with os.scandir(folder_path) as iterator:
|
||||||
|
return not any(iterator)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return True
|
||||||
|
except OSError as exc: # pragma: no cover - defensive guard
|
||||||
|
logger.debug("Failed to inspect folder %s: %s", folder_path, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_hash_folder(name: str) -> bool:
|
||||||
|
if len(name) != 64:
|
||||||
|
return False
|
||||||
|
hex_chars = set("0123456789abcdefABCDEF")
|
||||||
|
return all(char in hex_chars for char in name)
|
||||||
|
|
||||||
|
async def _remove_empty_folder(self, folder_path: Path) -> bool:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
shutil.rmtree,
|
||||||
|
str(folder_path),
|
||||||
|
)
|
||||||
|
logger.debug("Removed empty example images folder %s", folder_path)
|
||||||
|
return True
|
||||||
|
except Exception as exc: # pragma: no cover - filesystem guard
|
||||||
|
logger.error("Failed to remove empty example images folder %s: %s", folder_path, exc, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _move_folder(self, folder_path: Path, deleted_bucket: Path) -> bool:
|
||||||
|
destination = self._build_destination(folder_path.name, deleted_bucket)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
shutil.move,
|
||||||
|
str(folder_path),
|
||||||
|
str(destination),
|
||||||
|
)
|
||||||
|
logger.debug("Moved example images folder %s -> %s", folder_path, destination)
|
||||||
|
return True
|
||||||
|
except Exception as exc: # pragma: no cover - filesystem guard
|
||||||
|
logger.error(
|
||||||
|
"Failed to move example images folder %s to %s: %s",
|
||||||
|
folder_path,
|
||||||
|
destination,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _build_destination(self, folder_name: str, deleted_bucket: Path) -> Path:
|
||||||
|
destination = deleted_bucket / folder_name
|
||||||
|
suffix = 1
|
||||||
|
|
||||||
|
while destination.exists():
|
||||||
|
destination = deleted_bucket / f"{folder_name}_{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
|
||||||
|
return destination
|
||||||
@@ -5,20 +5,20 @@ from typing import Dict, List, Optional
|
|||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from ..utils.models import LoraMetadata
|
from ..utils.models import LoraMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.routes_common import ModelRouteUtils
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class LoraService(BaseModelService):
|
class LoraService(BaseModelService):
|
||||||
"""LoRA-specific service implementation"""
|
"""LoRA-specific service implementation"""
|
||||||
|
|
||||||
def __init__(self, scanner):
|
def __init__(self, scanner, update_service=None):
|
||||||
"""Initialize LoRA service
|
"""Initialize LoRA service
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
scanner: LoRA scanner instance
|
scanner: LoRA scanner instance
|
||||||
|
update_service: Optional service for remote update tracking.
|
||||||
"""
|
"""
|
||||||
super().__init__("lora", scanner, LoraMetadata)
|
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, lora_data: Dict) -> Dict:
|
async def format_response(self, lora_data: Dict) -> Dict:
|
||||||
"""Format LoRA data for API response"""
|
"""Format LoRA data for API response"""
|
||||||
@@ -38,7 +38,8 @@ class LoraService(BaseModelService):
|
|||||||
"usage_tips": lora_data.get("usage_tips", ""),
|
"usage_tips": lora_data.get("usage_tips", ""),
|
||||||
"notes": lora_data.get("notes", ""),
|
"notes": lora_data.get("notes", ""),
|
||||||
"favorite": lora_data.get("favorite", False),
|
"favorite": lora_data.get("favorite", False),
|
||||||
"civitai": ModelRouteUtils.filter_civitai_data(lora_data.get("civitai", {}), minimal=True)
|
"update_available": bool(lora_data.get("update_available", False)),
|
||||||
|
"civitai": self.filter_civitai_data(lora_data.get("civitai", {}), minimal=True)
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
@@ -179,4 +180,4 @@ class LoraService(BaseModelService):
|
|||||||
|
|
||||||
def find_duplicate_filenames(self) -> Dict:
|
def find_duplicate_filenames(self) -> Dict:
|
||||||
"""Find LoRAs with conflicting filenames"""
|
"""Find LoRAs with conflicting filenames"""
|
||||||
return self.scanner._hash_index.get_duplicate_filenames()
|
return self.scanner._hash_index.get_duplicate_filenames()
|
||||||
|
|||||||
157
py/services/metadata_archive_manager.py
Normal file
157
py/services/metadata_archive_manager.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import zipfile
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from .downloader import get_downloader, DownloadProgress
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class MetadataArchiveManager:
|
||||||
|
"""Manages downloading and extracting Civitai metadata archive database"""
|
||||||
|
|
||||||
|
DOWNLOAD_URLS = [
|
||||||
|
"https://github.com/willmiao/civitai-metadata-archive-db/releases/download/db-2025-08-08/civitai.zip",
|
||||||
|
"https://huggingface.co/datasets/willmiao/civitai-metadata-archive-db/blob/main/civitai.zip"
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, base_path: str):
|
||||||
|
"""Initialize with base path where files will be stored"""
|
||||||
|
self.base_path = Path(base_path)
|
||||||
|
self.civitai_folder = self.base_path / "civitai"
|
||||||
|
self.archive_path = self.base_path / "civitai.zip"
|
||||||
|
self.db_path = self.civitai_folder / "civitai.sqlite"
|
||||||
|
|
||||||
|
def is_database_available(self) -> bool:
|
||||||
|
"""Check if the SQLite database is available and valid"""
|
||||||
|
return self.db_path.exists() and self.db_path.stat().st_size > 0
|
||||||
|
|
||||||
|
def get_database_path(self) -> Optional[str]:
|
||||||
|
"""Get the path to the SQLite database if available"""
|
||||||
|
if self.is_database_available():
|
||||||
|
return str(self.db_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def download_and_extract_database(self, progress_callback=None) -> bool:
|
||||||
|
"""Download and extract the metadata archive database
|
||||||
|
|
||||||
|
Args:
|
||||||
|
progress_callback: Optional callback function to report progress
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create directories if they don't exist
|
||||||
|
self.base_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.civitai_folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Download the archive
|
||||||
|
if not await self._download_archive(progress_callback):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Extract the archive
|
||||||
|
if not await self._extract_archive(progress_callback):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Clean up the archive file
|
||||||
|
if self.archive_path.exists():
|
||||||
|
self.archive_path.unlink()
|
||||||
|
|
||||||
|
logger.info(f"Successfully downloaded and extracted metadata database to {self.db_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error downloading and extracting metadata database: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _download_archive(self, progress_callback=None) -> bool:
|
||||||
|
"""Download the zip archive from one of the available URLs"""
|
||||||
|
downloader = await get_downloader()
|
||||||
|
|
||||||
|
for url in self.DOWNLOAD_URLS:
|
||||||
|
try:
|
||||||
|
logger.info(f"Attempting to download from {url}")
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("download", f"Downloading from {url}")
|
||||||
|
|
||||||
|
# Custom progress callback to report download progress
|
||||||
|
async def download_progress(progress, snapshot=None):
|
||||||
|
if progress_callback:
|
||||||
|
if isinstance(progress, DownloadProgress):
|
||||||
|
percent = progress.percent_complete
|
||||||
|
elif isinstance(snapshot, DownloadProgress):
|
||||||
|
percent = snapshot.percent_complete
|
||||||
|
else:
|
||||||
|
percent = float(progress or 0)
|
||||||
|
progress_callback("download", f"Downloading archive... {percent:.1f}%")
|
||||||
|
|
||||||
|
success, result = await downloader.download_file(
|
||||||
|
url=url,
|
||||||
|
save_path=str(self.archive_path),
|
||||||
|
progress_callback=download_progress,
|
||||||
|
use_auth=False, # Public download, no auth needed
|
||||||
|
allow_resume=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Successfully downloaded archive from {url}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to download from {url}: {result}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error downloading from {url}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.error("Failed to download archive from any URL")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _extract_archive(self, progress_callback=None) -> bool:
|
||||||
|
"""Extract the zip archive to the civitai folder"""
|
||||||
|
try:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("extract", "Extracting archive...")
|
||||||
|
|
||||||
|
# Run extraction in thread pool to avoid blocking
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, self._extract_zip_sync)
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("extract", "Extraction completed")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error extracting archive: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _extract_zip_sync(self):
|
||||||
|
"""Synchronous zip extraction (runs in thread pool)"""
|
||||||
|
with zipfile.ZipFile(self.archive_path, 'r') as archive:
|
||||||
|
archive.extractall(path=self.base_path)
|
||||||
|
|
||||||
|
async def remove_database(self) -> bool:
|
||||||
|
"""Remove the metadata database and folder"""
|
||||||
|
try:
|
||||||
|
if self.civitai_folder.exists():
|
||||||
|
# Remove all files in the civitai folder
|
||||||
|
for file_path in self.civitai_folder.iterdir():
|
||||||
|
if file_path.is_file():
|
||||||
|
file_path.unlink()
|
||||||
|
|
||||||
|
# Remove the folder itself
|
||||||
|
self.civitai_folder.rmdir()
|
||||||
|
|
||||||
|
# Also remove the archive file if it exists
|
||||||
|
if self.archive_path.exists():
|
||||||
|
self.archive_path.unlink()
|
||||||
|
|
||||||
|
logger.info("Successfully removed metadata database")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing metadata database: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
133
py/services/metadata_service.py
Normal file
133
py/services/metadata_service.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from .model_metadata_provider import (
|
||||||
|
ModelMetadataProvider,
|
||||||
|
ModelMetadataProviderManager,
|
||||||
|
SQLiteModelMetadataProvider,
|
||||||
|
CivitaiModelMetadataProvider,
|
||||||
|
CivArchiveModelMetadataProvider,
|
||||||
|
FallbackMetadataProvider,
|
||||||
|
RateLimitRetryingProvider,
|
||||||
|
)
|
||||||
|
from .settings_manager import get_settings_manager
|
||||||
|
from .metadata_archive_manager import MetadataArchiveManager
|
||||||
|
from .service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def initialize_metadata_providers():
|
||||||
|
"""Initialize and configure all metadata providers based on settings"""
|
||||||
|
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||||
|
|
||||||
|
# Clear existing providers to allow reinitialization
|
||||||
|
provider_manager.providers.clear()
|
||||||
|
provider_manager.default_provider = None
|
||||||
|
|
||||||
|
# Get settings
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||||
|
|
||||||
|
providers = []
|
||||||
|
|
||||||
|
# Initialize archive database provider if enabled
|
||||||
|
if enable_archive_db:
|
||||||
|
try:
|
||||||
|
# Initialize archive manager
|
||||||
|
base_path = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
archive_manager = MetadataArchiveManager(base_path)
|
||||||
|
|
||||||
|
db_path = archive_manager.get_database_path()
|
||||||
|
if db_path and os.path.exists(db_path):
|
||||||
|
sqlite_provider = SQLiteModelMetadataProvider(db_path)
|
||||||
|
provider_manager.register_provider('sqlite', sqlite_provider)
|
||||||
|
providers.append(('sqlite', sqlite_provider))
|
||||||
|
logger.debug(f"SQLite metadata provider registered with database: {db_path}")
|
||||||
|
else:
|
||||||
|
logger.warning("Metadata archive database is enabled but database file not found")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize SQLite metadata provider: {e}")
|
||||||
|
|
||||||
|
# Initialize Civitai API provider (always available as fallback)
|
||||||
|
try:
|
||||||
|
civitai_client = await ServiceRegistry.get_civitai_client()
|
||||||
|
civitai_provider = CivitaiModelMetadataProvider(civitai_client)
|
||||||
|
provider_manager.register_provider('civitai_api', civitai_provider)
|
||||||
|
providers.append(('civitai_api', civitai_provider))
|
||||||
|
logger.debug("Civitai API metadata provider registered")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
|
||||||
|
|
||||||
|
# Register CivArchive provider, and all add to fallback providers
|
||||||
|
try:
|
||||||
|
civarchive_client = await ServiceRegistry.get_civarchive_client()
|
||||||
|
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
|
||||||
|
provider_manager.register_provider('civarchive_api', civarchive_provider)
|
||||||
|
providers.append(('civarchive_api', civarchive_provider))
|
||||||
|
logger.debug("CivArchive metadata provider registered (also included in fallback)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
|
||||||
|
|
||||||
|
# Set up fallback provider based on available providers
|
||||||
|
if len(providers) > 1:
|
||||||
|
# Always use Civitai API (it has better metadata), then CivArchive API, then Archive DB
|
||||||
|
ordered_providers: list[tuple[str, ModelMetadataProvider]] = []
|
||||||
|
ordered_providers.extend([p for p in providers if p[0] == 'civitai_api'])
|
||||||
|
ordered_providers.extend([p for p in providers if p[0] == 'civarchive_api'])
|
||||||
|
ordered_providers.extend([p for p in providers if p[0] == 'sqlite'])
|
||||||
|
|
||||||
|
if ordered_providers:
|
||||||
|
fallback_provider = FallbackMetadataProvider(ordered_providers)
|
||||||
|
provider_manager.register_provider('fallback', fallback_provider, is_default=True)
|
||||||
|
elif len(providers) == 1:
|
||||||
|
# Only one provider available, set it as default
|
||||||
|
provider_name, provider = providers[0]
|
||||||
|
provider_manager.register_provider(provider_name, provider, is_default=True)
|
||||||
|
logger.debug(f"Single metadata provider registered as default: {provider_name}")
|
||||||
|
else:
|
||||||
|
logger.warning("No metadata providers available - this may cause metadata lookup failures")
|
||||||
|
|
||||||
|
return provider_manager
|
||||||
|
|
||||||
|
async def update_metadata_providers():
|
||||||
|
"""Update metadata providers based on current settings"""
|
||||||
|
try:
|
||||||
|
# Get current settings
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||||
|
|
||||||
|
# Reinitialize all providers with new settings
|
||||||
|
provider_manager = await initialize_metadata_providers()
|
||||||
|
|
||||||
|
logger.info(f"Updated metadata providers, archive_db enabled: {enable_archive_db}")
|
||||||
|
return provider_manager
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update metadata providers: {e}")
|
||||||
|
return await ModelMetadataProviderManager.get_instance()
|
||||||
|
|
||||||
|
async def get_metadata_archive_manager():
|
||||||
|
"""Get metadata archive manager instance"""
|
||||||
|
base_path = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
return MetadataArchiveManager(base_path)
|
||||||
|
|
||||||
|
def _wrap_provider_with_rate_limit(provider_name: str | None, provider: ModelMetadataProvider) -> ModelMetadataProvider:
|
||||||
|
if isinstance(provider, (FallbackMetadataProvider, RateLimitRetryingProvider)):
|
||||||
|
return provider
|
||||||
|
return RateLimitRetryingProvider(provider, label=provider_name)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_metadata_provider(provider_name: str = None):
|
||||||
|
"""Get a specific metadata provider or default provider with rate-limit handling."""
|
||||||
|
|
||||||
|
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||||
|
|
||||||
|
provider = (
|
||||||
|
provider_manager._get_provider(provider_name)
|
||||||
|
if provider_name
|
||||||
|
else provider_manager._get_provider()
|
||||||
|
)
|
||||||
|
|
||||||
|
return _wrap_provider_with_rate_limit(provider_name, provider)
|
||||||
|
|
||||||
|
async def get_default_metadata_provider():
|
||||||
|
"""Get the default metadata provider (fallback or single provider)"""
|
||||||
|
return await get_metadata_provider()
|
||||||
460
py/services/metadata_sync_service.py
Normal file
460
py/services/metadata_sync_service.py
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
"""Services for synchronising metadata with remote providers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, Iterable, Optional
|
||||||
|
|
||||||
|
from ..services.settings_manager import SettingsManager
|
||||||
|
from ..utils.civitai_utils import resolve_license_payload
|
||||||
|
from ..utils.model_utils import determine_base_model
|
||||||
|
from .errors import RateLimitError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataProviderProtocol:
|
||||||
|
"""Subset of metadata provider interface consumed by the sync service."""
|
||||||
|
|
||||||
|
async def get_model_by_hash(self, sha256: str) -> tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_model_version(
|
||||||
|
self, model_id: int, model_version_id: Optional[int]
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataSyncService:
|
||||||
|
"""High level orchestration for metadata synchronisation flows."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
metadata_manager,
|
||||||
|
preview_service,
|
||||||
|
settings: SettingsManager,
|
||||||
|
default_metadata_provider_factory: Callable[[], Awaitable[MetadataProviderProtocol]],
|
||||||
|
metadata_provider_selector: Callable[[str], Awaitable[MetadataProviderProtocol]],
|
||||||
|
) -> None:
|
||||||
|
self._metadata_manager = metadata_manager
|
||||||
|
self._preview_service = preview_service
|
||||||
|
self._settings = settings
|
||||||
|
self._get_default_provider = default_metadata_provider_factory
|
||||||
|
self._get_provider = metadata_provider_selector
|
||||||
|
|
||||||
|
async def load_local_metadata(self, metadata_path: str) -> Dict[str, Any]:
|
||||||
|
"""Load metadata JSON from disk, returning an empty structure when missing."""
|
||||||
|
|
||||||
|
if not os.path.exists(metadata_path):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Error loading metadata from %s: %s", metadata_path, exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def mark_not_found_on_civitai(
|
||||||
|
self, metadata_path: str, local_metadata: Dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Persist the not-found flag for a metadata payload."""
|
||||||
|
|
||||||
|
local_metadata["from_civitai"] = False
|
||||||
|
await self._metadata_manager.save_metadata(metadata_path, local_metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_civitai_api_metadata(meta: Dict[str, Any]) -> bool:
|
||||||
|
"""Determine if the metadata originated from the CivitAI public API."""
|
||||||
|
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
return False
|
||||||
|
files = meta.get("files")
|
||||||
|
images = meta.get("images")
|
||||||
|
source = meta.get("source")
|
||||||
|
return bool(files) and bool(images) and source != "archive_db"
|
||||||
|
|
||||||
|
async def update_model_metadata(
|
||||||
|
self,
|
||||||
|
metadata_path: str,
|
||||||
|
local_metadata: Dict[str, Any],
|
||||||
|
civitai_metadata: Dict[str, Any],
|
||||||
|
metadata_provider: Optional[MetadataProviderProtocol] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Merge remote metadata into the local record and persist the result."""
|
||||||
|
|
||||||
|
existing_civitai = local_metadata.get("civitai") or {}
|
||||||
|
|
||||||
|
if (
|
||||||
|
civitai_metadata.get("source") == "archive_db"
|
||||||
|
and self.is_civitai_api_metadata(existing_civitai)
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"Skip civitai update for %s (%s)",
|
||||||
|
local_metadata.get("model_name", ""),
|
||||||
|
existing_civitai.get("name", ""),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
merged_civitai = existing_civitai.copy()
|
||||||
|
merged_civitai.update(civitai_metadata)
|
||||||
|
|
||||||
|
if civitai_metadata.get("source") == "archive_db":
|
||||||
|
model_name = civitai_metadata.get("model", {}).get("name", "")
|
||||||
|
version_name = civitai_metadata.get("name", "")
|
||||||
|
logger.info(
|
||||||
|
"Recovered metadata from archive_db for deleted model: %s (%s)",
|
||||||
|
model_name,
|
||||||
|
version_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "trainedWords" in existing_civitai:
|
||||||
|
existing_trained = existing_civitai.get("trainedWords", [])
|
||||||
|
new_trained = civitai_metadata.get("trainedWords", [])
|
||||||
|
merged_trained = list(set(existing_trained + new_trained))
|
||||||
|
merged_civitai["trainedWords"] = merged_trained
|
||||||
|
|
||||||
|
local_metadata["civitai"] = merged_civitai
|
||||||
|
|
||||||
|
if "model" in civitai_metadata and civitai_metadata["model"]:
|
||||||
|
model_data = civitai_metadata["model"]
|
||||||
|
|
||||||
|
if model_data.get("name"):
|
||||||
|
local_metadata["model_name"] = model_data["name"]
|
||||||
|
|
||||||
|
if not local_metadata.get("modelDescription") and model_data.get("description"):
|
||||||
|
local_metadata["modelDescription"] = model_data["description"]
|
||||||
|
|
||||||
|
if not local_metadata.get("tags") and model_data.get("tags"):
|
||||||
|
local_metadata["tags"] = model_data["tags"]
|
||||||
|
|
||||||
|
if model_data.get("creator") and not local_metadata.get("civitai", {}).get(
|
||||||
|
"creator"
|
||||||
|
):
|
||||||
|
local_metadata.setdefault("civitai", {})["creator"] = model_data["creator"]
|
||||||
|
|
||||||
|
merged_civitai = local_metadata.get("civitai") or {}
|
||||||
|
civitai_model = merged_civitai.get("model")
|
||||||
|
if not isinstance(civitai_model, dict):
|
||||||
|
civitai_model = {}
|
||||||
|
|
||||||
|
license_payload = resolve_license_payload(model_data)
|
||||||
|
civitai_model.update(license_payload)
|
||||||
|
|
||||||
|
merged_civitai["model"] = civitai_model
|
||||||
|
local_metadata["civitai"] = merged_civitai
|
||||||
|
|
||||||
|
local_metadata["base_model"] = determine_base_model(
|
||||||
|
civitai_metadata.get("baseModel")
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._preview_service.ensure_preview_for_metadata(
|
||||||
|
metadata_path, local_metadata, civitai_metadata.get("images", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._metadata_manager.save_metadata(metadata_path, local_metadata)
|
||||||
|
return local_metadata
|
||||||
|
|
||||||
|
async def fetch_and_update_model(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
sha256: str,
|
||||||
|
file_path: str,
|
||||||
|
model_data: Dict[str, Any],
|
||||||
|
update_cache_func: Callable[[str, str, Dict[str, Any]], Awaitable[bool]],
|
||||||
|
) -> tuple[bool, Optional[str]]:
|
||||||
|
"""Fetch metadata for a model and update both disk and cache state.
|
||||||
|
|
||||||
|
Callers should hydrate ``model_data`` via ``MetadataManager.hydrate_model_data``
|
||||||
|
before invoking this method so that the persisted payload retains all known
|
||||||
|
metadata fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(model_data, dict):
|
||||||
|
error = f"Invalid model_data type: {type(model_data)}"
|
||||||
|
logger.error(error)
|
||||||
|
return False, error
|
||||||
|
|
||||||
|
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||||
|
enable_archive = self._settings.get("enable_metadata_archive_db", False)
|
||||||
|
previous_source = model_data.get("metadata_source") or (model_data.get("civitai") or {}).get("source")
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider_attempts: list[tuple[Optional[str], MetadataProviderProtocol]] = []
|
||||||
|
sqlite_attempted = False
|
||||||
|
|
||||||
|
if model_data.get("civitai_deleted") is True:
|
||||||
|
if previous_source in (None, "civarchive"):
|
||||||
|
try:
|
||||||
|
provider_attempts.append(("civarchive_api", await self._get_provider("civarchive_api")))
|
||||||
|
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||||
|
logger.debug("Unable to resolve civarchive provider: %s", exc)
|
||||||
|
|
||||||
|
if enable_archive and model_data.get("db_checked") is not True:
|
||||||
|
try:
|
||||||
|
provider_attempts.append(("sqlite", await self._get_provider("sqlite")))
|
||||||
|
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||||
|
logger.debug("Unable to resolve sqlite provider: %s", exc)
|
||||||
|
|
||||||
|
if not provider_attempts:
|
||||||
|
if not enable_archive:
|
||||||
|
error_msg = "CivitAI model is deleted and metadata archive DB is not enabled"
|
||||||
|
elif model_data.get("db_checked") is True:
|
||||||
|
error_msg = "CivitAI model is deleted and not found in metadata archive DB"
|
||||||
|
else:
|
||||||
|
error_msg = "CivitAI model is deleted and no archive provider is available"
|
||||||
|
return False, error_msg
|
||||||
|
else:
|
||||||
|
provider_attempts.append((None, await self._get_default_provider()))
|
||||||
|
|
||||||
|
civitai_metadata: Optional[Dict[str, Any]] = None
|
||||||
|
metadata_provider: Optional[MetadataProviderProtocol] = None
|
||||||
|
provider_used: Optional[str] = None
|
||||||
|
last_error: Optional[str] = None
|
||||||
|
civitai_api_not_found = False
|
||||||
|
|
||||||
|
for provider_name, provider in provider_attempts:
|
||||||
|
try:
|
||||||
|
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
|
||||||
|
except RateLimitError as exc:
|
||||||
|
exc.provider = exc.provider or (provider_name or provider.__class__.__name__)
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
|
||||||
|
civitai_metadata_candidate, error = None, str(exc)
|
||||||
|
|
||||||
|
if provider_name == "sqlite":
|
||||||
|
sqlite_attempted = True
|
||||||
|
|
||||||
|
is_default_provider = provider_name is None
|
||||||
|
|
||||||
|
if civitai_metadata_candidate:
|
||||||
|
civitai_metadata = civitai_metadata_candidate
|
||||||
|
metadata_provider = provider
|
||||||
|
provider_used = provider_name
|
||||||
|
break
|
||||||
|
|
||||||
|
if is_default_provider and error == "Model not found":
|
||||||
|
civitai_api_not_found = True
|
||||||
|
|
||||||
|
last_error = error or last_error
|
||||||
|
|
||||||
|
if civitai_metadata is None or metadata_provider is None:
|
||||||
|
if sqlite_attempted:
|
||||||
|
model_data["db_checked"] = True
|
||||||
|
|
||||||
|
if civitai_api_not_found:
|
||||||
|
model_data["from_civitai"] = False
|
||||||
|
model_data["civitai_deleted"] = True
|
||||||
|
model_data["db_checked"] = sqlite_attempted or (enable_archive and model_data.get("db_checked", False))
|
||||||
|
model_data["last_checked_at"] = datetime.now().timestamp()
|
||||||
|
|
||||||
|
data_to_save = model_data.copy()
|
||||||
|
data_to_save.pop("folder", None)
|
||||||
|
await self._metadata_manager.save_metadata(file_path, data_to_save)
|
||||||
|
|
||||||
|
default_error = (
|
||||||
|
"CivitAI model is deleted and metadata archive DB is not enabled"
|
||||||
|
if model_data.get("civitai_deleted") and not enable_archive
|
||||||
|
else "CivitAI model is deleted and not found in metadata archive DB"
|
||||||
|
if model_data.get("civitai_deleted") and (model_data.get("db_checked") is True or sqlite_attempted)
|
||||||
|
else "No provider returned metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
error_msg = (
|
||||||
|
f"Error fetching metadata: {last_error or default_error} "
|
||||||
|
f"(model_name={model_data.get('model_name', '')})"
|
||||||
|
)
|
||||||
|
logger.error(error_msg)
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
|
model_data["from_civitai"] = True
|
||||||
|
if provider_used is None:
|
||||||
|
model_data["civitai_deleted"] = False
|
||||||
|
elif civitai_api_not_found:
|
||||||
|
model_data["civitai_deleted"] = True
|
||||||
|
model_data["db_checked"] = enable_archive and (
|
||||||
|
civitai_metadata.get("source") == "archive_db" or sqlite_attempted
|
||||||
|
)
|
||||||
|
source = civitai_metadata.get("source") or "civitai_api"
|
||||||
|
if source == "api":
|
||||||
|
source = "civitai_api"
|
||||||
|
elif provider_used == "civarchive_api" and source != "civarchive":
|
||||||
|
source = "civarchive"
|
||||||
|
elif provider_used == "sqlite":
|
||||||
|
source = "archive_db"
|
||||||
|
model_data["metadata_source"] = source
|
||||||
|
model_data["last_checked_at"] = datetime.now().timestamp()
|
||||||
|
|
||||||
|
readable_source = {
|
||||||
|
"civitai_api": "CivitAI API",
|
||||||
|
"civarchive": "CivArchive API",
|
||||||
|
"archive_db": "Archive Database",
|
||||||
|
}.get(source, source)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Fetched metadata for %s via %s",
|
||||||
|
model_data.get("model_name", ""),
|
||||||
|
readable_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
local_metadata = model_data.copy()
|
||||||
|
local_metadata.pop("folder", None)
|
||||||
|
|
||||||
|
await self.update_model_metadata(
|
||||||
|
metadata_path,
|
||||||
|
local_metadata,
|
||||||
|
civitai_metadata,
|
||||||
|
metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
update_payload = {
|
||||||
|
"model_name": local_metadata.get("model_name"),
|
||||||
|
"preview_url": local_metadata.get("preview_url"),
|
||||||
|
"civitai": local_metadata.get("civitai"),
|
||||||
|
}
|
||||||
|
|
||||||
|
model_data.update(update_payload)
|
||||||
|
|
||||||
|
await update_cache_func(file_path, file_path, local_metadata)
|
||||||
|
return True, None
|
||||||
|
except KeyError as exc:
|
||||||
|
error_msg = f"Error fetching metadata - Missing key: {exc} in model_data={model_data}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return False, error_msg
|
||||||
|
except RateLimitError as exc:
|
||||||
|
provider_label = exc.provider or "metadata provider"
|
||||||
|
wait_hint = (
|
||||||
|
f"; retry after approximately {int(exc.retry_after)}s"
|
||||||
|
if exc.retry_after and exc.retry_after > 0
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
error_msg = f"Rate limited by {provider_label}{wait_hint}"
|
||||||
|
logger.warning(error_msg)
|
||||||
|
return False, error_msg
|
||||||
|
except Exception as exc: # pragma: no cover - error path
|
||||||
|
error_msg = f"Error fetching metadata: {exc}"
|
||||||
|
logger.error(error_msg, exc_info=True)
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
|
async def fetch_metadata_by_sha(
|
||||||
|
self, sha256: str, metadata_provider: Optional[MetadataProviderProtocol] = None
|
||||||
|
) -> tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
|
"""Fetch metadata for a SHA256 hash from the configured provider."""
|
||||||
|
|
||||||
|
provider = metadata_provider or await self._get_default_provider()
|
||||||
|
return await provider.get_model_by_hash(sha256)
|
||||||
|
|
||||||
|
async def relink_metadata(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
file_path: str,
|
||||||
|
metadata: Dict[str, Any],
|
||||||
|
model_id: int,
|
||||||
|
model_version_id: Optional[int],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Relink a local metadata record to a specific CivitAI model version."""
|
||||||
|
|
||||||
|
provider = await self._get_default_provider()
|
||||||
|
civitai_metadata = await provider.get_model_version(model_id, model_version_id)
|
||||||
|
if not civitai_metadata:
|
||||||
|
raise ValueError(
|
||||||
|
f"Model version not found on CivitAI for ID: {model_id}"
|
||||||
|
+ (f" with version: {model_version_id}" if model_version_id else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||||
|
await self.update_model_metadata(
|
||||||
|
metadata_path,
|
||||||
|
metadata,
|
||||||
|
civitai_metadata,
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
async def save_metadata_updates(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
file_path: str,
|
||||||
|
updates: Dict[str, Any],
|
||||||
|
metadata_loader: Callable[[str], Awaitable[Dict[str, Any]]],
|
||||||
|
update_cache: Callable[[str, str, Dict[str, Any]], Awaitable[bool]],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Apply metadata updates and persist to disk and cache."""
|
||||||
|
|
||||||
|
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||||
|
metadata = await metadata_loader(metadata_path)
|
||||||
|
|
||||||
|
for key, value in updates.items():
|
||||||
|
if isinstance(value, dict) and isinstance(metadata.get(key), dict):
|
||||||
|
metadata[key].update(value)
|
||||||
|
else:
|
||||||
|
metadata[key] = value
|
||||||
|
|
||||||
|
await self._metadata_manager.save_metadata(file_path, metadata)
|
||||||
|
await update_cache(file_path, file_path, metadata)
|
||||||
|
|
||||||
|
if "model_name" in updates:
|
||||||
|
logger.debug("Metadata update touched model_name; cache resort required")
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
async def verify_duplicate_hashes(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
file_paths: Iterable[str],
|
||||||
|
metadata_loader: Callable[[str], Awaitable[Dict[str, Any]]],
|
||||||
|
hash_calculator: Callable[[str], Awaitable[str]],
|
||||||
|
update_cache: Callable[[str, str, Dict[str, Any]], Awaitable[bool]],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Verify a collection of files share the same SHA256 hash."""
|
||||||
|
|
||||||
|
file_paths = list(file_paths)
|
||||||
|
if not file_paths:
|
||||||
|
raise ValueError("No file paths provided for verification")
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"verified_as_duplicates": True,
|
||||||
|
"mismatched_files": [],
|
||||||
|
"new_hash_map": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_hash: Optional[str] = None
|
||||||
|
first_metadata_path = os.path.splitext(file_paths[0])[0] + ".metadata.json"
|
||||||
|
first_metadata = await metadata_loader(first_metadata_path)
|
||||||
|
if first_metadata and "sha256" in first_metadata:
|
||||||
|
expected_hash = first_metadata["sha256"].lower()
|
||||||
|
|
||||||
|
for path in file_paths:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
actual_hash = await hash_calculator(path)
|
||||||
|
metadata_path = os.path.splitext(path)[0] + ".metadata.json"
|
||||||
|
metadata = await metadata_loader(metadata_path)
|
||||||
|
stored_hash = metadata.get("sha256", "").lower()
|
||||||
|
|
||||||
|
if not expected_hash:
|
||||||
|
expected_hash = stored_hash
|
||||||
|
|
||||||
|
if actual_hash != expected_hash:
|
||||||
|
results["verified_as_duplicates"] = False
|
||||||
|
results["mismatched_files"].append(path)
|
||||||
|
results["new_hash_map"][path] = actual_hash
|
||||||
|
|
||||||
|
if actual_hash != stored_hash:
|
||||||
|
metadata["sha256"] = actual_hash
|
||||||
|
await self._metadata_manager.save_metadata(path, metadata)
|
||||||
|
await update_cache(path, path, metadata)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive path
|
||||||
|
logger.error("Error verifying hash for %s: %s", path, exc)
|
||||||
|
results["mismatched_files"].append(path)
|
||||||
|
results["new_hash_map"][path] = "error_calculating_hash"
|
||||||
|
results["verified_as_duplicates"] = False
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import List, Dict, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
from natsort import natsorted
|
from natsort import natsorted
|
||||||
|
|
||||||
@@ -15,19 +15,182 @@ SUPPORTED_SORT_MODES = [
|
|||||||
('size', 'desc'),
|
('size', 'desc'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
DISPLAY_NAME_MODES = {"model_name", "file_name"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ModelCache:
|
class ModelCache:
|
||||||
"""Cache structure for model data with extensible sorting"""
|
"""Cache structure for model data with extensible sorting."""
|
||||||
|
|
||||||
raw_data: List[Dict]
|
raw_data: List[Dict]
|
||||||
folders: List[str]
|
folders: List[str]
|
||||||
|
version_index: Dict[int, Dict] = field(default_factory=dict)
|
||||||
|
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||||
|
name_display_mode: str = "model_name"
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
# Cache for last sort: (sort_key, order) -> sorted list
|
# Cache for last sort: (sort_key, order) -> sorted list
|
||||||
self._last_sort: Tuple[str, str] = (None, None)
|
self._last_sort: Tuple[str, str] = (None, None)
|
||||||
self._last_sorted_data: List[Dict] = []
|
self._last_sorted_data: List[Dict] = []
|
||||||
|
self._normalize_raw_data()
|
||||||
|
self.name_display_mode = self._normalize_display_mode(self.name_display_mode)
|
||||||
# Default sort on init
|
# Default sort on init
|
||||||
asyncio.create_task(self.resort())
|
asyncio.create_task(self.resort())
|
||||||
|
self.rebuild_version_index()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_display_mode(value: Optional[str]) -> str:
|
||||||
|
if isinstance(value, str) and value in DISPLAY_NAME_MODES:
|
||||||
|
return value
|
||||||
|
return "model_name"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_string(value: Any) -> str:
|
||||||
|
"""Return a safe string representation for metadata fields."""
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def _normalize_item(self, item: Dict) -> None:
|
||||||
|
"""Ensure core metadata fields are present and string typed."""
|
||||||
|
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
for field in ("model_name", "file_name", "folder"):
|
||||||
|
if field in item:
|
||||||
|
item[field] = self._ensure_string(item.get(field))
|
||||||
|
|
||||||
|
def _normalize_raw_data(self) -> None:
|
||||||
|
"""Normalize every cached entry before it is consumed."""
|
||||||
|
|
||||||
|
for item in self.raw_data:
|
||||||
|
self._normalize_item(item)
|
||||||
|
|
||||||
|
def _get_display_name(self, item: Dict) -> str:
|
||||||
|
"""Return the value used for name-based sorting based on display settings."""
|
||||||
|
|
||||||
|
if self.name_display_mode == "file_name":
|
||||||
|
primary = self._ensure_string(item.get("file_name"))
|
||||||
|
fallback = self._ensure_string(item.get("model_name"))
|
||||||
|
else:
|
||||||
|
primary = self._ensure_string(item.get("model_name"))
|
||||||
|
fallback = self._ensure_string(item.get("file_name"))
|
||||||
|
|
||||||
|
candidate = primary or fallback
|
||||||
|
return candidate or ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_version_id(value: Any) -> Optional[int]:
|
||||||
|
"""Normalize a potential version identifier into an integer."""
|
||||||
|
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def rebuild_version_index(self) -> None:
|
||||||
|
"""Rebuild the version and model indexes from the current raw data."""
|
||||||
|
|
||||||
|
self.version_index = {}
|
||||||
|
self.model_id_index = {}
|
||||||
|
for item in self.raw_data:
|
||||||
|
self.add_to_version_index(item)
|
||||||
|
|
||||||
|
def add_to_version_index(self, item: Dict) -> None:
|
||||||
|
"""Register a cache item in the version/model indexes if possible."""
|
||||||
|
|
||||||
|
civitai_data = item.get('civitai') if isinstance(item, dict) else None
|
||||||
|
if not isinstance(civitai_data, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
version_id = self._normalize_version_id(civitai_data.get('id'))
|
||||||
|
if version_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.version_index[version_id] = item
|
||||||
|
|
||||||
|
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||||
|
if model_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
descriptor = self._build_version_descriptor(item, civitai_data, version_id)
|
||||||
|
if descriptor is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
versions = self.model_id_index.setdefault(model_id, [])
|
||||||
|
for index, existing in enumerate(versions):
|
||||||
|
if existing.get('versionId') == descriptor['versionId']:
|
||||||
|
versions[index] = descriptor
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
versions.append(descriptor)
|
||||||
|
|
||||||
|
def remove_from_version_index(self, item: Dict) -> None:
|
||||||
|
"""Remove a cache item from the version/model indexes if present."""
|
||||||
|
|
||||||
|
civitai_data = item.get('civitai') if isinstance(item, dict) else None
|
||||||
|
if not isinstance(civitai_data, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
version_id = self._normalize_version_id(civitai_data.get('id'))
|
||||||
|
if version_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
existing = self.version_index.get(version_id)
|
||||||
|
if existing is item or (
|
||||||
|
isinstance(existing, dict)
|
||||||
|
and existing.get('file_path') == item.get('file_path')
|
||||||
|
):
|
||||||
|
self.version_index.pop(version_id, None)
|
||||||
|
|
||||||
|
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||||
|
if model_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
versions = self.model_id_index.get(model_id)
|
||||||
|
if not versions:
|
||||||
|
return
|
||||||
|
|
||||||
|
filtered = [v for v in versions if v.get('versionId') != version_id]
|
||||||
|
if filtered:
|
||||||
|
self.model_id_index[model_id] = filtered
|
||||||
|
else:
|
||||||
|
self.model_id_index.pop(model_id, None)
|
||||||
|
|
||||||
|
def _build_version_descriptor(
|
||||||
|
self,
|
||||||
|
item: Dict,
|
||||||
|
civitai_data: Dict[str, Any],
|
||||||
|
version_id: int,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Create a lightweight descriptor for a version entry."""
|
||||||
|
|
||||||
|
model_name = self._ensure_string(civitai_data.get('name'))
|
||||||
|
file_name = self._ensure_string(item.get('file_name'))
|
||||||
|
return {
|
||||||
|
'versionId': version_id,
|
||||||
|
'name': model_name,
|
||||||
|
'fileName': file_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_versions_by_model_id(self, model_id: Any) -> List[Dict[str, Any]]:
|
||||||
|
"""Return cached version descriptors for a given model ID."""
|
||||||
|
|
||||||
|
normalized_id = self._normalize_version_id(model_id)
|
||||||
|
if normalized_id is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
versions = self.model_id_index.get(normalized_id, [])
|
||||||
|
return [dict(version) for version in versions]
|
||||||
|
|
||||||
async def resort(self):
|
async def resort(self):
|
||||||
"""Resort cached data according to last sort mode if set"""
|
"""Resort cached data according to last sort mode if set"""
|
||||||
@@ -39,17 +202,22 @@ class ModelCache:
|
|||||||
# Update folder list
|
# Update folder list
|
||||||
# else: do nothing
|
# else: do nothing
|
||||||
|
|
||||||
all_folders = set(l['folder'] for l in self.raw_data)
|
all_folders = {
|
||||||
|
self._ensure_string(item.get('folder'))
|
||||||
|
for item in self.raw_data
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
self.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
self.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||||
|
self.rebuild_version_index()
|
||||||
|
|
||||||
def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]:
|
def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]:
|
||||||
"""Sort data by sort_key and order"""
|
"""Sort data by sort_key and order"""
|
||||||
reverse = (order == 'desc')
|
reverse = (order == 'desc')
|
||||||
if sort_key == 'name':
|
if sort_key == 'name':
|
||||||
# Natural sort by model_name, case-insensitive
|
# Natural sort by configured display name, case-insensitive
|
||||||
return natsorted(
|
return natsorted(
|
||||||
data,
|
data,
|
||||||
key=lambda x: x['model_name'].lower(),
|
key=lambda x: self._get_display_name(x).lower(),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
elif sort_key == 'date':
|
elif sort_key == 'date':
|
||||||
@@ -80,6 +248,20 @@ class ModelCache:
|
|||||||
self._last_sorted_data = sorted_data
|
self._last_sorted_data = sorted_data
|
||||||
return sorted_data
|
return sorted_data
|
||||||
|
|
||||||
|
async def update_name_display_mode(self, display_mode: str) -> None:
|
||||||
|
"""Update the display mode used for name sorting and refresh cached results."""
|
||||||
|
|
||||||
|
normalized = self._normalize_display_mode(display_mode)
|
||||||
|
async with self._lock:
|
||||||
|
if self.name_display_mode == normalized:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.name_display_mode = normalized
|
||||||
|
|
||||||
|
if self._last_sort[0] == 'name':
|
||||||
|
sort_key, order = self._last_sort
|
||||||
|
self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order)
|
||||||
|
|
||||||
async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool:
|
async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool:
|
||||||
"""Update preview_url for a specific model in all cached data
|
"""Update preview_url for a specific model in all cached data
|
||||||
|
|
||||||
|
|||||||
541
py/services/model_file_service.py
Normal file
541
py/services/model_file_service.py
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
import asyncio
|
||||||
|
import fnmatch
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence, Set
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
||||||
|
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||||
|
from ..services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressCallback(ABC):
|
||||||
|
"""Abstract callback interface for progress reporting"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def on_progress(self, progress_data: Dict[str, Any]) -> None:
|
||||||
|
"""Called when progress is updated"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AutoOrganizeResult:
|
||||||
|
"""Result object for auto-organize operations"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.total: int = 0
|
||||||
|
self.processed: int = 0
|
||||||
|
self.success_count: int = 0
|
||||||
|
self.failure_count: int = 0
|
||||||
|
self.skipped_count: int = 0
|
||||||
|
self.operation_type: str = 'unknown'
|
||||||
|
self.cleanup_counts: Dict[str, int] = {}
|
||||||
|
self.results: List[Dict[str, Any]] = []
|
||||||
|
self.results_truncated: bool = False
|
||||||
|
self.sample_results: List[Dict[str, Any]] = []
|
||||||
|
self.is_flat_structure: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert result to dictionary"""
|
||||||
|
result = {
|
||||||
|
'success': True,
|
||||||
|
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total',
|
||||||
|
'summary': {
|
||||||
|
'total': self.total,
|
||||||
|
'success': self.success_count,
|
||||||
|
'skipped': self.skipped_count,
|
||||||
|
'failures': self.failure_count,
|
||||||
|
'organization_type': 'flat' if self.is_flat_structure else 'structured',
|
||||||
|
'cleaned_dirs': self.cleanup_counts,
|
||||||
|
'operation_type': self.operation_type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.results_truncated:
|
||||||
|
result['results_truncated'] = True
|
||||||
|
result['sample_results'] = self.sample_results
|
||||||
|
else:
|
||||||
|
result['results'] = self.results
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class ModelFileService:
|
||||||
|
"""Service for handling model file operations and organization"""
|
||||||
|
|
||||||
|
def __init__(self, scanner, model_type: str):
|
||||||
|
"""Initialize the service
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scanner: Model scanner instance
|
||||||
|
model_type: Type of model (e.g., 'lora', 'checkpoint')
|
||||||
|
"""
|
||||||
|
self.scanner = scanner
|
||||||
|
self.model_type = model_type
|
||||||
|
|
||||||
|
def get_model_roots(self) -> List[str]:
|
||||||
|
"""Get model root directories"""
|
||||||
|
return self.scanner.get_model_roots()
|
||||||
|
|
||||||
|
async def auto_organize_models(
|
||||||
|
self,
|
||||||
|
file_paths: Optional[List[str]] = None,
|
||||||
|
progress_callback: Optional[ProgressCallback] = None,
|
||||||
|
exclusion_patterns: Optional[Sequence[str]] = None,
|
||||||
|
) -> AutoOrganizeResult:
|
||||||
|
"""Auto-organize models based on current settings
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_paths: Optional list of specific file paths to organize.
|
||||||
|
If None, organizes all models.
|
||||||
|
progress_callback: Optional callback for progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AutoOrganizeResult object with operation results
|
||||||
|
"""
|
||||||
|
result = AutoOrganizeResult()
|
||||||
|
source_directories: Set[str] = set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get all models from cache
|
||||||
|
cache = await self.scanner.get_cached_data()
|
||||||
|
all_models = cache.raw_data
|
||||||
|
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
normalized_exclusions = settings_manager.normalize_auto_organize_exclusions(
|
||||||
|
exclusion_patterns
|
||||||
|
if exclusion_patterns is not None
|
||||||
|
else settings_manager.get_auto_organize_exclusions()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter models if specific file paths are provided
|
||||||
|
if file_paths:
|
||||||
|
all_models = [model for model in all_models if model.get('file_path') in file_paths]
|
||||||
|
result.operation_type = 'bulk'
|
||||||
|
else:
|
||||||
|
result.operation_type = 'all'
|
||||||
|
|
||||||
|
model_roots = self.get_model_roots()
|
||||||
|
if not model_roots:
|
||||||
|
raise ValueError('No model roots configured')
|
||||||
|
|
||||||
|
if normalized_exclusions:
|
||||||
|
all_models = [
|
||||||
|
model
|
||||||
|
for model in all_models
|
||||||
|
if not self._should_exclude_model(
|
||||||
|
model.get('file_path'), normalized_exclusions, model_roots
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Check if flat structure is configured for this model type
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
path_template = settings_manager.get_download_path_template(self.model_type)
|
||||||
|
result.is_flat_structure = not path_template
|
||||||
|
|
||||||
|
# Initialize tracking
|
||||||
|
result.total = len(all_models)
|
||||||
|
|
||||||
|
# Send initial progress
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback.on_progress({
|
||||||
|
'type': 'auto_organize_progress',
|
||||||
|
'status': 'started',
|
||||||
|
'total': result.total,
|
||||||
|
'processed': 0,
|
||||||
|
'success': 0,
|
||||||
|
'failures': 0,
|
||||||
|
'skipped': 0,
|
||||||
|
'operation_type': result.operation_type
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.total == 0:
|
||||||
|
if progress_callback:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
payload = {
|
||||||
|
'type': 'auto_organize_progress',
|
||||||
|
'total': 0,
|
||||||
|
'processed': 0,
|
||||||
|
'success': 0,
|
||||||
|
'failures': 0,
|
||||||
|
'skipped': 0,
|
||||||
|
'operation_type': result.operation_type
|
||||||
|
}
|
||||||
|
await progress_callback.on_progress({**payload, 'status': 'processing'})
|
||||||
|
await progress_callback.on_progress({
|
||||||
|
**payload,
|
||||||
|
'status': 'cleaning',
|
||||||
|
'message': 'Cleaning up empty directories...'
|
||||||
|
})
|
||||||
|
result.cleanup_counts = {}
|
||||||
|
await progress_callback.on_progress({
|
||||||
|
**payload,
|
||||||
|
'status': 'completed',
|
||||||
|
'cleanup': result.cleanup_counts
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Process models in batches
|
||||||
|
await self._process_models_in_batches(
|
||||||
|
all_models,
|
||||||
|
model_roots,
|
||||||
|
result,
|
||||||
|
progress_callback,
|
||||||
|
source_directories # Pass the set to track source directories
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send cleanup progress
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback.on_progress({
|
||||||
|
'type': 'auto_organize_progress',
|
||||||
|
'status': 'cleaning',
|
||||||
|
'total': result.total,
|
||||||
|
'processed': result.processed,
|
||||||
|
'success': result.success_count,
|
||||||
|
'failures': result.failure_count,
|
||||||
|
'skipped': result.skipped_count,
|
||||||
|
'message': 'Cleaning up empty directories...',
|
||||||
|
'operation_type': result.operation_type
|
||||||
|
})
|
||||||
|
|
||||||
|
# Clean up empty directories - only in affected directories for bulk operations
|
||||||
|
cleanup_paths = list(source_directories) if result.operation_type == 'bulk' else model_roots
|
||||||
|
result.cleanup_counts = await self._cleanup_empty_directories(cleanup_paths)
|
||||||
|
|
||||||
|
# Send completion message
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback.on_progress({
|
||||||
|
'type': 'auto_organize_progress',
|
||||||
|
'status': 'completed',
|
||||||
|
'total': result.total,
|
||||||
|
'processed': result.processed,
|
||||||
|
'success': result.success_count,
|
||||||
|
'failures': result.failure_count,
|
||||||
|
'skipped': result.skipped_count,
|
||||||
|
'cleanup': result.cleanup_counts,
|
||||||
|
'operation_type': result.operation_type
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in auto_organize_models: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Send error message
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback.on_progress({
|
||||||
|
'type': 'auto_organize_progress',
|
||||||
|
'status': 'error',
|
||||||
|
'error': str(e),
|
||||||
|
'operation_type': result.operation_type
|
||||||
|
})
|
||||||
|
|
||||||
|
raise e
|
||||||
|
|
||||||
|
async def _process_models_in_batches(
|
||||||
|
self,
|
||||||
|
all_models: List[Dict[str, Any]],
|
||||||
|
model_roots: List[str],
|
||||||
|
result: AutoOrganizeResult,
|
||||||
|
progress_callback: Optional[ProgressCallback],
|
||||||
|
source_directories: Optional[Set[str]] = None
|
||||||
|
) -> None:
|
||||||
|
"""Process models in batches to avoid overwhelming the system"""
|
||||||
|
|
||||||
|
for i in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
|
||||||
|
batch = all_models[i:i + AUTO_ORGANIZE_BATCH_SIZE]
|
||||||
|
|
||||||
|
for model in batch:
|
||||||
|
await self._process_single_model(model, model_roots, result, source_directories)
|
||||||
|
result.processed += 1
|
||||||
|
|
||||||
|
# Send progress update after each batch
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback.on_progress({
|
||||||
|
'type': 'auto_organize_progress',
|
||||||
|
'status': 'processing',
|
||||||
|
'total': result.total,
|
||||||
|
'processed': result.processed,
|
||||||
|
'success': result.success_count,
|
||||||
|
'failures': result.failure_count,
|
||||||
|
'skipped': result.skipped_count,
|
||||||
|
'operation_type': result.operation_type
|
||||||
|
})
|
||||||
|
|
||||||
|
# Small delay between batches
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
async def _process_single_model(
|
||||||
|
self,
|
||||||
|
model: Dict[str, Any],
|
||||||
|
model_roots: List[str],
|
||||||
|
result: AutoOrganizeResult,
|
||||||
|
source_directories: Optional[Set[str]] = None
|
||||||
|
) -> None:
|
||||||
|
"""Process a single model for organization"""
|
||||||
|
try:
|
||||||
|
file_path = model.get('file_path')
|
||||||
|
model_name = model.get('model_name', 'Unknown')
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
self._add_result(result, model_name, False, "No file path found")
|
||||||
|
result.failure_count += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find which model root this file belongs to
|
||||||
|
current_root = self._find_model_root(file_path, model_roots)
|
||||||
|
if not current_root:
|
||||||
|
self._add_result(result, model_name, False,
|
||||||
|
"Model file not found in any configured root directory")
|
||||||
|
result.failure_count += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine target directory
|
||||||
|
target_dir = await self._calculate_target_directory(
|
||||||
|
model, current_root, result.is_flat_structure
|
||||||
|
)
|
||||||
|
|
||||||
|
if target_dir is None:
|
||||||
|
self._add_result(result, model_name, False,
|
||||||
|
"Skipped - insufficient metadata for organization")
|
||||||
|
result.skipped_count += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
current_dir = os.path.dirname(file_path)
|
||||||
|
|
||||||
|
# Skip if already in correct location
|
||||||
|
if current_dir.replace(os.sep, '/') == target_dir.replace(os.sep, '/'):
|
||||||
|
result.skipped_count += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for conflicts
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
target_file_path = os.path.join(target_dir, file_name)
|
||||||
|
|
||||||
|
if os.path.exists(target_file_path):
|
||||||
|
self._add_result(result, model_name, False,
|
||||||
|
f"Target file already exists: {target_file_path}")
|
||||||
|
result.failure_count += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store the source directory for potential cleanup
|
||||||
|
if source_directories is not None:
|
||||||
|
source_directories.add(current_dir)
|
||||||
|
|
||||||
|
# Perform the move
|
||||||
|
success = await self.scanner.move_model(file_path, target_dir)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
result.success_count += 1
|
||||||
|
else:
|
||||||
|
self._add_result(result, model_name, False, "Failed to move model")
|
||||||
|
result.failure_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing model {model.get('model_name', 'Unknown')}: {e}", exc_info=True)
|
||||||
|
self._add_result(result, model.get('model_name', 'Unknown'), False, f"Error: {str(e)}")
|
||||||
|
result.failure_count += 1
|
||||||
|
|
||||||
|
def _find_model_root(self, file_path: str, model_roots: List[str]) -> Optional[str]:
|
||||||
|
"""Find which model root the file belongs to"""
|
||||||
|
for root in model_roots:
|
||||||
|
# Normalize paths for comparison
|
||||||
|
normalized_root = os.path.normpath(root).replace(os.sep, '/')
|
||||||
|
normalized_file = os.path.normpath(file_path).replace(os.sep, '/')
|
||||||
|
|
||||||
|
if normalized_file.startswith(normalized_root):
|
||||||
|
return root
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _should_exclude_model(
|
||||||
|
self,
|
||||||
|
file_path: Optional[str],
|
||||||
|
patterns: Sequence[str],
|
||||||
|
model_roots: Sequence[str],
|
||||||
|
) -> bool:
|
||||||
|
if not file_path or not patterns:
|
||||||
|
return False
|
||||||
|
|
||||||
|
normalized_path = os.path.normpath(file_path).replace(os.sep, '/')
|
||||||
|
filename = os.path.basename(normalized_path)
|
||||||
|
relative_path = None
|
||||||
|
|
||||||
|
if model_roots:
|
||||||
|
root = self._find_model_root(file_path, list(model_roots))
|
||||||
|
if root:
|
||||||
|
normalized_root = os.path.normpath(root)
|
||||||
|
try:
|
||||||
|
relative = os.path.relpath(file_path, normalized_root)
|
||||||
|
except ValueError:
|
||||||
|
relative = None
|
||||||
|
if relative is not None:
|
||||||
|
relative_path = relative.replace(os.sep, '/')
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
if fnmatch.fnmatch(filename, pattern):
|
||||||
|
return True
|
||||||
|
if relative_path and fnmatch.fnmatch(relative_path, pattern):
|
||||||
|
return True
|
||||||
|
if fnmatch.fnmatch(normalized_path, pattern):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _calculate_target_directory(
|
||||||
|
self,
|
||||||
|
model: Dict[str, Any],
|
||||||
|
current_root: str,
|
||||||
|
is_flat_structure: bool
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Calculate the target directory for a model"""
|
||||||
|
if is_flat_structure:
|
||||||
|
file_path = model.get('file_path')
|
||||||
|
current_dir = os.path.dirname(file_path)
|
||||||
|
|
||||||
|
# Check if already in root directory
|
||||||
|
if os.path.normpath(current_dir) == os.path.normpath(current_root):
|
||||||
|
return None # Signal to skip
|
||||||
|
|
||||||
|
return current_root
|
||||||
|
else:
|
||||||
|
# Calculate new relative path based on settings
|
||||||
|
new_relative_path = calculate_relative_path_for_model(model, self.model_type)
|
||||||
|
|
||||||
|
if not new_relative_path:
|
||||||
|
return None # Signal to skip
|
||||||
|
|
||||||
|
return os.path.join(current_root, new_relative_path).replace(os.sep, '/')
|
||||||
|
|
||||||
|
def _add_result(
|
||||||
|
self,
|
||||||
|
result: AutoOrganizeResult,
|
||||||
|
model_name: str,
|
||||||
|
success: bool,
|
||||||
|
message: str
|
||||||
|
) -> None:
|
||||||
|
"""Add a result entry if under the limit"""
|
||||||
|
if len(result.results) < 100: # Limit detailed results
|
||||||
|
result.results.append({
|
||||||
|
"model": model_name,
|
||||||
|
"success": success,
|
||||||
|
"message": message
|
||||||
|
})
|
||||||
|
elif len(result.results) == 100:
|
||||||
|
# Mark as truncated and save sample
|
||||||
|
result.results_truncated = True
|
||||||
|
result.sample_results = result.results[:50]
|
||||||
|
|
||||||
|
async def _cleanup_empty_directories(self, paths: List[str]) -> Dict[str, int]:
|
||||||
|
"""Clean up empty directories after organizing
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paths: List of paths to check for empty directories
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with counts of removed directories by root path
|
||||||
|
"""
|
||||||
|
cleanup_counts = {}
|
||||||
|
for path in paths:
|
||||||
|
removed = remove_empty_dirs(path)
|
||||||
|
cleanup_counts[path] = removed
|
||||||
|
return cleanup_counts
|
||||||
|
|
||||||
|
|
||||||
|
class ModelMoveService:
|
||||||
|
"""Service for handling individual model moves"""
|
||||||
|
|
||||||
|
def __init__(self, scanner):
|
||||||
|
"""Initialize the service
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scanner: Model scanner instance
|
||||||
|
"""
|
||||||
|
self.scanner = scanner
|
||||||
|
|
||||||
|
async def move_model(self, file_path: str, target_path: str) -> Dict[str, Any]:
|
||||||
|
"""Move a single model file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Source file path
|
||||||
|
target_path: Target directory path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with move result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
source_dir = os.path.dirname(file_path)
|
||||||
|
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||||
|
logger.info(f"Source and target directories are the same: {source_dir}")
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': 'Source and target directories are the same',
|
||||||
|
'original_file_path': file_path,
|
||||||
|
'new_file_path': file_path
|
||||||
|
}
|
||||||
|
|
||||||
|
new_file_path = await self.scanner.move_model(file_path, target_path)
|
||||||
|
if new_file_path:
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'original_file_path': file_path,
|
||||||
|
'new_file_path': new_file_path
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': 'Failed to move model',
|
||||||
|
'original_file_path': file_path,
|
||||||
|
'new_file_path': None
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
'original_file_path': file_path,
|
||||||
|
'new_file_path': None
|
||||||
|
}
|
||||||
|
|
||||||
|
async def move_models_bulk(self, file_paths: List[str], target_path: str) -> Dict[str, Any]:
|
||||||
|
"""Move multiple model files
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_paths: List of source file paths
|
||||||
|
target_path: Target directory path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with bulk move results
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for file_path in file_paths:
|
||||||
|
result = await self.move_model(file_path, target_path)
|
||||||
|
results.append({
|
||||||
|
"original_file_path": file_path,
|
||||||
|
"new_file_path": result.get('new_file_path'),
|
||||||
|
"success": result['success'],
|
||||||
|
"message": result.get('message', result.get('error', 'Unknown'))
|
||||||
|
})
|
||||||
|
|
||||||
|
success_count = sum(1 for r in results if r["success"])
|
||||||
|
failure_count = len(results) - success_count
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': f'Moved {success_count} of {len(file_paths)} models',
|
||||||
|
'results': results,
|
||||||
|
'success_count': success_count,
|
||||||
|
'failure_count': failure_count
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
'results': [],
|
||||||
|
'success_count': 0,
|
||||||
|
'failure_count': len(file_paths)
|
||||||
|
}
|
||||||
346
py/services/model_lifecycle_service.py
Normal file
346
py/services/model_lifecycle_service.py
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
"""Service routines for model lifecycle mutations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..services.model_update_service import ModelUpdateService
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_model_artifacts(
|
||||||
|
target_dir: str, file_name: str, main_extension: str | None = None
|
||||||
|
) -> List[str]:
|
||||||
|
"""Delete the primary model artefacts within ``target_dir``."""
|
||||||
|
|
||||||
|
main_extension = ".safetensors" if main_extension is None else main_extension
|
||||||
|
main_file = f"{file_name}{main_extension}" if main_extension else file_name
|
||||||
|
patterns = [main_file, f"{file_name}.metadata.json"]
|
||||||
|
for ext in PREVIEW_EXTENSIONS:
|
||||||
|
patterns.append(f"{file_name}{ext}")
|
||||||
|
|
||||||
|
deleted: List[str] = []
|
||||||
|
main_path = os.path.join(target_dir, main_file).replace(os.sep, "/")
|
||||||
|
|
||||||
|
if os.path.exists(main_path):
|
||||||
|
os.remove(main_path)
|
||||||
|
deleted.append(main_path)
|
||||||
|
else:
|
||||||
|
logger.warning("Model file not found: %s", main_file)
|
||||||
|
|
||||||
|
for pattern in patterns[1:]:
|
||||||
|
path = os.path.join(target_dir, pattern)
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
deleted.append(pattern)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive path
|
||||||
|
logger.warning("Failed to delete %s: %s", pattern, exc)
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
class ModelLifecycleService:
|
||||||
|
"""Co-ordinate destructive and mutating model operations."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
scanner,
|
||||||
|
metadata_manager,
|
||||||
|
metadata_loader: Callable[[str], Awaitable[Dict[str, object]]],
|
||||||
|
recipe_scanner_factory: Callable[[], Awaitable] | None = None,
|
||||||
|
update_service: "ModelUpdateService" | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._scanner = scanner
|
||||||
|
self._metadata_manager = metadata_manager
|
||||||
|
self._metadata_loader = metadata_loader
|
||||||
|
self._recipe_scanner_factory = (
|
||||||
|
recipe_scanner_factory or ServiceRegistry.get_recipe_scanner
|
||||||
|
)
|
||||||
|
self._update_service = update_service
|
||||||
|
|
||||||
|
async def delete_model(self, file_path: str) -> Dict[str, object]:
|
||||||
|
"""Delete a model file and associated artefacts."""
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
raise ValueError("Model path is required")
|
||||||
|
|
||||||
|
cache = await self._scanner.get_cached_data()
|
||||||
|
|
||||||
|
cached_entry = None
|
||||||
|
if cache and hasattr(cache, "raw_data"):
|
||||||
|
cached_entry = next(
|
||||||
|
(item for item in cache.raw_data if item.get("file_path") == file_path),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata_payload = {}
|
||||||
|
try:
|
||||||
|
metadata_payload = await self._metadata_manager.load_metadata_payload(file_path)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive guard
|
||||||
|
logger.debug("Failed to load metadata payload for %s: %s", file_path, exc)
|
||||||
|
|
||||||
|
model_id = (
|
||||||
|
self._extract_model_id_from_payload(metadata_payload)
|
||||||
|
or self._extract_model_id_from_payload(cached_entry)
|
||||||
|
)
|
||||||
|
|
||||||
|
target_dir = os.path.dirname(file_path)
|
||||||
|
base_name = os.path.basename(file_path)
|
||||||
|
file_name, main_extension = os.path.splitext(base_name)
|
||||||
|
deleted_files = await delete_model_artifacts(
|
||||||
|
target_dir, file_name, main_extension=main_extension
|
||||||
|
)
|
||||||
|
|
||||||
|
if cache:
|
||||||
|
cache.raw_data = [
|
||||||
|
item for item in cache.raw_data if item.get("file_path") != file_path
|
||||||
|
]
|
||||||
|
await cache.resort()
|
||||||
|
|
||||||
|
if hasattr(self._scanner, "_hash_index") and self._scanner._hash_index:
|
||||||
|
self._scanner._hash_index.remove_by_path(file_path)
|
||||||
|
|
||||||
|
await self._sync_update_for_model(model_id)
|
||||||
|
return {"success": True, "deleted_files": deleted_files}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_model_id_from_payload(payload: Any) -> Optional[int]:
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
return None
|
||||||
|
civitai = payload.get("civitai")
|
||||||
|
if isinstance(civitai, Mapping):
|
||||||
|
candidate = civitai.get("modelId") or civitai.get("model_id")
|
||||||
|
if candidate is None:
|
||||||
|
model_section = civitai.get("model")
|
||||||
|
if isinstance(model_section, Mapping):
|
||||||
|
candidate = model_section.get("id")
|
||||||
|
normalized = ModelLifecycleService._coerce_int(candidate)
|
||||||
|
if normalized is not None:
|
||||||
|
return normalized
|
||||||
|
fallback = payload.get("model_id") or payload.get("civitai_model_id")
|
||||||
|
return ModelLifecycleService._coerce_int(fallback)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_int(value: Any) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _sync_update_for_model(self, model_id: Optional[int]) -> None:
|
||||||
|
if self._update_service is None or model_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
versions = await self._scanner.get_model_versions_by_id(model_id)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive log
|
||||||
|
logger.debug(
|
||||||
|
"Failed to collect local versions for model %s: %s", model_id, exc
|
||||||
|
)
|
||||||
|
versions = []
|
||||||
|
|
||||||
|
version_ids = set()
|
||||||
|
for version in versions or []:
|
||||||
|
candidate = (
|
||||||
|
version.get("versionId")
|
||||||
|
or version.get("id")
|
||||||
|
or version.get("version_id")
|
||||||
|
)
|
||||||
|
normalized = ModelLifecycleService._coerce_int(candidate)
|
||||||
|
if normalized is not None:
|
||||||
|
version_ids.add(normalized)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._update_service.update_in_library_versions(
|
||||||
|
self._scanner.model_type,
|
||||||
|
model_id,
|
||||||
|
sorted(version_ids),
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive log
|
||||||
|
logger.debug(
|
||||||
|
"Failed to sync update record for model %s: %s", model_id, exc
|
||||||
|
)
|
||||||
|
|
||||||
|
async def exclude_model(self, file_path: str) -> Dict[str, object]:
|
||||||
|
"""Mark a model as excluded and prune cache references."""
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
raise ValueError("Model path is required")
|
||||||
|
|
||||||
|
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||||
|
metadata = await self._metadata_loader(metadata_path)
|
||||||
|
metadata["exclude"] = True
|
||||||
|
|
||||||
|
await self._metadata_manager.save_metadata(file_path, metadata)
|
||||||
|
|
||||||
|
cache = await self._scanner.get_cached_data()
|
||||||
|
model_to_remove = next(
|
||||||
|
(item for item in cache.raw_data if item["file_path"] == file_path),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if model_to_remove:
|
||||||
|
for tag in model_to_remove.get("tags", []):
|
||||||
|
if tag in getattr(self._scanner, "_tags_count", {}):
|
||||||
|
self._scanner._tags_count[tag] = max(
|
||||||
|
0, self._scanner._tags_count[tag] - 1
|
||||||
|
)
|
||||||
|
if self._scanner._tags_count[tag] == 0:
|
||||||
|
del self._scanner._tags_count[tag]
|
||||||
|
|
||||||
|
if hasattr(self._scanner, "_hash_index") and self._scanner._hash_index:
|
||||||
|
self._scanner._hash_index.remove_by_path(file_path)
|
||||||
|
|
||||||
|
cache.raw_data = [
|
||||||
|
item for item in cache.raw_data if item["file_path"] != file_path
|
||||||
|
]
|
||||||
|
await cache.resort()
|
||||||
|
|
||||||
|
excluded = getattr(self._scanner, "_excluded_models", None)
|
||||||
|
if isinstance(excluded, list):
|
||||||
|
excluded.append(file_path)
|
||||||
|
|
||||||
|
message = f"Model {os.path.basename(file_path)} excluded"
|
||||||
|
return {"success": True, "message": message}
|
||||||
|
|
||||||
|
async def bulk_delete_models(self, file_paths: Iterable[str]) -> Dict[str, object]:
|
||||||
|
"""Delete a collection of models via the scanner bulk operation."""
|
||||||
|
|
||||||
|
file_paths = list(file_paths)
|
||||||
|
if not file_paths:
|
||||||
|
raise ValueError("No file paths provided for deletion")
|
||||||
|
|
||||||
|
return await self._scanner.bulk_delete_models(file_paths)
|
||||||
|
|
||||||
|
async def rename_model(
|
||||||
|
self, *, file_path: str, new_file_name: str
|
||||||
|
) -> Dict[str, object]:
|
||||||
|
"""Rename a model and its companion artefacts."""
|
||||||
|
|
||||||
|
if not file_path or not new_file_name:
|
||||||
|
raise ValueError("File path and new file name are required")
|
||||||
|
|
||||||
|
invalid_chars = {"/", "\\", ":", "*", "?", '"', "<", ">", "|"}
|
||||||
|
if any(char in new_file_name for char in invalid_chars):
|
||||||
|
raise ValueError("Invalid characters in file name")
|
||||||
|
|
||||||
|
target_dir = os.path.dirname(file_path)
|
||||||
|
base_name = os.path.basename(file_path)
|
||||||
|
old_file_name, old_extension = os.path.splitext(base_name)
|
||||||
|
if not old_extension:
|
||||||
|
old_extension = ".safetensors"
|
||||||
|
new_file_path = os.path.join(
|
||||||
|
target_dir, f"{new_file_name}{old_extension}"
|
||||||
|
).replace(os.sep, "/")
|
||||||
|
|
||||||
|
if os.path.exists(new_file_path):
|
||||||
|
raise ValueError("A file with this name already exists")
|
||||||
|
|
||||||
|
patterns = [
|
||||||
|
f"{old_file_name}{old_extension}",
|
||||||
|
f"{old_file_name}.metadata.json",
|
||||||
|
f"{old_file_name}.metadata.json.bak",
|
||||||
|
]
|
||||||
|
for ext in PREVIEW_EXTENSIONS:
|
||||||
|
patterns.append(f"{old_file_name}{ext}")
|
||||||
|
|
||||||
|
existing_files: List[tuple[str, str]] = []
|
||||||
|
for pattern in patterns:
|
||||||
|
path = os.path.join(target_dir, pattern)
|
||||||
|
if os.path.exists(path):
|
||||||
|
existing_files.append((path, pattern))
|
||||||
|
|
||||||
|
metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json")
|
||||||
|
metadata: Optional[Dict[str, object]] = None
|
||||||
|
hash_value: Optional[str] = None
|
||||||
|
|
||||||
|
if os.path.exists(metadata_path):
|
||||||
|
metadata = await self._metadata_loader(metadata_path)
|
||||||
|
hash_value = metadata.get("sha256") if isinstance(metadata, dict) else None
|
||||||
|
|
||||||
|
renamed_files: List[str] = []
|
||||||
|
new_metadata_path: Optional[str] = None
|
||||||
|
new_preview: Optional[str] = None
|
||||||
|
|
||||||
|
for old_path, pattern in existing_files:
|
||||||
|
ext = self._get_multipart_ext(pattern)
|
||||||
|
new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(
|
||||||
|
os.sep, "/"
|
||||||
|
)
|
||||||
|
os.rename(old_path, new_path)
|
||||||
|
renamed_files.append(new_path)
|
||||||
|
|
||||||
|
if ext == ".metadata.json":
|
||||||
|
new_metadata_path = new_path
|
||||||
|
|
||||||
|
if metadata and new_metadata_path:
|
||||||
|
metadata["file_name"] = new_file_name
|
||||||
|
metadata["file_path"] = new_file_path
|
||||||
|
|
||||||
|
if metadata.get("preview_url"):
|
||||||
|
old_preview = str(metadata["preview_url"])
|
||||||
|
ext = self._get_multipart_ext(old_preview)
|
||||||
|
new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(
|
||||||
|
os.sep, "/"
|
||||||
|
)
|
||||||
|
metadata["preview_url"] = new_preview
|
||||||
|
|
||||||
|
await self._metadata_manager.save_metadata(new_file_path, metadata)
|
||||||
|
|
||||||
|
if metadata:
|
||||||
|
await self._scanner.update_single_model_cache(
|
||||||
|
file_path, new_file_path, metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
if hash_value and getattr(self._scanner, "model_type", "") == "lora":
|
||||||
|
recipe_scanner = await self._recipe_scanner_factory()
|
||||||
|
if recipe_scanner:
|
||||||
|
try:
|
||||||
|
await recipe_scanner.update_lora_filename_by_hash(
|
||||||
|
hash_value, new_file_name
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Error updating recipe references for %s: %s",
|
||||||
|
file_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"new_file_path": new_file_path,
|
||||||
|
"new_preview_path": new_preview,
|
||||||
|
"renamed_files": renamed_files,
|
||||||
|
"reload_required": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_multipart_ext(filename: str) -> str:
|
||||||
|
"""Return the extension for files with compound suffixes."""
|
||||||
|
|
||||||
|
known_suffixes = [
|
||||||
|
".metadata.json.bak",
|
||||||
|
".metadata.json",
|
||||||
|
".safetensors",
|
||||||
|
*PREVIEW_EXTENSIONS,
|
||||||
|
]
|
||||||
|
|
||||||
|
for suffix in sorted(known_suffixes, key=len, reverse=True):
|
||||||
|
if filename.endswith(suffix):
|
||||||
|
return suffix
|
||||||
|
|
||||||
|
basename = os.path.basename(filename)
|
||||||
|
dot_index = basename.rfind(".")
|
||||||
|
if dot_index != -1:
|
||||||
|
return basename[dot_index:]
|
||||||
|
|
||||||
|
return os.path.splitext(basename)[1]
|
||||||
685
py/services/model_metadata_provider.py
Normal file
685
py/services/model_metadata_provider.py
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from typing import Optional, Dict, Tuple, Any, List, Sequence
|
||||||
|
from .downloader import get_downloader
|
||||||
|
from .errors import RateLimitError
|
||||||
|
|
||||||
|
try:
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
except ImportError as exc:
|
||||||
|
BeautifulSoup = None # type: ignore[assignment]
|
||||||
|
_BS4_IMPORT_ERROR = exc
|
||||||
|
else:
|
||||||
|
_BS4_IMPORT_ERROR = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiosqlite
|
||||||
|
except ImportError as exc:
|
||||||
|
aiosqlite = None # type: ignore[assignment]
|
||||||
|
_AIOSQLITE_IMPORT_ERROR = exc
|
||||||
|
else:
|
||||||
|
_AIOSQLITE_IMPORT_ERROR = None
|
||||||
|
|
||||||
|
def _require_beautifulsoup() -> Any:
|
||||||
|
if BeautifulSoup is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"BeautifulSoup (bs4) is required for CivArchiveModelMetadataProvider. "
|
||||||
|
"Install it with 'pip install beautifulsoup4'."
|
||||||
|
) from _BS4_IMPORT_ERROR
|
||||||
|
return BeautifulSoup
|
||||||
|
|
||||||
|
def _require_aiosqlite() -> Any:
|
||||||
|
if aiosqlite is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"aiosqlite is required for SQLiteModelMetadataProvider. "
|
||||||
|
"Install it with 'pip install aiosqlite'."
|
||||||
|
) from _AIOSQLITE_IMPORT_ERROR
|
||||||
|
return aiosqlite
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _RateLimitRetryHelper:
|
||||||
|
"""Coordinate exponential backoff retries after rate limiting."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
retry_limit: int = 3,
|
||||||
|
base_delay: float = 1.5,
|
||||||
|
max_delay: float = 30.0,
|
||||||
|
jitter_ratio: float = 0.2,
|
||||||
|
) -> None:
|
||||||
|
self._retry_limit = max(1, retry_limit)
|
||||||
|
self._base_delay = base_delay
|
||||||
|
self._max_delay = max_delay
|
||||||
|
self._jitter_ratio = max(0.0, jitter_ratio)
|
||||||
|
|
||||||
|
async def run(self, label: str, func, *args, **kwargs):
|
||||||
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
except RateLimitError as exc:
|
||||||
|
attempt += 1
|
||||||
|
if attempt >= self._retry_limit:
|
||||||
|
exc.provider = exc.provider or label
|
||||||
|
raise
|
||||||
|
|
||||||
|
delay = self._calculate_delay(exc.retry_after, attempt)
|
||||||
|
logger.warning(
|
||||||
|
"Provider %s rate limited request; retrying in %.2fs (attempt %s/%s)",
|
||||||
|
label,
|
||||||
|
delay,
|
||||||
|
attempt,
|
||||||
|
self._retry_limit,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
def _calculate_delay(self, retry_after: Optional[float], attempt: int) -> float:
|
||||||
|
if retry_after is not None:
|
||||||
|
return min(self._max_delay, max(0.0, retry_after))
|
||||||
|
|
||||||
|
base_delay = self._base_delay * (2 ** max(0, attempt - 1))
|
||||||
|
jitter_span = base_delay * self._jitter_ratio
|
||||||
|
if jitter_span > 0:
|
||||||
|
base_delay += random.uniform(-jitter_span, jitter_span)
|
||||||
|
|
||||||
|
return min(self._max_delay, max(0.0, base_delay))
|
||||||
|
|
||||||
|
class ModelMetadataProvider(ABC):
|
||||||
|
"""Base abstract class for all model metadata providers"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
"""Find model by hash value"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||||
|
"""Get all versions of a model with their details"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_model_versions_bulk(
|
||||||
|
self, model_ids: Sequence[int]
|
||||||
|
) -> Optional[Dict[int, Dict]]:
|
||||||
|
"""Fetch model versions for multiple model ids when supported."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
|
"""Get specific model version with additional metadata"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
"""Fetch model version metadata"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||||
|
"""Fetch models owned by the specified user"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
||||||
|
"""Provider that uses Civitai API for metadata"""
|
||||||
|
|
||||||
|
def __init__(self, civitai_client):
|
||||||
|
self.client = civitai_client
|
||||||
|
|
||||||
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
return await self.client.get_model_by_hash(model_hash)
|
||||||
|
|
||||||
|
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||||
|
return await self.client.get_model_versions(model_id)
|
||||||
|
|
||||||
|
async def get_model_versions_bulk(
|
||||||
|
self, model_ids: Sequence[int]
|
||||||
|
) -> Optional[Dict[int, Dict]]:
|
||||||
|
return await self.client.get_model_versions_bulk(model_ids)
|
||||||
|
|
||||||
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
|
return await self.client.get_model_version(model_id, version_id)
|
||||||
|
|
||||||
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
return await self.client.get_model_version_info(version_id)
|
||||||
|
|
||||||
|
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||||
|
return await self.client.get_user_models(username)
|
||||||
|
|
||||||
|
class CivArchiveModelMetadataProvider(ModelMetadataProvider):
|
||||||
|
"""Provider that uses CivArchive API for metadata"""
|
||||||
|
|
||||||
|
def __init__(self, civarchive_client):
|
||||||
|
self.client = civarchive_client
|
||||||
|
|
||||||
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
return await self.client.get_model_by_hash(model_hash)
|
||||||
|
|
||||||
|
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||||
|
return await self.client.get_model_versions(model_id)
|
||||||
|
|
||||||
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
|
return await self.client.get_model_version(model_id, version_id)
|
||||||
|
|
||||||
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
return await self.client.get_model_version_info(version_id)
|
||||||
|
|
||||||
|
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||||
|
"""Not supported by CivArchive provider"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
class SQLiteModelMetadataProvider(ModelMetadataProvider):
|
||||||
|
"""Provider that uses SQLite database for metadata"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str):
|
||||||
|
self.db_path = db_path
|
||||||
|
self._aiosqlite = _require_aiosqlite()
|
||||||
|
|
||||||
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
"""Find model by hash value from SQLite database"""
|
||||||
|
async with self._aiosqlite.connect(self.db_path) as db:
|
||||||
|
# Look up in model_files table to get model_id and version_id
|
||||||
|
query = """
|
||||||
|
SELECT model_id, version_id
|
||||||
|
FROM model_files
|
||||||
|
WHERE sha256 = ?
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
db.row_factory = self._aiosqlite.Row
|
||||||
|
cursor = await db.execute(query, (model_hash.upper(),))
|
||||||
|
file_row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not file_row:
|
||||||
|
return None, "Model not found"
|
||||||
|
|
||||||
|
# Get version details
|
||||||
|
model_id = file_row['model_id']
|
||||||
|
version_id = file_row['version_id']
|
||||||
|
|
||||||
|
# Build response in the same format as Civitai API
|
||||||
|
result = await self._get_version_with_model_data(db, model_id, version_id)
|
||||||
|
return result, None if result else "Error retrieving model data"
|
||||||
|
|
||||||
|
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||||
|
"""Get all versions of a model from SQLite database"""
|
||||||
|
async with self._aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = self._aiosqlite.Row
|
||||||
|
|
||||||
|
# First check if model exists
|
||||||
|
model_query = "SELECT * FROM models WHERE id = ?"
|
||||||
|
cursor = await db.execute(model_query, (model_id,))
|
||||||
|
model_row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not model_row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
model_data = json.loads(model_row['data'])
|
||||||
|
model_type = model_row['type']
|
||||||
|
model_name = model_row['name']
|
||||||
|
|
||||||
|
# Get all versions for this model
|
||||||
|
versions_query = """
|
||||||
|
SELECT id, name, base_model, data, position, published_at
|
||||||
|
FROM model_versions
|
||||||
|
WHERE model_id = ?
|
||||||
|
ORDER BY position ASC
|
||||||
|
"""
|
||||||
|
cursor = await db.execute(versions_query, (model_id,))
|
||||||
|
version_rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
if not version_rows:
|
||||||
|
return {'modelVersions': [], 'type': model_type}
|
||||||
|
|
||||||
|
# Format versions similar to Civitai API
|
||||||
|
model_versions = []
|
||||||
|
for row in version_rows:
|
||||||
|
version_data = json.loads(row['data'])
|
||||||
|
# Add fields from the row to ensure we have the basic fields
|
||||||
|
version_entry = {
|
||||||
|
'id': row['id'],
|
||||||
|
'modelId': int(model_id),
|
||||||
|
'name': row['name'],
|
||||||
|
'baseModel': row['base_model'],
|
||||||
|
'model': {
|
||||||
|
'name': model_row['name'],
|
||||||
|
'type': model_type,
|
||||||
|
},
|
||||||
|
'source': 'archive_db'
|
||||||
|
}
|
||||||
|
# Update with any additional data
|
||||||
|
version_entry.update(version_data)
|
||||||
|
model_versions.append(version_entry)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'modelVersions': model_versions,
|
||||||
|
'type': model_type,
|
||||||
|
'name': model_name
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
|
"""Get specific model version with additional metadata from SQLite database"""
|
||||||
|
if not model_id and not version_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with self._aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = self._aiosqlite.Row
|
||||||
|
|
||||||
|
# Case 1: Only version_id is provided
|
||||||
|
if model_id is None and version_id is not None:
|
||||||
|
# First get the version info to extract model_id
|
||||||
|
version_query = "SELECT model_id FROM model_versions WHERE id = ?"
|
||||||
|
cursor = await db.execute(version_query, (version_id,))
|
||||||
|
version_row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not version_row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
model_id = version_row['model_id']
|
||||||
|
|
||||||
|
# Case 2: model_id is provided but version_id is not
|
||||||
|
elif model_id is not None and version_id is None:
|
||||||
|
# Find the latest version
|
||||||
|
version_query = """
|
||||||
|
SELECT id FROM model_versions
|
||||||
|
WHERE model_id = ?
|
||||||
|
ORDER BY position ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
cursor = await db.execute(version_query, (model_id,))
|
||||||
|
version_row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not version_row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
version_id = version_row['id']
|
||||||
|
|
||||||
|
# Now we have both model_id and version_id, get the full data
|
||||||
|
return await self._get_version_with_model_data(db, model_id, version_id)
|
||||||
|
|
||||||
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
"""Fetch model version metadata from SQLite database"""
|
||||||
|
async with self._aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = self._aiosqlite.Row
|
||||||
|
|
||||||
|
# Get version details
|
||||||
|
version_query = "SELECT model_id FROM model_versions WHERE id = ?"
|
||||||
|
cursor = await db.execute(version_query, (version_id,))
|
||||||
|
version_row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not version_row:
|
||||||
|
return None, "Model version not found"
|
||||||
|
|
||||||
|
model_id = version_row['model_id']
|
||||||
|
|
||||||
|
# Build complete version data with model info
|
||||||
|
version_data = await self._get_version_with_model_data(db, model_id, version_id)
|
||||||
|
return version_data, None
|
||||||
|
|
||||||
|
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||||
|
"""Listing models by username is not supported for archive database"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _get_version_with_model_data(self, db, model_id, version_id) -> Optional[Dict]:
|
||||||
|
"""Helper to build version data with model information"""
|
||||||
|
# Get version details
|
||||||
|
version_query = "SELECT name, base_model, data FROM model_versions WHERE id = ? AND model_id = ?"
|
||||||
|
cursor = await db.execute(version_query, (version_id, model_id))
|
||||||
|
version_row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not version_row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get model details
|
||||||
|
model_query = "SELECT name, type, data, username FROM models WHERE id = ?"
|
||||||
|
cursor = await db.execute(model_query, (model_id,))
|
||||||
|
model_row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not model_row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse JSON data
|
||||||
|
try:
|
||||||
|
version_data = json.loads(version_row['data'])
|
||||||
|
model_data = json.loads(model_row['data'])
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
result = {
|
||||||
|
"id": int(version_id),
|
||||||
|
"modelId": int(model_id),
|
||||||
|
"name": version_row['name'],
|
||||||
|
"baseModel": version_row['base_model'],
|
||||||
|
"model": {
|
||||||
|
"name": model_row['name'],
|
||||||
|
"description": model_data.get("description"),
|
||||||
|
"type": model_row['type'],
|
||||||
|
"tags": model_data.get("tags", [])
|
||||||
|
},
|
||||||
|
"creator": {
|
||||||
|
"username": model_row['username'] or model_data.get("creator", {}).get("username"),
|
||||||
|
"image": model_data.get("creator", {}).get("image")
|
||||||
|
},
|
||||||
|
"source": "archive_db"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add any additional fields from version data
|
||||||
|
result.update(version_data)
|
||||||
|
|
||||||
|
# Attach files associated with this version from model_files table
|
||||||
|
files_query = """
|
||||||
|
SELECT data
|
||||||
|
FROM model_files
|
||||||
|
WHERE version_id = ? AND type = 'Model'
|
||||||
|
ORDER BY id ASC
|
||||||
|
"""
|
||||||
|
cursor = await db.execute(files_query, (version_id,))
|
||||||
|
file_rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for file_row in file_rows:
|
||||||
|
try:
|
||||||
|
file_data = json.loads(file_row['data'])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping model_files entry with invalid JSON for version_id %s", version_id
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
# Remove 'modelId' and 'modelVersionId' fields if present
|
||||||
|
file_data.pop('modelId', None)
|
||||||
|
file_data.pop('modelVersionId', None)
|
||||||
|
files.append(file_data)
|
||||||
|
|
||||||
|
if 'files' in result:
|
||||||
|
existing_files = result['files']
|
||||||
|
if isinstance(existing_files, list):
|
||||||
|
existing_files.extend(files)
|
||||||
|
result['files'] = existing_files
|
||||||
|
else:
|
||||||
|
merged_files = files.copy()
|
||||||
|
if existing_files:
|
||||||
|
merged_files.insert(0, existing_files)
|
||||||
|
result['files'] = merged_files
|
||||||
|
elif files:
|
||||||
|
result['files'] = files
|
||||||
|
else:
|
||||||
|
result['files'] = []
|
||||||
|
|
||||||
|
return result
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FallbackMetadataProvider(ModelMetadataProvider):
|
||||||
|
"""Try providers in order, return first successful result."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
providers: Sequence[ModelMetadataProvider | Tuple[str, ModelMetadataProvider]],
|
||||||
|
*,
|
||||||
|
rate_limit_retry_limit: int = 3,
|
||||||
|
rate_limit_base_delay: float = 1.5,
|
||||||
|
rate_limit_max_delay: float = 30.0,
|
||||||
|
rate_limit_jitter_ratio: float = 0.2,
|
||||||
|
) -> None:
|
||||||
|
self.providers: List[ModelMetadataProvider] = []
|
||||||
|
self._provider_labels: List[str] = []
|
||||||
|
|
||||||
|
for entry in providers:
|
||||||
|
if isinstance(entry, tuple) and len(entry) == 2:
|
||||||
|
name, provider = entry
|
||||||
|
else:
|
||||||
|
provider = entry
|
||||||
|
name = provider.__class__.__name__
|
||||||
|
self.providers.append(provider)
|
||||||
|
self._provider_labels.append(str(name))
|
||||||
|
|
||||||
|
self._rate_limit_retry_limit = max(1, rate_limit_retry_limit)
|
||||||
|
self._rate_limit_base_delay = rate_limit_base_delay
|
||||||
|
self._rate_limit_max_delay = rate_limit_max_delay
|
||||||
|
self._rate_limit_jitter_ratio = max(0.0, rate_limit_jitter_ratio)
|
||||||
|
self._rate_limit_helper = _RateLimitRetryHelper(
|
||||||
|
retry_limit=self._rate_limit_retry_limit,
|
||||||
|
base_delay=self._rate_limit_base_delay,
|
||||||
|
max_delay=self._rate_limit_max_delay,
|
||||||
|
jitter_ratio=self._rate_limit_jitter_ratio,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
for provider, label in self._iter_providers():
|
||||||
|
try:
|
||||||
|
result, error = await self._call_with_rate_limit(
|
||||||
|
label,
|
||||||
|
provider.get_model_by_hash,
|
||||||
|
model_hash,
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
return result, error
|
||||||
|
except RateLimitError as exc:
|
||||||
|
exc.provider = exc.provider or label
|
||||||
|
raise exc
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Provider %s failed for get_model_by_hash: %s", label, e)
|
||||||
|
continue
|
||||||
|
return None, "Model not found"
|
||||||
|
|
||||||
|
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||||
|
for provider, label in self._iter_providers():
|
||||||
|
try:
|
||||||
|
result = await self._call_with_rate_limit(
|
||||||
|
label,
|
||||||
|
provider.get_model_versions,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
except RateLimitError as exc:
|
||||||
|
exc.provider = exc.provider or label
|
||||||
|
raise exc
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Provider %s failed for get_model_versions: %s", label, e)
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
|
for provider, label in self._iter_providers():
|
||||||
|
try:
|
||||||
|
result = await self._call_with_rate_limit(
|
||||||
|
label,
|
||||||
|
provider.get_model_version,
|
||||||
|
model_id,
|
||||||
|
version_id,
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
except RateLimitError as exc:
|
||||||
|
exc.provider = exc.provider or label
|
||||||
|
raise exc
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Provider %s failed for get_model_version: %s", label, e)
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
for provider, label in self._iter_providers():
|
||||||
|
try:
|
||||||
|
result, error = await self._call_with_rate_limit(
|
||||||
|
label,
|
||||||
|
provider.get_model_version_info,
|
||||||
|
version_id,
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
return result, error
|
||||||
|
except RateLimitError as exc:
|
||||||
|
exc.provider = exc.provider or label
|
||||||
|
raise exc
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Provider %s failed for get_model_version_info: %s", label, e)
|
||||||
|
continue
|
||||||
|
return None, "No provider could retrieve the data"
|
||||||
|
|
||||||
|
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||||
|
for provider, label in self._iter_providers():
|
||||||
|
try:
|
||||||
|
result = await self._call_with_rate_limit(
|
||||||
|
label,
|
||||||
|
provider.get_user_models,
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
except RateLimitError as exc:
|
||||||
|
exc.provider = exc.provider or label
|
||||||
|
raise exc
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Provider %s failed for get_user_models: %s", label, e)
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _iter_providers(self):
|
||||||
|
return zip(self.providers, self._provider_labels)
|
||||||
|
|
||||||
|
async def _call_with_rate_limit(self, label: str, func, *args, **kwargs):
|
||||||
|
return await self._rate_limit_helper.run(label, func, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitRetryingProvider(ModelMetadataProvider):
|
||||||
|
"""Adapter that retries individual provider calls after rate limiting."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: ModelMetadataProvider,
|
||||||
|
label: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
rate_limit_retry_limit: int = 3,
|
||||||
|
rate_limit_base_delay: float = 1.5,
|
||||||
|
rate_limit_max_delay: float = 30.0,
|
||||||
|
rate_limit_jitter_ratio: float = 0.2,
|
||||||
|
) -> None:
|
||||||
|
self._provider = provider
|
||||||
|
self._label = label or provider.__class__.__name__
|
||||||
|
self._rate_limit_helper = _RateLimitRetryHelper(
|
||||||
|
retry_limit=rate_limit_retry_limit,
|
||||||
|
base_delay=rate_limit_base_delay,
|
||||||
|
max_delay=rate_limit_max_delay,
|
||||||
|
jitter_ratio=rate_limit_jitter_ratio,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __getattr__(self, item):
|
||||||
|
return getattr(self._provider, item)
|
||||||
|
|
||||||
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
return await self._rate_limit_helper.run(
|
||||||
|
self._label,
|
||||||
|
self._provider.get_model_by_hash,
|
||||||
|
model_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||||
|
return await self._rate_limit_helper.run(
|
||||||
|
self._label,
|
||||||
|
self._provider.get_model_versions,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_model_versions_bulk(
|
||||||
|
self,
|
||||||
|
model_ids: Sequence[int],
|
||||||
|
) -> Optional[Dict[int, Dict]]:
|
||||||
|
return await self._rate_limit_helper.run(
|
||||||
|
self._label,
|
||||||
|
self._provider.get_model_versions_bulk,
|
||||||
|
model_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
|
return await self._rate_limit_helper.run(
|
||||||
|
self._label,
|
||||||
|
self._provider.get_model_version,
|
||||||
|
model_id,
|
||||||
|
version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
return await self._rate_limit_helper.run(
|
||||||
|
self._label,
|
||||||
|
self._provider.get_model_version_info,
|
||||||
|
version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||||
|
return await self._rate_limit_helper.run(
|
||||||
|
self._label,
|
||||||
|
self._provider.get_user_models,
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ModelMetadataProviderManager:
|
||||||
|
"""Manager for selecting and using model metadata providers"""
|
||||||
|
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_instance(cls):
|
||||||
|
"""Get singleton instance of ModelMetadataProviderManager"""
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.providers = {}
|
||||||
|
self.default_provider = None
|
||||||
|
|
||||||
|
def register_provider(self, name: str, provider: ModelMetadataProvider, is_default: bool = False):
|
||||||
|
"""Register a metadata provider"""
|
||||||
|
self.providers[name] = provider
|
||||||
|
if is_default or self.default_provider is None:
|
||||||
|
self.default_provider = name
|
||||||
|
|
||||||
|
async def get_model_by_hash(self, model_hash: str, provider_name: str = None) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
"""Find model by hash using specified or default provider"""
|
||||||
|
provider = self._get_provider(provider_name)
|
||||||
|
return await provider.get_model_by_hash(model_hash)
|
||||||
|
|
||||||
|
async def get_model_versions(self, model_id: str, provider_name: str = None) -> Optional[Dict]:
|
||||||
|
"""Get model versions using specified or default provider"""
|
||||||
|
provider = self._get_provider(provider_name)
|
||||||
|
return await provider.get_model_versions(model_id)
|
||||||
|
|
||||||
|
async def get_model_versions_bulk(
|
||||||
|
self,
|
||||||
|
model_ids: Sequence[int],
|
||||||
|
provider_name: str = None,
|
||||||
|
) -> Optional[Dict[int, Dict]]:
|
||||||
|
"""Fetch model versions for multiple model ids when supported by provider."""
|
||||||
|
provider = self._get_provider(provider_name)
|
||||||
|
try:
|
||||||
|
return await provider.get_model_versions_bulk(model_ids)
|
||||||
|
except NotImplementedError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_model_version(self, model_id: int = None, version_id: int = None, provider_name: str = None) -> Optional[Dict]:
|
||||||
|
"""Get specific model version using specified or default provider"""
|
||||||
|
provider = self._get_provider(provider_name)
|
||||||
|
return await provider.get_model_version(model_id, version_id)
|
||||||
|
|
||||||
|
async def get_model_version_info(self, version_id: str, provider_name: str = None) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
|
"""Fetch model version info using specified or default provider"""
|
||||||
|
provider = self._get_provider(provider_name)
|
||||||
|
return await provider.get_model_version_info(version_id)
|
||||||
|
|
||||||
|
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
|
||||||
|
"""Fetch models owned by the specified user"""
|
||||||
|
provider = self._get_provider(provider_name)
|
||||||
|
return await provider.get_user_models(username)
|
||||||
|
|
||||||
|
def _get_provider(self, provider_name: str = None) -> ModelMetadataProvider:
|
||||||
|
"""Get provider by name or default provider"""
|
||||||
|
if provider_name and provider_name in self.providers:
|
||||||
|
return self.providers[provider_name]
|
||||||
|
|
||||||
|
if self.default_provider is None:
|
||||||
|
raise ValueError("No default provider set and no valid provider specified")
|
||||||
|
|
||||||
|
return self.providers[self.default_provider]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user