Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ee8408421 | ||
|
|
4cc9e87f29 | ||
|
|
9097e369c8 | ||
|
|
543aad4236 | ||
|
|
3807fd2d63 | ||
|
|
b3c2f7a2d1 | ||
|
|
0845aa6a81 | ||
|
|
a4607c0e2f | ||
|
|
586eccbbb3 | ||
|
|
6a3cb21adf | ||
|
|
09eb1fa3aa | ||
|
|
3ede3e808c | ||
|
|
4b3c629ff9 | ||
|
|
14cb7b9c07 | ||
|
|
c47001c580 | ||
|
|
529173a472 | ||
|
|
f64d2e19f7 | ||
|
|
6348cc40cb | ||
|
|
66a4675321 | ||
|
|
2898627673 | ||
|
|
f6d4001e3c | ||
|
|
c6b26d88f0 | ||
|
|
0ff7f21958 | ||
|
|
1ca7eac48c | ||
|
|
67794d92c8 | ||
|
|
ca971f4daf | ||
|
|
a32b1c64fe | ||
|
|
ffc925dbdb | ||
|
|
f4f213508d | ||
|
|
7f06dd654a | ||
|
|
7479ddf8eb | ||
|
|
0768964041 | ||
|
|
9b66c57a6a | ||
|
|
3eb64b175d | ||
|
|
25a8adc91d | ||
|
|
d533751ffb | ||
|
|
6f1e0dc844 | ||
|
|
894a7ed2b1 | ||
|
|
c87b7a4c7f | ||
|
|
a000e349c9 | ||
|
|
f23e3c04ec | ||
|
|
63146fdc27 | ||
|
|
7795598346 | ||
|
|
04252db0c4 | ||
|
|
aeb2e938ef | ||
|
|
3799d33c62 | ||
|
|
98b317d493 | ||
|
|
4292f96645 | ||
|
|
182c7cc807 | ||
|
|
53be057425 | ||
|
|
ac70dba927 | ||
|
|
f79cc244ef | ||
|
|
14137e1b1b | ||
|
|
43ee4597a9 | ||
|
|
f311c8e2f3 | ||
|
|
48933b4078 | ||
|
|
45288a130f | ||
|
|
73256eb15d | ||
|
|
6855c04fab | ||
|
|
f6f735ad88 | ||
|
|
a7250a5f80 | ||
|
|
d326e08834 | ||
|
|
0e2d513cf7 | ||
|
|
5cf530ba78 | ||
|
|
3bc2e08ca5 | ||
|
|
38996b4095 | ||
|
|
4063a1b8ae | ||
|
|
e9e0b487f4 | ||
|
|
099b0889b0 | ||
|
|
33be4ce687 | ||
|
|
5c7b3dc5cd | ||
|
|
f909f554ff | ||
|
|
fc6d88be5e | ||
|
|
ed556acd1d | ||
|
|
c6c52a1ae7 | ||
|
|
98ed3c7199 | ||
|
|
ee1c7d579c | ||
|
|
ba06dce34d | ||
|
|
8991524463 | ||
|
|
3fe1006a1e | ||
|
|
63c8f8a841 | ||
|
|
25877685ea | ||
|
|
5b111272d4 | ||
|
|
67365336bb | ||
|
|
3a991c8fcf | ||
|
|
d85708588c | ||
|
|
63f6c1276b | ||
|
|
8b4d5e20a9 | ||
|
|
9b22b1f377 | ||
|
|
d1357e7a79 | ||
|
|
e15e7beb1b | ||
|
|
8b4b198697 | ||
|
|
13700c554a | ||
|
|
1e6506aee6 | ||
|
|
2235541000 | ||
|
|
6f5b9e2ee4 | ||
|
|
045213d363 | ||
|
|
1f0c9a338a | ||
|
|
59b40cfd59 | ||
|
|
fc4598a810 | ||
|
|
7b1863180f | ||
|
|
edec048e30 | ||
|
|
4690874be1 | ||
|
|
bf9b18ec00 | ||
|
|
d08d6e6032 | ||
|
|
8737e515b7 | ||
|
|
4667cd47fe | ||
|
|
161f4f5051 | ||
|
|
858a5b34bf | ||
|
|
4a6510adde | ||
|
|
41addf9e4f | ||
|
|
b7c7e9d33d | ||
|
|
bd5b16e17c | ||
|
|
ec1d1705e7 | ||
|
|
16b3f741e7 | ||
|
|
99c8d86c6e | ||
|
|
1176523d61 | ||
|
|
5478fa6d80 | ||
|
|
0bac6da968 | ||
|
|
911ca86a0d | ||
|
|
e6b0bea455 | ||
|
|
85a218042d | ||
|
|
921f6eb8d2 | ||
|
|
0ac48126aa | ||
|
|
0544a515b6 | ||
|
|
423ba6a777 | ||
|
|
7f4121d9e4 | ||
|
|
62c5b2e622 | ||
|
|
73678be2c8 | ||
|
|
473d97e392 | ||
|
|
f3d535d577 | ||
|
|
5d06a23198 | ||
|
|
dabd1d429e | ||
|
|
dfb515b0f8 | ||
|
|
6ce59b9e04 | ||
|
|
76778930b5 | ||
|
|
92433f33ca | ||
|
|
b8037db423 | ||
|
|
c9040ea68c | ||
|
|
8972679e25 | ||
|
|
ad829c0a99 | ||
|
|
a5e2aa0da1 | ||
|
|
086ca3053f | ||
|
|
f07b608288 | ||
|
|
42c979536b | ||
|
|
4800167979 | ||
|
|
cb28baf25d | ||
|
|
4bd15f89d6 | ||
|
|
16bca596b6 | ||
|
|
ebc902c9e7 | ||
|
|
f81969cdd0 | ||
|
|
950a2abc49 | ||
|
|
04a900e120 | ||
|
|
1c11eb1e89 | ||
|
|
4dd9ba2ff4 | ||
|
|
27a23d76dd | ||
|
|
fcb176d519 | ||
|
|
359be7fd9f | ||
|
|
ece35824d3 | ||
|
|
fe807bded4 | ||
|
|
7cdfe13151 | ||
|
|
d04ca79e00 | ||
|
|
a37b527895 | ||
|
|
1e1a04a465 | ||
|
|
c6555ae47d | ||
|
|
a76328cc16 | ||
|
|
ef527c2072 | ||
|
|
45079dce25 | ||
|
|
5466028ae2 | ||
|
|
ea265cfd9e | ||
|
|
0e3e5fe912 | ||
|
|
6d8ea36260 | ||
|
|
ea1fb49a37 | ||
|
|
244fa4ae43 | ||
|
|
5cfb449c3c | ||
|
|
3c9b9f5344 | ||
|
|
9ebaca92f1 | ||
|
|
494266953c | ||
|
|
583097cd16 | ||
|
|
cb1d1759e3 | ||
|
|
563ffb2bd3 | ||
|
|
25ab373539 | ||
|
|
ed0acdda22 | ||
|
|
a84337c928 | ||
|
|
cface2944e | ||
|
|
1e92934f53 | ||
|
|
bf3f30356e | ||
|
|
bfabf9b793 | ||
|
|
1f50f9ae72 | ||
|
|
7da5a8d4e8 | ||
|
|
2b3fd79a92 | ||
|
|
aa8b500f57 | ||
|
|
702847a5c2 | ||
|
|
5cc1aa29fe | ||
|
|
105cc05bdf | ||
|
|
50b8b2992b | ||
|
|
f4c9713815 | ||
|
|
7154dc87d2 | ||
|
|
2c9bef8883 | ||
|
|
0d5d44a484 | ||
|
|
d5953a948c | ||
|
|
132198a3a9 | ||
|
|
add5ec5a3e | ||
|
|
260fd10bf0 | ||
|
|
95d2b270dc | ||
|
|
1aa6c57e58 | ||
|
|
bde377dd37 | ||
|
|
834dc12b05 | ||
|
|
35655d7784 | ||
|
|
434da54ede | ||
|
|
d299b76bd3 | ||
|
|
9f90b6ab6c | ||
|
|
2a77a38655 | ||
|
|
39463bfbcb | ||
|
|
83437def32 | ||
|
|
7f6bbd6323 | ||
|
|
82a82b1e84 | ||
|
|
85bce309e9 | ||
|
|
8b98b0edf9 | ||
|
|
8c765bbf39 | ||
|
|
0fd10576fe | ||
|
|
744e0336c5 | ||
|
|
040a8d7b33 | ||
|
|
39d2f996ae | ||
|
|
97c573cc1d | ||
|
|
f6ef818e60 | ||
|
|
f5094c68c6 | ||
|
|
9834023522 | ||
|
|
c267562aec | ||
|
|
a68b9cff95 | ||
|
|
a604b0d0cc | ||
|
|
24466228ef | ||
|
|
fcb1b88870 | ||
|
|
3fb4119c79 | ||
|
|
eb4cb30efd | ||
|
|
53c226eba8 | ||
|
|
3b74d93b74 | ||
|
|
ff54ab4ef6 | ||
|
|
a7c90b63df | ||
|
|
872962c435 | ||
|
|
b9c4627a77 | ||
|
|
eff7a288cf | ||
|
|
94d419cf01 | ||
|
|
4760d576f3 | ||
|
|
800c9f3f02 | ||
|
|
6c7def3184 | ||
|
|
962279db29 | ||
|
|
bffe286f40 | ||
|
|
abc96dd648 | ||
|
|
6142dbdb25 | ||
|
|
01040ada26 | ||
|
|
ee2d83993a | ||
|
|
0103960645 | ||
|
|
b8e30e13f2 | ||
|
|
b8554af71b | ||
|
|
e130e945d5 | ||
|
|
7c4496ae1d | ||
|
|
cfd0914b9b | ||
|
|
53a6f0abfc | ||
|
|
0c2c9ca456 | ||
|
|
74a6d0a895 | ||
|
|
01e02502c0 | ||
|
|
e1e68ef2bf | ||
|
|
ea3bf622e9 | ||
|
|
da92b54fc9 | ||
|
|
664812ec56 | ||
|
|
86df3054dd | ||
|
|
43de4f8071 | ||
|
|
e685fade30 | ||
|
|
77cc97f0f0 | ||
|
|
39738c0c96 | ||
|
|
16bb46fb55 | ||
|
|
12511eb39f | ||
|
|
c594af7f43 | ||
|
|
10a9b75685 | ||
|
|
806121f95c | ||
|
|
9ee4face73 | ||
|
|
f1c40865da | ||
|
|
245e9e38aa | ||
|
|
a423c08559 | ||
|
|
497ad7bdc8 | ||
|
|
f897116e30 | ||
|
|
79b6a8bbb4 | ||
|
|
7e53bab0fc | ||
|
|
4955a730b7 | ||
|
|
b3ff17ab11 | ||
|
|
f19cc027ee | ||
|
|
ed66c2c0a2 | ||
|
|
e6ca1d2a36 | ||
|
|
c9534c2011 | ||
|
|
1b2036b57b | ||
|
|
247694ab3e | ||
|
|
215379eca4 | ||
|
|
7661b65df4 | ||
|
|
996064413f | ||
|
|
1c43385484 | ||
|
|
d2d21258d4 | ||
|
|
3847ba1c58 | ||
|
|
3a65a4bb39 | ||
|
|
6ab9b4ad1f | ||
|
|
f5127637ca | ||
|
|
f995fd14a6 | ||
|
|
9296d64571 | ||
|
|
f00556ab51 | ||
|
|
1c88e13a97 | ||
|
|
5fed1d523d | ||
|
|
c963847c31 | ||
|
|
bb21addebb | ||
|
|
bf2274b761 | ||
|
|
609ddc777f | ||
|
|
353bd009ed | ||
|
|
2070c1113d | ||
|
|
203df7576c | ||
|
|
4e8487967c | ||
|
|
1b1dbd3ec8 | ||
|
|
7f0e363cfe | ||
|
|
d6f4433541 | ||
|
|
5e79699ed9 | ||
|
|
e41edd2f5e | ||
|
|
b881fdd557 | ||
|
|
e648a81245 | ||
|
|
699c130edd | ||
|
|
acf802e7e1 | ||
|
|
f40e8c37e7 | ||
|
|
2d2043d7ae | ||
|
|
788878ca6c | ||
|
|
f96565a301 | ||
|
|
462ec87e89 | ||
|
|
e4b1971532 | ||
|
|
8a888930e3 | ||
|
|
963436aa4a | ||
|
|
a3a1088c14 | ||
|
|
590c310553 | ||
|
|
6dd282e6ca | ||
|
|
6150b4aea3 | ||
|
|
c41f8c76cf | ||
|
|
f593288b73 | ||
|
|
a4528aeb0c | ||
|
|
0453ce44fb | ||
|
|
1a6b08757b | ||
|
|
8c2d61de06 | ||
|
|
87fd964374 | ||
|
|
271fc14386 | ||
|
|
176dfed0af | ||
|
|
34057638ca | ||
|
|
bb29f02000 | ||
|
|
392f736925 | ||
|
|
73b401f3e7 | ||
|
|
f7c21f5fd1 | ||
|
|
6078aac714 | ||
|
|
c61bd05f75 | ||
|
|
89f225b980 | ||
|
|
66b0313ae9 | ||
|
|
e02650d1cc | ||
|
|
45bf17c48f | ||
|
|
854839b16a | ||
|
|
08c4122d5e | ||
|
|
ef23a7119e | ||
|
|
21bcb2ba23 | ||
|
|
c94ff7da3f | ||
|
|
71961eb998 | ||
|
|
9b4dc4f782 | ||
|
|
bb6d094383 | ||
|
|
ea7ed597de | ||
|
|
ba736a6fe1 | ||
|
|
ceb18cef3c | ||
|
|
327cd66dcc | ||
|
|
31779d8f7e | ||
|
|
d426662fd6 | ||
|
|
bf0285378d | ||
|
|
bde760e8b4 | ||
|
|
a358ee2d83 | ||
|
|
559ce91954 | ||
|
|
b5bd8b9b48 | ||
|
|
9576ec9b23 | ||
|
|
a582991c15 | ||
|
|
2d0abab222 | ||
|
|
8ec323dc95 | ||
|
|
630374def7 | ||
|
|
0e09c85efd | ||
|
|
89c0db5f36 | ||
|
|
debf61f714 | ||
|
|
0c53c1d32b | ||
|
|
d563ed4c68 | ||
|
|
3b39a5a8c4 | ||
|
|
50144c70dc | ||
|
|
482743bf3a | ||
|
|
f3a36af629 | ||
|
|
bc960ba585 | ||
|
|
e648764715 | ||
|
|
15fac6c472 | ||
|
|
61591020be | ||
|
|
f92b3c5fb9 | ||
|
|
a39a98615b | ||
|
|
2a3defdb34 | ||
|
|
733f8790ca | ||
|
|
4a9bbd9402 | ||
|
|
1562ca32f9 | ||
|
|
7b780e4fa1 | ||
|
|
444b30ef3d | ||
|
|
42b46496d1 | ||
|
|
f092da4108 | ||
|
|
8e635c1d28 | ||
|
|
3b8b7a0454 | ||
|
|
9ef1516b0b | ||
|
|
1af210058c | ||
|
|
6caad83081 | ||
|
|
520bfcfae1 | ||
|
|
2e5bc8953a | ||
|
|
f304562f01 | ||
|
|
c9690d9a1b | ||
|
|
5daface279 | ||
|
|
5687aaa5d9 | ||
|
|
ba67817ea0 | ||
|
|
e0452dbd0c | ||
|
|
c376061333 | ||
|
|
bb02ea6cf8 | ||
|
|
887beae614 | ||
|
|
1290d5c925 | ||
|
|
dffb340250 | ||
|
|
059298f537 | ||
|
|
0ba2144f2f | ||
|
|
83a2fb9860 | ||
|
|
5445fadf94 | ||
|
|
6d110b86dd | ||
|
|
2ef6d4738a | ||
|
|
65f7d4e109 | ||
|
|
6166c370ef | ||
|
|
ad1cade6bf | ||
|
|
803519062a | ||
|
|
6246c44994 | ||
|
|
9780aee40d | ||
|
|
e5e8baf1fd | ||
|
|
79e73b603f | ||
|
|
49c3a09129 | ||
|
|
4a24b7fa0f | ||
|
|
80aef10230 | ||
|
|
92055caf4d | ||
|
|
d8e284b0de | ||
|
|
0b98269695 | ||
|
|
402946b462 | ||
|
|
c374ceb0a7 | ||
|
|
6f21b0a5cd | ||
|
|
1c36bb7688 | ||
|
|
82a9d51731 | ||
|
|
ac03aa3c11 | ||
|
|
d8173a6d72 | ||
|
|
60c9ffa14c | ||
|
|
0751c2edcc | ||
|
|
7e050a699d | ||
|
|
9badada4a3 | ||
|
|
5e881272a1 | ||
|
|
a229a1eb3b | ||
|
|
b621adc6f6 | ||
|
|
7d3bc413ef | ||
|
|
6f9b7892b1 | ||
|
|
2332b544e6 | ||
|
|
3b091b7aa8 | ||
|
|
e1e430a968 | ||
|
|
2d49125e09 | ||
|
|
721047c95f | ||
|
|
74662de2b6 | ||
|
|
84ef0333f3 | ||
|
|
c6624843b9 | ||
|
|
8bd30913cd | ||
|
|
d7eb932ed3 | ||
|
|
4594dc5189 | ||
|
|
894a621e85 | ||
|
|
23dbf86e70 | ||
|
|
d86138ef1c | ||
|
|
4aa6f99676 | ||
|
|
10432e0f99 | ||
|
|
4f69f8bf92 | ||
|
|
1511dd3ca3 | ||
|
|
23967a81d6 | ||
|
|
21ff025376 | ||
|
|
9ba19f9539 | ||
|
|
215d802b12 | ||
|
|
21dccbe5d9 | ||
|
|
329150f1c4 | ||
|
|
17f7234b49 | ||
|
|
8e529d3845 | ||
|
|
a3f3539a27 | ||
|
|
d5c37e8f2c | ||
|
|
a5f5dfb0da | ||
|
|
08531c80f3 | ||
|
|
96ce207027 | ||
|
|
13f9a27af8 | ||
|
|
d0d40c02b5 | ||
|
|
0e541a19ba | ||
|
|
1d71c40a2b | ||
|
|
adc3e91122 | ||
|
|
b260208701 | ||
|
|
3dd517c37f | ||
|
|
93397bee77 | ||
|
|
3f72c01911 | ||
|
|
77e6a2e38c | ||
|
|
bef99183aa | ||
|
|
7b84229510 | ||
|
|
23c46a621e | ||
|
|
4dc7255bb0 | ||
|
|
180513c98a | ||
|
|
00894bc069 | ||
|
|
a1b128640d | ||
|
|
8bec4c62fa | ||
|
|
a7ba6a54a3 | ||
|
|
33d0b9a52d | ||
|
|
46546d0178 | ||
|
|
756ced0001 | ||
|
|
2addb39ba3 | ||
|
|
232ede4292 | ||
|
|
f161e84a1d | ||
|
|
4d4a8029f6 | ||
|
|
2a1dcddd90 | ||
|
|
4d319f97bc | ||
|
|
3372d4cb40 | ||
|
|
eb22bb669f | ||
|
|
0d64a66977 | ||
|
|
b0e81166e1 | ||
|
|
79d7aa7485 | ||
|
|
af995a84cb | ||
|
|
5a71bdca3c | ||
|
|
77d918fbe0 | ||
|
|
a0b255338d | ||
|
|
e0b09c144f | ||
|
|
418d09b07f | ||
|
|
4c7936b019 | ||
|
|
77f42b5b3e | ||
|
|
bd69f7a730 | ||
|
|
3ce6523248 | ||
|
|
2efed9dcfc | ||
|
|
88e2a0bc38 | ||
|
|
338e3f8547 | ||
|
|
d1ccc902da | ||
|
|
5e54e24b60 | ||
|
|
aae2f592b5 | ||
|
|
bd94e6a460 | ||
|
|
cbe3e1a786 | ||
|
|
7a0917e053 | ||
|
|
8ac4f7a65b | ||
|
|
453cd47e85 | ||
|
|
d5679ec529 | ||
|
|
2f0be502c3 | ||
|
|
ec971915b5 | ||
|
|
bb71b241d1 | ||
|
|
7aa01755ec | ||
|
|
8ce0ca1f24 |
No files matched your search
@@ -2,7 +2,8 @@ node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
data
|
||||
config
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.example
|
||||
.test-sandbox
|
||||
@@ -0,0 +1,16 @@
|
||||
# FanKarr : configuration du binaire Bun
|
||||
# Copier ce fichier en .env dans le même dossier que le binaire fankarr
|
||||
|
||||
# ── Authentification ──────────────────────────────────────────────────────────
|
||||
|
||||
# Clé secrète JWT (générée automatiquement si absente ; il est conseillé de la fixer)
|
||||
# JWT_SECRET=changez-moi-avec-une-chaine-aleatoire-longue
|
||||
|
||||
# Durée de validité de la session
|
||||
# Exemples : "1d", "7d", "30d", "1y" ; "never" ou "0" : pas d'expiration
|
||||
# AUTH_TOKEN_EXPIRY=30d
|
||||
|
||||
# ── Serveur ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Port d'écoute (défaut : 9898)
|
||||
# PORT=9898
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,64 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 300" width="1200" height="300">
|
||||
<defs>
|
||||
<radialGradient id="iconGlow" cx="477" cy="140" r="100" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#e8a030" stop-opacity="0.22"/>
|
||||
<stop offset="100%" stop-color="#e8a030" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="bgVignette" cx="50%" cy="50%" r="60%">
|
||||
<stop offset="0%" stop-color="#111827" stop-opacity="0"/>
|
||||
<stop offset="100%" stop-color="#070a10" stop-opacity="0.6"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="300" fill="#0b0e17" rx="16"/>
|
||||
<rect width="1200" height="300" fill="url(#bgVignette)" rx="16"/>
|
||||
<rect width="1200" height="300" fill="none" stroke="#1c2636" stroke-width="1" rx="16"/>
|
||||
|
||||
<!-- Glow behind kanji box -->
|
||||
<circle cx="477" cy="140" r="100" fill="url(#iconGlow)"/>
|
||||
|
||||
<!-- Kanji icon box -->
|
||||
<rect x="432" y="95" width="90" height="90" rx="14"
|
||||
fill="#0f1520"
|
||||
stroke="#e8a030" stroke-width="1.2" stroke-opacity="0.5"/>
|
||||
|
||||
<!-- Kanji 改 (fankai) -->
|
||||
<text
|
||||
x="477" y="140"
|
||||
font-family="'Yu Mincho', 'MS Mincho', 'Hiragino Mincho Pro', 'Noto Serif CJK JP', 'Source Han Serif', serif"
|
||||
font-size="54"
|
||||
font-weight="700"
|
||||
fill="#e8a030"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
>改</text>
|
||||
|
||||
<!-- FanKarr -->
|
||||
<text
|
||||
x="542" y="140"
|
||||
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Helvetica, Arial, sans-serif"
|
||||
font-size="68"
|
||||
font-weight="700"
|
||||
fill="#ffffff"
|
||||
dominant-baseline="central"
|
||||
letter-spacing="-1"
|
||||
>FanKarr</text>
|
||||
|
||||
<!-- Thin separator -->
|
||||
<line x1="490" y1="204" x2="710" y2="204"
|
||||
stroke="#e8a030" stroke-width="0.6" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text
|
||||
x="600" y="228"
|
||||
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Helvetica, Arial, sans-serif"
|
||||
font-size="14"
|
||||
font-weight="300"
|
||||
font-style="italic"
|
||||
fill="#4a6a84"
|
||||
text-anchor="middle"
|
||||
letter-spacing="2"
|
||||
>par des fans, pour des fans</text>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 859 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -14,11 +14,11 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -48,11 +48,21 @@ jobs:
|
||||
bun-target: bun-windows-x64
|
||||
binary: binaries/fankarr.exe
|
||||
archive: fankarr-windows-x64.zip
|
||||
- target: windows-x64-legacy
|
||||
os: ubuntu-latest
|
||||
bun-target: bun-windows-x64-baseline
|
||||
binary: binaries/fankarr.exe
|
||||
archive: fankarr-windows-x64-legacy.zip
|
||||
- target: linux-x64
|
||||
os: ubuntu-latest
|
||||
bun-target: bun-linux-x64
|
||||
binary: binaries/fankarr
|
||||
archive: fankarr-linux-x64.zip
|
||||
- target: linux-arm64
|
||||
os: ubuntu-latest
|
||||
bun-target: bun-linux-arm64
|
||||
binary: binaries/fankarr
|
||||
archive: fankarr-linux-arm64.zip
|
||||
- target: macos
|
||||
os: macos-latest
|
||||
bun-target: bun-darwin-arm64
|
||||
@@ -68,11 +78,11 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -101,15 +111,14 @@ jobs:
|
||||
run: bun build server/organize-worker.ts --target node --outfile organize-worker.js
|
||||
|
||||
- name: Package archive (Windows)
|
||||
if: matrix.target == 'windows-x64'
|
||||
run: Compress-Archive -Path ${{ matrix.binary }}, organize-worker.js, public -DestinationPath ${{ matrix.archive }}
|
||||
if: matrix.target == 'windows-x64' || matrix.target == 'windows-x64-legacy'
|
||||
run: Compress-Archive -Path ${{ matrix.binary }}, organize-worker.js, public, version.txt -DestinationPath ${{ matrix.archive }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Package archive (Unix)
|
||||
if: matrix.target != 'windows-x64'
|
||||
if: matrix.target != 'windows-x64' && matrix.target != 'windows-x64-legacy'
|
||||
run: |
|
||||
chmod +x ${{ matrix.binary }}
|
||||
zip -r ${{ matrix.archive }} ${{ matrix.binary }} organize-worker.js public/
|
||||
zip -r ${{ matrix.archive }} ${{ matrix.binary }} organize-worker.js public/ version.txt
|
||||
|
||||
- name: Upload archive to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||
@@ -9,8 +9,28 @@ name: release-please
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
token: ${{ secrets.FINED_TOKEN }}
|
||||
release-type: simple
|
||||
release-type: simple
|
||||
|
||||
# Quand une release est créée, on écrit version.txt et on le committe
|
||||
- uses: actions/checkout@v4
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
with:
|
||||
token: ${{ secrets.FINED_TOKEN }}
|
||||
|
||||
- name: Write version.txt
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
run: |
|
||||
echo "${{ steps.release.outputs.tag_name }}" > version.txt
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add version.txt
|
||||
git commit -m "chore: update version.txt to ${{ steps.release.outputs.tag_name }}"
|
||||
git push
|
||||
@@ -1,3 +1,6 @@
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -7,6 +10,7 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
config
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
@@ -23,3 +27,8 @@ binaries
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Instance de test locale
|
||||
.test-sandbox
|
||||
scripts/seed-test-sandbox.mjs
|
||||
docker-compose.test.yml
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM node:20-slim AS base
|
||||
FROM node:22-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack enable && corepack prepare pnpm@10 --activate
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
@@ -15,7 +15,8 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN pnpm run build
|
||||
|
||||
# ── Production ─────────────────────────────────────────────────
|
||||
FROM node:20-slim
|
||||
FROM node:22-slim
|
||||
ARG BUILD_VERSION=dev
|
||||
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -24,8 +25,9 @@ COPY --from=build /app/dist/server /app/dist/server
|
||||
COPY --from=build /app/dist/client /app/public
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
RUN mkdir -p /app/data
|
||||
RUN mkdir -p /config
|
||||
RUN echo "$BUILD_VERSION" > /app/version.txt
|
||||
|
||||
EXPOSE 3001
|
||||
EXPOSE 9898
|
||||
ENV NODE_ENV=production
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -1,69 +1,111 @@
|
||||
<div align="center">
|
||||
|
||||
# Fankarr
|
||||
|
||||
# FanKarr
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
|
||||
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
|
||||
Inspiré de Radarr et Sonarr, pour les éditions Kai et Yabai
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||

|
||||
## Aperçu
|
||||
|
||||
> Vue catalogue : toutes les séries Fankai avec leur état d'organisation. Les badges indiquent si les fichiers sont déjà présents dans votre médiathèque Jellyfin.
|
||||

|
||||
|
||||
> Accueil : le dernier import, les séries ajoutées récemment et les chiffres de la médiathèque. L'administrateur y retrouve ce qui attend une action : demandes, imports en erreur, dossiers à renommer.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Médiathèque : tout le catalogue Fankai. Chaque affiche indique l'état de la série (importée, partielle, en téléchargement, dossier à renommer, nouveauté), avec des filtres, un tri et plusieurs tailles d'affiches.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Fiche d'une série : l'import saison par saison et épisode par épisode, avec le téléchargement d'un épisode, d'une saison ou de toute la série, l'import manuel et la surveillance des nouveaux épisodes.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Activité : un torrent par ligne, du téléchargement à l'import, avec le détail des erreurs et un bouton pour relancer l'import.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/mobile-dashboard.webp" width="260" alt="Accueil sur mobile">
|
||||
<img src=".github/images/mobile-serie.webp" width="260" alt="Fiche d'une série sur mobile">
|
||||
<img src=".github/images/mobile-activity.webp" width="260" alt="Activité sur mobile">
|
||||
</p>
|
||||
|
||||
> Sur mobile : l'interface s'adapte au téléphone, avec une barre d'onglets en bas de l'écran.
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Catalogue complet** des séries Fankai avec affiches, statut et badges d'état
|
||||
- **Téléchargement en un clic** vers qBittorrent — par épisode, par saison ou intégrale
|
||||
- **Organisation automatique** des fichiers téléchargés vers votre médiathèque (hardlink ou déplacement)
|
||||
- **Suivi par épisode** — badge ✓ organisé sur chaque épisode et compteur par saison
|
||||
- **Notifications** toast automatiques quand une organisation se termine
|
||||
- **Page téléchargements** avec statut d'organisation et badge erreurs avec détail au hover
|
||||
- **Logs centralisés** avec filtre par niveau/source, rotation automatique et clear depuis l'UI
|
||||
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
|
||||
- **Authentification** par mot de passe avec session JWT
|
||||
- **Compatible Runtipi / Docker**
|
||||
### Téléchargement
|
||||
- Tout le catalogue Fankai, avec les affiches et l'état de chaque série
|
||||
- Envoi au client torrent par épisode, par saison ou en intégrale
|
||||
- Choix de la source quand plusieurs torrents existent (qualité, pack)
|
||||
- Bouton « Tout télécharger » : l'intégrale choisie, complétée par les packs des épisodes qu'elle ne contient pas
|
||||
- Progression par épisode, même pour un fichier contenu dans un pack (si le client torrent l'indique)
|
||||
- Surveillance : les nouveaux épisodes des séries surveillées sont envoyés au client (vérification toutes les 6 heures)
|
||||
|
||||
### Organisation
|
||||
- Import automatique des téléchargements terminés dans la médiathèque (vérification toutes les 5 minutes)
|
||||
- Trois modes d'import : Hardlink (recommandé), Copier ou Déplacer. Avec un hardlink, le client torrent continue de partager le fichier.
|
||||
- Analyse de la médiathèque : les épisodes déjà présents sur le disque sont reconnus sans être déplacés
|
||||
- En option, les épisodes dont le fichier a disparu sont retirés de la médiathèque
|
||||
- Quand vous retirez une série de la médiathèque en supprimant ses fichiers, son dossier est effacé et ses torrents sont retirés du client
|
||||
|
||||
### Demandes
|
||||
- Les invités demandent une série, une saison ou des épisodes, depuis FanKarr ou depuis le plugin Jellyfin
|
||||
- Les administrateurs approuvent, refusent (avec un message) ou marquent une demande comme disponible
|
||||
- Approbation automatique, pour tous les invités ou certains seulement : la demande est approuvée et le torrent part aussitôt au client
|
||||
- Bouton « Tout supprimer » pour vider la liste
|
||||
- Une demande passe à « disponible » quand des épisodes de la série sont importés, et les demandes disponibles sont supprimées quand la série est retirée de la médiathèque
|
||||
|
||||
### Métadonnées et affichage
|
||||
- Badges de langue VOSTFR et MULTI sur chaque épisode, détectés automatiquement
|
||||
- Fichiers NFO et images en option, pour Infuse ou les lecteurs sans agent Fankai
|
||||
- Plex : connexion au compte, installation de l'agent de métadonnées Fankai et création de la bibliothèque (Plex Media Server 1.43 ou plus récent pour l'agent)
|
||||
- Jellyfin, Emby et Kodi : métadonnées Fankai avec le [plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) ou l'add-on [Fankai pour Kodi](https://github.com/Nackophilz/fankai_kodi) (Kodi 20 ou plus récent)
|
||||
|
||||
### Utilisateurs et Jellyfin
|
||||
- Plusieurs comptes, avec deux rôles (administrateur et invité) et des liens d'invitation
|
||||
- Synchronisation Jellyfin : un compte FanKarr est créé pour chaque utilisateur Jellyfin, toutes les heures ou à la demande
|
||||
- Connexion depuis le plugin Jellyfin avec le compte Jellyfin
|
||||
- Un jeton d'API personnel par utilisateur pour l'API publique
|
||||
|
||||
### Système
|
||||
- Journaux filtrables par niveau et par source, avec rotation automatique, que vous pouvez vider depuis l'interface
|
||||
- Authentification par mot de passe, session JWT
|
||||
- Clients torrent pris en charge : qBittorrent, Transmission, Deluge, rTorrent, uTorrent, Synology Download Station et Real-Debrid. Vous pouvez en configurer plusieurs.
|
||||
- Installation avec Docker, Runtipi ou un binaire autonome
|
||||
|
||||
---
|
||||
|
||||

|
||||
## Données des torrents (scraper)
|
||||
|
||||
> Vue série : détail des épisodes avec leur état de disponibilité torrent et d'organisation dans Jellyfin.
|
||||
Les données du catalogue viennent du dépôt [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
|
||||
|
||||
---
|
||||
Ce dépôt récupère les torrents Fankai sur les trackers publics, les associe aux épisodes grâce à l'API Fankai et publie le résultat toutes les 6 heures avec GitHub Actions : un fichier par série dans le dossier `series/`, et des fichiers d'index à la racine (`available.json`, `infohash_map.json`).
|
||||
|
||||

|
||||
|
||||
> Vue téléchargements : suivi en temps réel de l'organisation avec détail des erreurs au survol.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Page logs : historique complet des événements avec filtres et gestion de la taille.
|
||||
|
||||
---
|
||||
|
||||
## Données torrent — Scraper
|
||||
|
||||
Les données du catalogue sont récupérées depuis le repo [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
|
||||
|
||||
Ce repo contient un pipeline de scripts Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, puis les croise avec l'API metadata de Fankai pour identifier chaque épisode. Les scripts tournent automatiquement **toutes les 6 heures** via GitHub Actions et publient le fichier `torrent_final.json` dans le repo.
|
||||
|
||||
Fankarr récupère ce fichier au démarrage et via le bouton **Mettre à jour** dans les paramètres. Aucun scraping ne se fait en local.
|
||||
FanKarr lit ces données au démarrage et les garde une heure en cache. Pour les recharger tout de suite, utilisez **Synchroniser maintenant** dans **Paramètres › Catalogue Fankai**. Les séries qui ne sont pas encore dans le scraper sont complétées par l'API Fankai, sans torrents. La collecte des torrents ne se fait pas sur votre serveur.
|
||||
|
||||
---
|
||||
|
||||
@@ -71,7 +113,7 @@ Fankarr récupère ce fichier au démarrage et via le bouton **Mettre à jour**
|
||||
|
||||
### Docker Compose (recommandé)
|
||||
|
||||
**Prérequis** : Docker + Docker Compose, qBittorrent accessible en réseau.
|
||||
**Prérequis** : Docker et Docker Compose, et un client torrent joignable depuis FanKarr.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -81,13 +123,12 @@ services:
|
||||
environment:
|
||||
- PUID=1000 # UID de votre utilisateur (id -u)
|
||||
- PGID=1000 # GID de votre utilisateur (id -g)
|
||||
- TZ=Europe/Paris # Votre timezone
|
||||
- TZ=Europe/Paris # Fuseau horaire
|
||||
volumes:
|
||||
- ./data:/app/data # Config, logs, base de données
|
||||
- /votre/chemin/media:/media/Kai # Destination Jellyfin
|
||||
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
|
||||
- ./config:/config # Configuration, journaux et données
|
||||
- /votre/chemin:/media # Dossier parent de la médiathèque et des téléchargements (nécessaire aux hardlinks)
|
||||
ports:
|
||||
- 3001:3001
|
||||
- 9898:9898
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
@@ -95,97 +136,279 @@ services:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Binaire autonome (Windows / Linux / macOS)
|
||||
> **Hardlinks** : le dossier des téléchargements terminés et la médiathèque doivent être sur le même système de fichiers. Montez un seul volume parent qui contient les deux (ex. `/votre/chemin:/media`), puis choisissez vos dossiers à l'intérieur, comme avec Radarr et Sonarr.
|
||||
>
|
||||
> Vous pouvez monter plusieurs volumes, mais les hardlinks ne fonctionnent pas d'un volume à l'autre ; FanKarr copie alors les fichiers.
|
||||
>
|
||||
> Si le client torrent tourne dans un autre conteneur ou sur une autre machine, renseignez dans ses réglages le chemin distant (le dossier tel que le client le voit) et le chemin local (le même dossier tel que FanKarr le voit).
|
||||
|
||||
Téléchargez l'archive correspondant à votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
|
||||
---
|
||||
|
||||
| Système | Archive |
|
||||
|---|---|
|
||||
| Windows x64 | `fankarr-windows-x64.zip` |
|
||||
| Linux x64 | `fankarr-linux-x64.zip` |
|
||||
| macOS (Apple Silicon) | `fankarr-macos.zip` |
|
||||
### Binaire autonome (Windows, Linux, macOS)
|
||||
|
||||
Extrayez l'archive — elle contient le binaire **et** le dossier `public/` qui doivent rester ensemble :
|
||||
Téléchargez l'archive de votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
|
||||
|
||||
| Système | Archive |
|
||||
| ---------------------------------------- | ---------------------------------- |
|
||||
| Windows x64 | `fankarr-windows-x64.zip` |
|
||||
| Windows x64, anciens processeurs | `fankarr-windows-x64-legacy.zip` |
|
||||
| Linux x64 | `fankarr-linux-x64.zip` |
|
||||
| Linux ARM64 | `fankarr-linux-arm64.zip` |
|
||||
| macOS (Apple Silicon) | `fankarr-macos.zip` |
|
||||
|
||||
Extrayez l'archive dans un dossier. Tous les fichiers doivent rester ensemble :
|
||||
|
||||
```
|
||||
fankarr-windows-x64/
|
||||
├── fankarr.exe ← lancer ce fichier
|
||||
└── public/ ← ne pas déplacer séparément
|
||||
fankarr/
|
||||
├── fankarr.exe # à lancer (fankarr sous Linux et macOS)
|
||||
├── organize-worker.js # tâche d'import, à garder à côté du binaire
|
||||
├── public/ # fichiers de l'interface, à garder à côté du binaire
|
||||
├── version.txt
|
||||
└── .env # facultatif, à créer (voir Variables d'environnement)
|
||||
```
|
||||
|
||||
**Linux / macOS** : rendre le binaire exécutable avant le premier lancement :
|
||||
> Le fichier `.env` sert à régler le port, la durée des sessions, etc. sans passer par les variables d'environnement du système. S'il est présent à côté du binaire, il est chargé au démarrage. Le fichier [`.env.example`](.env.example) du dépôt sert de modèle.
|
||||
|
||||
**Linux et macOS** : dans l'archive, le binaire est rangé dans un sous-dossier `binaries/`. Placez-le à côté de `public/` et rendez-le exécutable avant le premier lancement :
|
||||
|
||||
```bash
|
||||
wget https://github.com/masutayunikon/fankarr/releases/latest/download/fankarr-linux-x64.zip
|
||||
unzip fankarr-linux-x64.zip -d fankarr
|
||||
cd fankarr
|
||||
# Le binaire est extrait dans binaries/ : le placer à côté de public/ et de organize-worker.js
|
||||
mv binaries/fankarr .
|
||||
chmod +x fankarr
|
||||
./fankarr
|
||||
```
|
||||
|
||||
Fankarr sera accessible sur `http://localhost:3001`. Les données (config, logs, base de données) sont sauvegardées dans un dossier `data/` créé automatiquement à côté du binaire.
|
||||
Pour Linux ARM64 ou macOS, remplacez le nom de l'archive.
|
||||
|
||||
FanKarr est alors accessible sur `http://localhost:9898`. La configuration, les journaux et les données sont enregistrés dans un dossier `config/`, créé automatiquement à côté du binaire.
|
||||
|
||||
---
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Défaut | Description |
|
||||
|---|---|---|
|
||||
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
|
||||
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
|
||||
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
|
||||
| `PORT` | `3001` | Port d'écoute du serveur |
|
||||
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `data/secret.key` si absent |
|
||||
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
|
||||
| Variable | Défaut | Description |
|
||||
| ------------------- | ------------- | ------------------------------------------------------------------------------------ |
|
||||
| `PUID` | `1000` | UID propriétaire des fichiers de `/config` (Docker) |
|
||||
| `PGID` | `1000` | GID propriétaire des fichiers de `/config` (Docker) |
|
||||
| `TZ` | non défini | Fuseau horaire (ex. `Europe/Paris`) |
|
||||
| `PORT` | `9898` | Port d'écoute du serveur |
|
||||
| `JWT_SECRET` | généré | Secret des sessions, généré dans `secret.key` (dossier de configuration) si absent |
|
||||
| `AUTH_TOKEN_EXPIRY` | `30d` | Durée de validité de la session (ex. `7d`, `1y` ; `never` ou `0` : pas d'expiration) |
|
||||
| `GITHUB_BASE` | dépôt scraper | Adresse de base des données du scraper, si vous hébergez votre propre copie |
|
||||
|
||||
### Premier démarrage
|
||||
Par défaut, `GITHUB_BASE` vaut `https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main`.
|
||||
|
||||
1. Ouvrir `http://localhost:3001`
|
||||
2. Créer un mot de passe à la première connexion
|
||||
3. Aller dans **Paramètres** → ajouter votre client qBittorrent
|
||||
4. Vérifier les chemins `completePath` et `mediaPath`
|
||||
5. Choisir le mode d'organisation : `hardlink` (recommandé) ou `move`
|
||||
6. Retourner au catalogue et télécharger
|
||||
---
|
||||
|
||||
### Premier lancement
|
||||
|
||||
1. Ouvrez `http://localhost:9898` (ou l'adresse de votre serveur).
|
||||
2. Créez le compte administrateur.
|
||||
3. Suivez l'assistant de configuration :
|
||||
- dossiers : téléchargements terminés, médiathèque et mode d'import (Hardlink, recommandé, Copier ou Déplacer) ;
|
||||
- client torrent ;
|
||||
- options d'import : import automatique, nommage des dossiers, fichiers NFO ;
|
||||
- serveur multimédia, Jellyfin ou Plex (facultatif) ;
|
||||
- catalogue : synchronisation des séries Fankai, puis analyse de la médiathèque si elle contient déjà des épisodes.
|
||||
4. Terminez avec la visite guidée, qui présente les écrans principaux, ou passez-la.
|
||||
|
||||

|
||||
|
||||
Tous ces réglages restent modifiables dans les paramètres, et l'assistant peut être relancé.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Organisation des fichiers
|
||||
|
||||
Fankarr organise automatiquement les fichiers terminés toutes les **30 secondes** vers la structure attendue par Jellyfin :
|
||||
FanKarr range les fichiers terminés selon la structure attendue par Jellyfin et Plex :
|
||||
|
||||
```
|
||||
/media/Kai/
|
||||
└── Black Lagoon Henshū/
|
||||
└── Saison 1/
|
||||
├── Black Lagoon Henshū S01E01.mkv
|
||||
├── Black Lagoon Henshū S01E02.mkv
|
||||
└── ...
|
||||
Black Lagoon Henshū/
|
||||
├── Saison 1/
|
||||
│ ├── Black Lagoon Henshū.S01E01.MULTI.1080p.x264-FANKAI.mkv
|
||||
│ ├── Black Lagoon Henshū.S01E02.MULTI.1080p.x264-FANKAI.mkv
|
||||
│ ├── Black Lagoon Henshū.S01E03.MULTI.1080p.x264-FANKAI.mkv
|
||||
│ └── Black Lagoon Henshū.S01E04.MULTI.1080p.x264-FANKAI.mkv
|
||||
├── Saison 2/
|
||||
│ ├── Black Lagoon Henshū.S02E05.MULTI.1080p.x264-FANKAI.mkv
|
||||
│ ├── Black Lagoon Henshū.S02E06.MULTI.1080p.x264-FANKAI.mkv
|
||||
│ └── Black Lagoon Henshū.S02E07.MULTI.1080p.x264-FANKAI.mkv
|
||||
└── Saison 3/
|
||||
└── Black Lagoon Henshū.S03E08.MULTI.1080p.x264-FANKAI.mkv
|
||||
```
|
||||
|
||||
Le mode **hardlink** est recommandé si vos dossiers `complete` et `media` sont sur le même filesystem — les fichiers ne sont pas déplacés, qBittorrent continue de seeder.
|
||||
Une option permet de nommer les dossiers de saison « Season 01 » plutôt que « Saison 1 ».
|
||||
|
||||
Le mode Hardlink est recommandé quand le dossier des téléchargements et la médiathèque sont sur le même système de fichiers : le fichier n'est pas copié et le client torrent continue de le partager.
|
||||
|
||||
L'import se lance :
|
||||
- automatiquement, si l'import automatique est activé : FanKarr cherche les téléchargements terminés toutes les 5 minutes ;
|
||||
- à la main, avec le bouton **Importer** de la page Activité.
|
||||
|
||||
L'analyse de la médiathèque (**Paramètres › Gestion des médias › Analyser la médiathèque**) reconnaît les fichiers déjà présents sur le disque sans les déplacer, utile pour une médiathèque existante.
|
||||
|
||||
---
|
||||
|
||||
## Journaux
|
||||
|
||||

|
||||
|
||||
> Les journaux sont dans **Paramètres › Journaux**. Chaque événement est horodaté et filtrable par niveau (`info`, `warn`, `error`, `debug`) et par source : `api`, `organize`, `rss-sync`, `requests`, `jellyfin`, `plex`, `auth`, `torrent-clients`, une source par client torrent (`qbittorrent`, `transmission`…), etc.
|
||||
|
||||
Le fichier garde environ les 2 000 dernières lignes. Les messages `debug` ne sont pas enregistrés quand `NODE_ENV=production`, ce qui est le cas dans l'image Docker.
|
||||
|
||||
---
|
||||
|
||||
## Plugin Jellyfin FanKarr Search
|
||||
|
||||
Un plugin Jellyfin intègre la recherche FanKarr à l'interface de votre serveur : vos utilisateurs parcourent le catalogue et demandent des séries sans quitter Jellyfin.
|
||||
|
||||
**[jellyfin-plugin-fankarr-search](https://github.com/Masutayunikon/jellyfin-plugin-fankarr-search)**
|
||||
|
||||
Le plugin nécessite **[Jellyfin JavaScript Injector](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector)**. Les instructions d'installation sont dans le README du plugin.
|
||||
|
||||
### Synchronisation des utilisateurs
|
||||
|
||||
FanKarr crée un compte invité pour chaque utilisateur Jellyfin actif qui n'en a pas encore (même nom d'utilisateur). La synchronisation se fait :
|
||||
- automatiquement toutes les heures, si Jellyfin est configuré ;
|
||||
- à la demande, depuis **Paramètres › Jellyfin et API › Synchroniser les utilisateurs**.
|
||||
|
||||
Les comptes ainsi créés reçoivent un mot de passe aléatoire. Ces utilisateurs se connectent par le plugin, qui échange leur session Jellyfin contre leur jeton FanKarr.
|
||||
|
||||
---
|
||||
|
||||
## API publique
|
||||
|
||||
FanKarr expose une API publique, sous `/api/v1`, utilisée par le plugin Jellyfin et ouverte aux intégrations tierces.
|
||||
|
||||
**Authentification** : `Authorization: Bearer <jeton>` (jeton visible dans **Paramètres › Mon profil › Jeton d'API**)
|
||||
|
||||
| Méthode | Route | Description |
|
||||
|---------|-------|-------------|
|
||||
| `POST` | `/api/v1/auth/jellyfin` | Échange une session Jellyfin contre le jeton FanKarr de l'utilisateur |
|
||||
| `GET` | `/api/v1/auth/me` | Compte associé au jeton |
|
||||
| `GET` | `/api/v1/series/search?q=` | Recherche par titre ; renvoie note, année, description et demande en cours |
|
||||
| `GET` | `/api/v1/series/:id` | Détail d'une série : saisons et épisodes |
|
||||
| `POST` | `/api/v1/requests` | Crée une demande ou complète la demande en cours |
|
||||
| `GET` | `/api/v1/requests` | Demandes de l'utilisateur du jeton |
|
||||
|
||||
### Connexion avec Jellyfin
|
||||
|
||||
```http
|
||||
POST /api/v1/auth/jellyfin
|
||||
Content-Type: application/json
|
||||
|
||||
{ "jellyfinUserId": "...", "jellyfinToken": "..." }
|
||||
```
|
||||
|
||||
Renvoie `{ token, username, role }`. Le `token` s'utilise ensuite en `Bearer` pour tous les autres appels. Si aucun compte FanKarr ne porte le nom de l'utilisateur Jellyfin, la réponse est une erreur 404 : lancez la synchronisation Jellyfin.
|
||||
|
||||
### Recherche
|
||||
|
||||
```http
|
||||
GET /api/v1/series/search?q=dragon+ball
|
||||
Authorization: Bearer <jeton>
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 42,
|
||||
"title": "Dragon Ball Z Kai",
|
||||
"original_title": "DRAGON BALL Z KAI",
|
||||
"image": "https://...",
|
||||
"year": 2009,
|
||||
"rating": 8.5,
|
||||
"description": "Suite de Dragon Ball...",
|
||||
"request": {
|
||||
"id": "uuid",
|
||||
"status": "pending",
|
||||
"seasons": [1, 2],
|
||||
"episodes": []
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
La recherche porte sur le titre et le titre original, sans tenir compte de la casse, parmi les séries présentes dans les données du scraper. Elle renvoie 50 résultats au maximum ; sans `q`, elle renvoie les 50 premières séries.
|
||||
|
||||
Le champ `request` vaut `null` si l'utilisateur n'a pas de demande en cours pour cette série.
|
||||
|
||||
### Détail d'une série (saisons et épisodes)
|
||||
|
||||
```http
|
||||
GET /api/v1/series/42
|
||||
Authorization: Bearer <jeton>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"title": "Dragon Ball Z Kai",
|
||||
"original_title": "DRAGON BALL Z KAI",
|
||||
"image": "https://...",
|
||||
"seasons": [
|
||||
{
|
||||
"season_number": 1,
|
||||
"episodes": [
|
||||
{ "id": 101, "episode_number": 1, "title": "...", "image": "https://..." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Créer une demande
|
||||
|
||||
```http
|
||||
POST /api/v1/requests
|
||||
Authorization: Bearer <jeton>
|
||||
Content-Type: application/json
|
||||
|
||||
{ "serieId": 42, "serieName": "Dragon Ball Z Kai", "seasons": [1, 2] }
|
||||
```
|
||||
|
||||
- `serieId` et `serieName` sont obligatoires.
|
||||
- `seasons` : numéros de saison (`season_number`). Sans saison ni épisode, la demande porte sur toute la série.
|
||||
- `episodes` : identifiants d'épisodes (`episode.id`). Prioritaires sur `seasons` s'ils sont renseignés.
|
||||
- Si une demande est déjà en cours pour cette série, les saisons et épisodes sont **fusionnés**.
|
||||
- Si l'approbation automatique est activée pour l'utilisateur, la demande est approuvée et le torrent envoyé au client.
|
||||
|
||||
---
|
||||
|
||||
## Runtipi
|
||||
|
||||
Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
||||
FanKarr est disponible dans le dépôt d'applications Runtipi de Masutayunikon. Pour l'installer :
|
||||
|
||||
1. Dans Runtipi, aller dans **Paramètres → App Stores**
|
||||
2. Ajouter l'URL : `https://github.com/Masutayunikon/runtipi-appstore`
|
||||
3. Fankarr apparaît dans le catalogue → **Installer**
|
||||
1. Dans Runtipi, ouvrez **Paramètres › App Stores**.
|
||||
2. Ajoutez l'adresse `https://github.com/Masutayunikon/runtipi-appstore`.
|
||||
3. FanKarr apparaît dans la liste des applications : cliquez sur **Installer**.
|
||||
|
||||
---
|
||||
|
||||
## Stack technique
|
||||
## Technologies
|
||||
|
||||
| Couche | Technologie |
|
||||
|---|---|
|
||||
| Frontend | Vue 3 + Vite + Tailwind CSS |
|
||||
| Backend | Express 5 + TypeScript |
|
||||
| Auth | JWT + bcrypt |
|
||||
| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) |
|
||||
| Packaging binaire | Bun (self-contained, pas de Node requis) |
|
||||
| Partie | Technologie |
|
||||
| ----------------- | ------------------------------------------------- |
|
||||
| Interface | Vue 3, Vite, Tailwind CSS v4 |
|
||||
| Serveur | Express 5, TypeScript |
|
||||
| Authentification | JWT, bcrypt |
|
||||
| Import | Worker threads Node (import en arrière-plan) |
|
||||
| Image Docker | `node:22-slim`, pnpm, gosu (PUID/PGID) |
|
||||
| Binaire | Bun (exécutable autonome, sans Node) |
|
||||
|
||||
---
|
||||
|
||||
## Liens
|
||||
|
||||
- [fankai.fr](https://fankai.fr) — Le projet Fankai
|
||||
- [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) — Reconnaissance des métadonnées dans Jellyfin
|
||||
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) — Pipeline de collecte des torrents
|
||||
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
|
||||
- [fankai.fr](https://fankai.fr) : le projet Fankai
|
||||
- [Plugin Jellyfin FanKarr Search](https://github.com/Masutayunikon/jellyfin-plugin-fankarr-search) : recherche et demandes FanKarr dans Jellyfin
|
||||
- [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) : reconnaissance des métadonnées Fankai dans Jellyfin et Emby
|
||||
- [Fankai pour Kodi](https://github.com/Nackophilz/fankai_kodi) : fournisseur de métadonnées Fankai pour Kodi
|
||||
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) : collecte des torrents
|
||||
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) : dépôt d'applications Runtipi
|
||||
@@ -5,11 +5,10 @@ services:
|
||||
environment:
|
||||
- PUID=1000 # UID de votre utilisateur (id -u)
|
||||
- PGID=1000 # GID de votre utilisateur (id -g)
|
||||
- TZ=Europe/Paris # Votre timezone
|
||||
- TZ=Europe/Paris # Fuseau horaire
|
||||
volumes:
|
||||
- ./data:/app/data # Config, logs, base de données
|
||||
- /votre/chemin/media:/media/Kai # Destination Jellyfin
|
||||
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
|
||||
- ./config:/config # Configuration, journaux et données
|
||||
- /votre/chemin:/media # Dossier parent de la médiathèque et des téléchargements (nécessaire aux hardlinks)
|
||||
ports:
|
||||
- 3001:3001
|
||||
restart: unless-stopped
|
||||
- 9898:9898
|
||||
restart: unless-stopped
|
||||
@@ -4,9 +4,9 @@ set -e
|
||||
PUID=${PUID:-1000}
|
||||
PGID=${PGID:-1000}
|
||||
|
||||
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
|
||||
echo "[fankarr] Exécution en tant que UID=${PUID} GID=${PGID}"
|
||||
|
||||
# Corriger les permissions du dossier data
|
||||
chown -R "$PUID:$PGID" /app/data
|
||||
# Attribuer /config à PUID:PGID
|
||||
chown -R "$PUID:$PGID" /config
|
||||
|
||||
exec gosu "$PUID:$PGID" node dist/server/index.js
|
||||
@@ -1,10 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Serif+Display&display=swap" rel="stylesheet">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;600;700;800&family=Zen+Kaku+Gothic+New:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>fankarr</title>
|
||||
<meta name="theme-color" content="#13100c" />
|
||||
<title>FanKarr</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"dev:client": "vite",
|
||||
"build": "vite build && tsc -p tsconfig.server.json",
|
||||
"build": "vue-tsc -p tsconfig.app.json --noEmit && vite build && tsc -p tsconfig.server.json",
|
||||
"typecheck": "vue-tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.server.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"build:bun:linux": "bun build server/index.ts --compile --target bun-linux-x64 --outfile binaries/fankarr-linux-x64 && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
"build:bun:windows": "bun build server/index.ts --compile --target bun-windows-x64 --outfile binaries/fankarr-windows-x64.exe && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
"build:bun:macos": "bun build server/index.ts --compile --target bun-darwin-arm64 --outfile binaries/fankarr-macos && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
@@ -17,18 +19,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"undici": "^8.1.0",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.3",
|
||||
"bcryptjs": "^3.0.3"
|
||||
"vue-router": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^24.10.1",
|
||||
@@ -38,7 +46,7 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.1.5",
|
||||
"@types/bcrypt": "^5.0.2"
|
||||
"vitest": "^5.0.0",
|
||||
"vue-tsc": "^3.1.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,18 +11,27 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vueuse/core':
|
||||
specifier: ^14.2.1
|
||||
version: 14.2.1(vue@3.5.29(typescript@5.9.3))
|
||||
bcryptjs:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
cookie-parser:
|
||||
specifier: ^1.4.7
|
||||
version: 1.4.7
|
||||
cors:
|
||||
specifier: ^2.8.6
|
||||
version: 2.8.6
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.3
|
||||
version: 9.0.3
|
||||
lucide-vue-next:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0(vue@3.5.29(typescript@5.9.3))
|
||||
node-fetch:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
@@ -32,6 +41,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
undici:
|
||||
specifier: ^8.1.0
|
||||
version: 8.1.0
|
||||
vue:
|
||||
specifier: ^3.5.25
|
||||
version: 3.5.29(typescript@5.9.3)
|
||||
@@ -45,6 +57,9 @@ importers:
|
||||
'@types/cookie-parser':
|
||||
specifier: ^1.4.8
|
||||
version: 1.4.10(@types/express@5.0.6)
|
||||
'@types/cors':
|
||||
specifier: ^2.8.19
|
||||
version: 2.8.19
|
||||
'@types/express':
|
||||
specifier: ^5.0.6
|
||||
version: 5.0.6
|
||||
@@ -72,6 +87,9 @@ importers:
|
||||
vite:
|
||||
specifier: ^7.3.1
|
||||
version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitest:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
vue-tsc:
|
||||
specifier: ^3.1.5
|
||||
version: 3.2.5(typescript@5.9.3)
|
||||
@@ -268,6 +286,9 @@ packages:
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0':
|
||||
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
@@ -308,66 +329,79 @@ packages:
|
||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||
@@ -437,24 +471,28 @@ packages:
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
||||
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
||||
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
||||
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
||||
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
|
||||
@@ -495,6 +533,9 @@ packages:
|
||||
'@types/body-parser@1.19.6':
|
||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
@@ -503,6 +544,12 @@ packages:
|
||||
peerDependencies:
|
||||
'@types/express': '*'
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -536,6 +583,9 @@ packages:
|
||||
'@types/serve-static@2.2.0':
|
||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@vitejs/plugin-vue@6.0.4':
|
||||
resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -543,6 +593,20 @@ packages:
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vitest/mocker@5.0.0':
|
||||
resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/spy@5.0.0':
|
||||
resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==}
|
||||
|
||||
'@volar/language-core@2.4.28':
|
||||
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
|
||||
|
||||
@@ -622,6 +686,19 @@ packages:
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
'@vueuse/core@14.2.1':
|
||||
resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/metadata@14.2.1':
|
||||
resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==}
|
||||
|
||||
'@vueuse/shared@14.2.1':
|
||||
resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -642,6 +719,10 @@ packages:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-kit@2.2.0:
|
||||
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -676,6 +757,10 @@ packages:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -733,6 +818,10 @@ packages:
|
||||
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cors@2.8.6:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
@@ -790,6 +879,9 @@ packages:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@2.3.2:
|
||||
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -809,10 +901,17 @@ packages:
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
etag@1.8.1:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
expect-type@1.4.0:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
express@5.2.1:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -979,24 +1078,28 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.31.1:
|
||||
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.31.1:
|
||||
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
||||
@@ -1039,6 +1142,11 @@ packages:
|
||||
lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
lucide-vue-next@1.0.0:
|
||||
resolution: {integrity: sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -1046,6 +1154,9 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magic-string@1.3.1:
|
||||
resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1096,10 +1207,18 @@ packages:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
obug@2.2.1:
|
||||
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1133,6 +1252,10 @@ packages:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
picomatch@4.0.7:
|
||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pinia@3.0.4:
|
||||
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
|
||||
peerDependencies:
|
||||
@@ -1242,6 +1365,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1250,10 +1376,16 @@ packages:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@4.2.0:
|
||||
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1281,10 +1413,22 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tinybench@6.1.4:
|
||||
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
tinyexec@1.3.0:
|
||||
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -1316,6 +1460,10 @@ packages:
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
undici@8.1.0:
|
||||
resolution: {integrity: sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
unpipe@1.0.0:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1372,6 +1520,47 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@5.0.0:
|
||||
resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==}
|
||||
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 5.0.0
|
||||
'@vitest/browser-preview': 5.0.0
|
||||
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
|
||||
'@vitest/coverage-istanbul': 5.0.0
|
||||
'@vitest/coverage-v8': 5.0.0
|
||||
'@vitest/ui': 5.0.0
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
@@ -1411,6 +1600,11 @@ packages:
|
||||
webpack-virtual-modules@0.6.2:
|
||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1550,6 +1744,8 @@ snapshots:
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
@@ -1709,6 +1905,11 @@ snapshots:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 24.12.0
|
||||
@@ -1717,6 +1918,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/express': 5.0.6
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
@@ -1758,12 +1965,25 @@ snapshots:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.2
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
'@vitest/mocker@5.0.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.0
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.3.1
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/spy@5.0.0': {}
|
||||
|
||||
'@volar/language-core@2.4.28':
|
||||
dependencies:
|
||||
'@volar/source-map': 2.4.28
|
||||
@@ -1886,6 +2106,19 @@ snapshots:
|
||||
typescript: 5.9.3
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
'@vueuse/core@14.2.1(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 14.2.1
|
||||
'@vueuse/shared': 14.2.1(vue@3.5.29(typescript@5.9.3))
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
'@vueuse/metadata@14.2.1': {}
|
||||
|
||||
'@vueuse/shared@14.2.1(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
@@ -1901,6 +2134,8 @@ snapshots:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-kit@2.2.0:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.0
|
||||
@@ -1943,6 +2178,8 @@ snapshots:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -1996,6 +2233,11 @@ snapshots:
|
||||
dependencies:
|
||||
is-what: 5.5.0
|
||||
|
||||
cors@2.8.6:
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
vary: 1.1.2
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
@@ -2035,6 +2277,8 @@ snapshots:
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-module-lexer@2.3.2: {}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -2074,8 +2318,14 @@ snapshots:
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
express@5.2.1:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
@@ -2115,6 +2365,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.7):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.7
|
||||
|
||||
fetch-blob@3.2.0:
|
||||
dependencies:
|
||||
node-domexception: 1.0.0
|
||||
@@ -2303,6 +2557,10 @@ snapshots:
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
lucide-vue-next@1.0.0(vue@3.5.29(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
dependencies:
|
||||
magic-string: 0.30.21
|
||||
@@ -2311,6 +2569,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magic-string@1.3.1:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
@@ -2348,8 +2610,12 @@ snapshots:
|
||||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
obug@2.2.1: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
@@ -2374,6 +2640,8 @@ snapshots:
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
picomatch@4.0.7: {}
|
||||
|
||||
pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.9
|
||||
@@ -2537,12 +2805,18 @@ snapshots:
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@4.2.0: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
@@ -2569,11 +2843,20 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
tinybench@6.1.4: {}
|
||||
|
||||
tinyexec@1.3.0: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
picomatch: 4.0.7
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
@@ -2599,6 +2882,8 @@ snapshots:
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici@8.1.0: {}
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
unplugin-utils@0.3.1:
|
||||
@@ -2630,6 +2915,27 @@ snapshots:
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
vitest@5.0.0(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
chai: 6.2.2
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
magic-string: 1.3.1
|
||||
obug: 2.2.1
|
||||
picomatch: 4.0.7
|
||||
std-env: 4.2.0
|
||||
tinybench: 6.1.4
|
||||
tinyexec: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.12.0
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-router@5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)):
|
||||
@@ -2676,6 +2982,11 @@ snapshots:
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 231 KiB |
@@ -1,114 +1,205 @@
|
||||
import fs from 'fs'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { JWT_SECRET } from './secret.js'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
import {
|
||||
findByUsername, findById, findByApiToken,
|
||||
createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen, markLogin,
|
||||
type User,
|
||||
} from './users.js'
|
||||
import { readSettings } from './settings.js'
|
||||
import { startOnboarding } from './onboarding.js'
|
||||
|
||||
const DATA_PATH = path.join(process.cwd(), 'data', 'auth.json')
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
interface AuthData {
|
||||
username: string
|
||||
passwordHash: string
|
||||
// ── Typage Express étendu ─────────────────────────────────────
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request { user?: User }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers fichier ───────────────────────────────────────────
|
||||
function readAuth(): AuthData | null {
|
||||
try {
|
||||
if (!fs.existsSync(DATA_PATH)) return null
|
||||
return JSON.parse(fs.readFileSync(DATA_PATH, 'utf-8'))
|
||||
} catch { return null }
|
||||
// ── Durée de session (JWT) ────────────────────────────────────
|
||||
const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d'
|
||||
const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never'
|
||||
const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY
|
||||
|
||||
function signToken(user: User): string {
|
||||
const payload = { id: user.id, username: user.username, role: user.role }
|
||||
return NO_EXPIRY
|
||||
? jwt.sign(payload, JWT_SECRET)
|
||||
: jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any })
|
||||
}
|
||||
|
||||
function writeAuth(data: AuthData): void {
|
||||
fs.mkdirSync(path.dirname(DATA_PATH), { recursive: true })
|
||||
fs.writeFileSync(DATA_PATH, JSON.stringify(data, null, 2))
|
||||
function setCookieAndRespond(res: Response, user: User): void {
|
||||
user.lastLoginAt = markLogin(user.id) ?? user.lastLoginAt
|
||||
const token = signToken(user)
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
res.json({ success: true, user: safeUser(user) })
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/auth/status
|
||||
export function authStatus(req: Request, res: Response): void {
|
||||
const existing = readAuth()
|
||||
const setup = hasUsers()
|
||||
const token = req.cookies?.fankarr_token
|
||||
|
||||
let loggedIn = false
|
||||
if (token) {
|
||||
try {
|
||||
jwt.verify(token, JWT_SECRET)
|
||||
loggedIn = true
|
||||
} catch {}
|
||||
}
|
||||
if (!token) { res.json({ setup, loggedIn: false }); return }
|
||||
|
||||
res.json({
|
||||
setup: !!existing,
|
||||
loggedIn
|
||||
})
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as any
|
||||
const user = findById(payload.id)
|
||||
if (!user) { res.json({ setup, loggedIn: false }); return }
|
||||
res.json({
|
||||
setup, loggedIn: true, role: user.role, username: user.username, userId: user.id,
|
||||
onboardingDone: !!readSettings().onboardingCompletedAt,
|
||||
tourSeen : !!user.tourSeenAt,
|
||||
})
|
||||
} catch {
|
||||
res.json({ setup, loggedIn: false })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/auth/setup
|
||||
// POST /api/auth/setup (premier lancement uniquement)
|
||||
export function authSetup(req: Request, res: Response): void {
|
||||
if (readAuth()) {
|
||||
res.status(400).json({ error: 'Un compte existe déjà' })
|
||||
return
|
||||
if (hasUsers()) {
|
||||
logger.warn('auth', 'Tentative de création du premier compte alors qu\'un compte existe')
|
||||
res.status(400).json({ error: 'Un compte existe déjà' }); return
|
||||
}
|
||||
|
||||
const { username, password } = req.body
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username et password requis' })
|
||||
return
|
||||
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
res.status(400).json({ error: 'Le mot de passe doit contenir au moins 6 caractères' }); return
|
||||
}
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
|
||||
writeAuth({ username, passwordHash })
|
||||
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
res.json({ success: true })
|
||||
try {
|
||||
const user = createUser(username, password, 'admin')
|
||||
startOnboarding()
|
||||
logger.info('auth', `Premier compte admin créé pour « ${username} »`)
|
||||
setCookieAndRespond(res, user)
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/auth/login
|
||||
export function authLogin(req: Request, res: Response): void {
|
||||
const auth = readAuth()
|
||||
if (!auth) {
|
||||
res.status(400).json({ error: 'Aucun compte configuré' })
|
||||
return
|
||||
if (!hasUsers()) {
|
||||
res.status(400).json({ error: 'Aucun compte configuré' }); return
|
||||
}
|
||||
|
||||
const { username, password } = req.body
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username et password requis' })
|
||||
return
|
||||
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
|
||||
}
|
||||
|
||||
if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) {
|
||||
res.status(401).json({ error: 'Identifiants incorrects' })
|
||||
return
|
||||
const user = findByUsername(username)
|
||||
if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
|
||||
logger.warn('auth', `Échec de la connexion de « ${username} »`)
|
||||
res.status(401).json({ error: 'Identifiants incorrects' }); return
|
||||
}
|
||||
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
res.json({ success: true })
|
||||
logger.info('auth', `Connexion de « ${username} » (rôle : ${user.role}, session : ${NO_EXPIRY ? 'sans expiration' : TOKEN_EXPIRY})`)
|
||||
setCookieAndRespond(res, user)
|
||||
}
|
||||
|
||||
// POST /api/auth/logout
|
||||
export function authLogout(_req: Request, res: Response): void {
|
||||
export function authLogout(req: Request, res: Response): void {
|
||||
const token = req.cookies?.fankarr_token
|
||||
if (token) {
|
||||
try {
|
||||
const p = jwt.verify(token, JWT_SECRET) as any
|
||||
logger.info('auth', `Déconnexion de « ${p.username} »`)
|
||||
} catch {}
|
||||
}
|
||||
res.clearCookie('fankarr_token')
|
||||
res.json({ success: true })
|
||||
}
|
||||
|
||||
// Middleware protection des routes
|
||||
// GET /api/auth/me
|
||||
export function authMe(req: Request, res: Response): void {
|
||||
res.json(safeUser(req.user!))
|
||||
}
|
||||
|
||||
// POST /api/auth/change-password
|
||||
export function authChangePassword(req: Request, res: Response): void {
|
||||
const { currentPassword, newPassword } = req.body
|
||||
if (!currentPassword || !newPassword) {
|
||||
res.status(400).json({ error: 'Mot de passe actuel et nouveau mot de passe requis' }); return
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
res.status(400).json({ error: 'Le nouveau mot de passe doit contenir au moins 6 caractères' }); return
|
||||
}
|
||||
try {
|
||||
changePassword(req.user!.id, currentPassword, newPassword)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/auth/regenerate-token
|
||||
export function authRegenerateToken(req: Request, res: Response): void {
|
||||
try {
|
||||
const token = regenerateApiToken(req.user!.id)
|
||||
res.json({ apiToken: token })
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/auth/tour-seen
|
||||
export function authTourSeen(req: Request, res: Response): void {
|
||||
try {
|
||||
res.json({ tourSeenAt: markTourSeen(req.user!.id) })
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contrôle d'accès ─────────────────────────────────────────
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const token = req.cookies?.fankarr_token
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Non authentifié' })
|
||||
return
|
||||
logger.debug('auth', `Accès refusé : non authentifié (${req.method} ${req.path})`)
|
||||
res.status(401).json({ error: 'Session expirée, reconnectez-vous' }); return
|
||||
}
|
||||
try {
|
||||
jwt.verify(token, JWT_SECRET)
|
||||
const payload = jwt.verify(token, JWT_SECRET) as any
|
||||
const user = findById(payload.id)
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'Utilisateur introuvable' }); return
|
||||
}
|
||||
req.user = user
|
||||
next()
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Token invalide' })
|
||||
logger.warn('auth', `Jeton de session invalide (${req.method} ${req.path})`)
|
||||
res.status(401).json({ error: 'Session expirée, reconnectez-vous' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
requireAuth(req, res, () => {
|
||||
if (req.user?.role !== 'admin') {
|
||||
logger.warn('auth', `Accès admin refusé pour « ${req.user?.username} » (${req.method} ${req.path})`)
|
||||
res.status(403).json({ error: 'Accès réservé aux administrateurs' }); return
|
||||
}
|
||||
next()
|
||||
})
|
||||
}
|
||||
|
||||
export function requireAuthOrApiToken(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const user = findByApiToken(authHeader.slice(7))
|
||||
if (user) { req.user = user; next(); return }
|
||||
}
|
||||
requireAuth(req, res, next)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
|
||||
// Charge le .env du binaire Bun avant tout module qui lit process.env
|
||||
;(() => {
|
||||
if (!_isBunBinary) return
|
||||
const binDir = path.dirname((process as any).execPath)
|
||||
const envPath = path.join(binDir, '.env')
|
||||
if (!fs.existsSync(envPath)) return
|
||||
try {
|
||||
const lines = fs.readFileSync(envPath, 'utf-8').split('\n')
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const eq = line.indexOf('=')
|
||||
if (eq === -1) continue
|
||||
const key = line.slice(0, eq).trim()
|
||||
let val = line.slice(eq + 1).trim()
|
||||
if ((val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1)
|
||||
}
|
||||
if (key && !(key in process.env)) process.env[key] = val
|
||||
}
|
||||
console.log(`[config] .env chargé depuis ${envPath}`)
|
||||
} catch (err) {
|
||||
console.warn(`[config] Impossible de charger le fichier .env : ${err}`)
|
||||
}
|
||||
})()
|
||||
|
||||
export const BASE_DIR = _isBunBinary
|
||||
? path.dirname((process as any).execPath)
|
||||
: process.cwd()
|
||||
|
||||
const isInContainer = (): boolean => {
|
||||
if (fs.existsSync('/.dockerenv')) return true;
|
||||
|
||||
// Podman
|
||||
if (fs.existsSync('/.containerenv')) return true;
|
||||
|
||||
try {
|
||||
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
|
||||
if (/docker|podman|containerd|lxc/i.test(cgroup)) return true;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const environ = fs.readFileSync('/proc/1/environ', 'utf8');
|
||||
if (environ.includes('container=')) return true;
|
||||
} catch {}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const DATA_DIR = isInContainer()
|
||||
? '/config'
|
||||
: path.join(BASE_DIR, 'config');
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Titres de l'API Fankai vers les noms de dossiers du dépôt GitLab des NFO. */
|
||||
|
||||
export const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
|
||||
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
|
||||
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
|
||||
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
|
||||
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
|
||||
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
|
||||
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
|
||||
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
|
||||
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
|
||||
'Tower Of God Henshū' : 'Tower of God Henshū',
|
||||
}
|
||||
|
||||
export function getGitlabTitle(title: string): string {
|
||||
return SERIE_TITLE_GITLAB_MAP[title] ?? title
|
||||
}
|
||||
@@ -1,560 +1,276 @@
|
||||
import express from 'express'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { authStatus, authSetup, authLogin, authLogout, requireAuth } from './auth.js'
|
||||
import { readSettings, writeSettings } from './settings.js'
|
||||
import {
|
||||
registerDriver, getDriver, getAvailableClients,
|
||||
listClients, addClient, removeClient, getClient,
|
||||
sanitizeClient, dispatchDownload, dispatchList
|
||||
} from './torrent-clients/index.js'
|
||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||
import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
|
||||
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||
import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, authTourSeen, requireAuth, requireAdmin } from './auth.js'
|
||||
import { migrateIfNeeded } from './users.js'
|
||||
import { migrateOnboarding } from './onboarding.js'
|
||||
import { readSettings } from './settings.js'
|
||||
import { registerDriver, dispatchList, dispatchRemove } from './torrent-clients/index.js'
|
||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||
import transmissionDriver from './torrent-clients/transmission.js'
|
||||
import synologyDsDriver from './torrent-clients/synology-ds.js'
|
||||
import utorrentDriver from './torrent-clients/utorrent.js'
|
||||
import rtorrentDriver from './torrent-clients/rtorrent.js'
|
||||
import realDebridDriver from './torrent-clients/real-debrid.js'
|
||||
import delugeDriver from './torrent-clients/deluge.js'
|
||||
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from './organize.js'
|
||||
import { logger } from './logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||
import { readAvailable, readInfohashMap } from './lib/github-cache.js'
|
||||
import { loadCatalog } from './lib/serie-helpers.js'
|
||||
import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js'
|
||||
import { pushNotif } from './lib/notifs.js'
|
||||
import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule, planNextAutoImport } from './lib/schedule.js'
|
||||
import { readRequests, completeRequest } from './requests.js'
|
||||
import { checkNfoUpdates } from './lib/nfo.js'
|
||||
import { runJellyfinSync } from './routes/jellyfin.js'
|
||||
import cors from 'cors';
|
||||
|
||||
import usersRouter from './routes/users.js'
|
||||
import invitesRouter from './routes/invites.js'
|
||||
import requestsRouter from './routes/requests.js'
|
||||
import jellyfinRouter from './routes/jellyfin.js'
|
||||
import publicApiRouter from './routes/public-api.js'
|
||||
import settingsRouter from './routes/settings.js'
|
||||
import torrentClientsRouter from './routes/torrent-clients.js'
|
||||
import seriesRouter from './routes/series.js'
|
||||
import downloadsRouter from './routes/downloads.js'
|
||||
import organizeRouter from './routes/organize.js'
|
||||
import importRouter from './routes/import.js'
|
||||
import systemRouter from './routes/system.js'
|
||||
import nfoUpdatesRouter from './routes/nfo-updates.js'
|
||||
import plexRouter from './routes/plex.js'
|
||||
import rssSyncRouter from './routes/rss-sync.js'
|
||||
import { runRssSync } from './lib/rss-sync.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
registerDriver(transmissionDriver)
|
||||
registerDriver(synologyDsDriver)
|
||||
registerDriver(utorrentDriver)
|
||||
registerDriver(rtorrentDriver)
|
||||
registerDriver(realDebridDriver)
|
||||
registerDriver(delugeDriver)
|
||||
|
||||
// ── Base directory ─────────────────────────────────────────────
|
||||
// Binaire Bun compilé : data/ et public/ sont à côté de l'exécutable
|
||||
// Docker / dev : relatif au cwd (comportement inchangé)
|
||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
export const BASE_DIR = _isBunBinary
|
||||
? path.dirname((process as any).execPath)
|
||||
: process.cwd()
|
||||
|
||||
const app = express()
|
||||
const PORT = Number(process.env.PORT) || 3001
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
const TORRENTS_PATH = path.join(BASE_DIR, 'data', 'torrent_final.json')
|
||||
const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL
|
||||
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main/data/torrent_final.json'
|
||||
const app = express()
|
||||
const PORT = Number(process.env.PORT) || 9898
|
||||
|
||||
app.use(express.json())
|
||||
app.use(cookieParser())
|
||||
|
||||
// ── Static frontend (prod) ─────────────────────────────────────
|
||||
|
||||
app.use(cors({
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
}));
|
||||
|
||||
const PUBLIC_PATH = path.join(BASE_DIR, 'public')
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
app.use(express.static(PUBLIC_PATH))
|
||||
}
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────
|
||||
app.get('/api/auth/status', authStatus)
|
||||
app.post('/api/auth/setup', authSetup)
|
||||
app.post('/api/auth/login', authLogin)
|
||||
app.post('/api/auth/logout', authLogout)
|
||||
// ── Migration vers le multi-utilisateur ───────────────────────
|
||||
migrateIfNeeded()
|
||||
migrateOnboarding()
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────
|
||||
app.get('/api/settings', requireAuth, (_req, res) => {
|
||||
res.json(readSettings())
|
||||
})
|
||||
app.post('/api/settings', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport } = req.body
|
||||
res.json(writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport }))
|
||||
})
|
||||
// ── Authentification ───────────────────────────────────────────
|
||||
app.get ('/api/auth/status', authStatus)
|
||||
app.post('/api/auth/setup', authSetup)
|
||||
app.post('/api/auth/login', authLogin)
|
||||
app.post('/api/auth/logout', authLogout)
|
||||
app.get ('/api/auth/me', requireAuth, authMe)
|
||||
app.post('/api/auth/change-password', requireAuth, authChangePassword)
|
||||
app.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken)
|
||||
app.post('/api/auth/tour-seen', requireAuth, authTourSeen)
|
||||
|
||||
// ── Torrent clients ────────────────────────────────────────────
|
||||
app.get('/api/torrent-clients/available', requireAuth, (_req, res) => {
|
||||
res.json(getAvailableClients())
|
||||
})
|
||||
app.get('/api/torrent-clients', requireAuth, (_req, res) => {
|
||||
res.json(listClients().map((c: any) => sanitizeClient(c)))
|
||||
})
|
||||
app.post('/api/torrent-clients', requireAuth, (req, res) => {
|
||||
const { name, type, config } = req.body
|
||||
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
res.json(sanitizeClient(addClient(name, type, config)))
|
||||
})
|
||||
app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
const ok = removeClient(String(req.params.uuid))
|
||||
if (!ok) res.status(404).json({ error: 'Client introuvable' })
|
||||
else res.json({ ok: true })
|
||||
})
|
||||
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
|
||||
const { type, config } = req.body
|
||||
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
|
||||
const driver = getDriver(type)
|
||||
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
res.json(await driver.test(config))
|
||||
})
|
||||
app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
|
||||
const client = getClient(String(req.params.uuid))
|
||||
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
|
||||
res.json(await driver.test(client.config))
|
||||
})
|
||||
app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) => {
|
||||
const client = getClient(String(req.params.uuid))
|
||||
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
|
||||
res.json(await driver.healthcheck(client.config))
|
||||
})
|
||||
// ── API publique v1 (authentification par jeton) ──────────────
|
||||
app.use('/api', publicApiRouter)
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
function readTorrents(): any[] {
|
||||
try {
|
||||
if (!fs.existsSync(TORRENTS_PATH)) return []
|
||||
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
} catch { return [] }
|
||||
}
|
||||
async function fankaiGet(endpoint: string): Promise<any> {
|
||||
const res = await fetch(`${FANKAI_API}${endpoint}`)
|
||||
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
|
||||
return res.json()
|
||||
}
|
||||
function indexBySerie(torrents: any[]): Map<number, any[]> {
|
||||
const map = new Map<number, any[]>()
|
||||
for (const t of torrents) {
|
||||
if (!t.serie_id) continue
|
||||
if (!map.has(t.serie_id)) map.set(t.serie_id, [])
|
||||
map.get(t.serie_id)!.push(t)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ── Series ─────────────────────────────────────────────────────
|
||||
|
||||
function computeSerieDownloadState(
|
||||
serieTorrents : any[],
|
||||
organized : Record<string, Record<string, string>>,
|
||||
activeTorrents: Set<string> // hashes actuellement dans qB en downloading
|
||||
): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
if (serieTorrents.length === 0) return 'none'
|
||||
|
||||
// Un torrent de la série est en cours de téléchargement ?
|
||||
const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase()))
|
||||
if (hasActive) return 'downloading'
|
||||
|
||||
// Compter les fichiers attendus vs organisés
|
||||
let totalFiles = 0
|
||||
let doneFiles = 0
|
||||
for (const t of serieTorrents) {
|
||||
const files: any[] = t.torrent_files ?? []
|
||||
if (files.length === 0) {
|
||||
// Fichier unique
|
||||
totalFiles++
|
||||
if (organized[t.infohash?.toLowerCase()]?.[t.raw]) doneFiles++
|
||||
} else {
|
||||
totalFiles += files.length
|
||||
doneFiles += files.filter((f: any) => organized[t.infohash?.toLowerCase()]?.[f.filename]).length
|
||||
}
|
||||
}
|
||||
|
||||
if (totalFiles === 0) return 'none'
|
||||
if (doneFiles === 0) return 'none'
|
||||
if (doneFiles >= totalFiles) return 'complete'
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
app.get('/api/series', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())])
|
||||
const byId = indexBySerie(torrents)
|
||||
|
||||
// Charger organized.json
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
// Récupérer les hashes actifs depuis qB (best effort)
|
||||
const activeTorrents = new Set<string>()
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const active = await dispatchList(category ?? 'fankai')
|
||||
for (const t of active) {
|
||||
if (t.state === 'downloading' && t.hash)
|
||||
activeTorrents.add(t.hash.toLowerCase())
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
|
||||
res.json({ series: seriesList.map((serie: any) => {
|
||||
const serieTorrents = byId.get(serie.id) ?? []
|
||||
return {
|
||||
...serie,
|
||||
torrent_count : serieTorrents.length,
|
||||
has_torrents : serieTorrents.length > 0,
|
||||
download_state: computeSerieDownloadState(serieTorrents, organized, activeTorrents),
|
||||
}
|
||||
})})
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
// ── Garde admin par préfixe ───────────────────────────────────
|
||||
// requireAdmin sur app.use('/api') bloquerait tous les utilisateurs
|
||||
const ADMIN_PREFIXES = [
|
||||
'/users', '/jellyfin', '/settings', '/torrent-clients',
|
||||
'/downloads', '/download', '/organize', '/import',
|
||||
'/system', '/nfo-updates', '/plex', '/rss-sync',
|
||||
'/torrent', '/manual-import', '/organized', '/organized-summary', '/organized-folders',
|
||||
'/rename-episode', '/rename-all', '/purge-nfo',
|
||||
'/logs', '/browse', '/browse-files', '/update', '/scan', '/debug',
|
||||
]
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) {
|
||||
return requireAdmin(req, res, next)
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`)])
|
||||
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
|
||||
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
|
||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
|
||||
}))
|
||||
// ── Routes (admins et utilisateurs connectés) ──────────────────
|
||||
app.use('/api', usersRouter)
|
||||
app.use('/api', jellyfinRouter)
|
||||
app.use('/api', settingsRouter)
|
||||
app.use('/api', torrentClientsRouter)
|
||||
app.use('/api', downloadsRouter)
|
||||
app.use('/api', organizeRouter)
|
||||
app.use('/api', importRouter)
|
||||
app.use('/api', systemRouter)
|
||||
app.use('/api', nfoUpdatesRouter)
|
||||
app.use('/api', plexRouter)
|
||||
app.use('/api', rssSyncRouter)
|
||||
app.use('/api', invitesRouter)
|
||||
app.use('/api', requireAuth, seriesRouter)
|
||||
app.use('/api', requireAuth, requestsRouter)
|
||||
|
||||
const torrents = readTorrents().filter(t => t.serie_id === id)
|
||||
const allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
const episodeTorrentMap: Record<number, any> = {}
|
||||
const seasonTorrentMap : Record<number, any> = {}
|
||||
const integraleTorrents: any[] = []
|
||||
const packEpisodesTorrents: any[] = []
|
||||
|
||||
for (const t of torrents) {
|
||||
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw }
|
||||
for (const ep of t.resolved_episodes ?? []) {
|
||||
availableEpisodeIds.add(ep.episode_id)
|
||||
if (t.type === 'episode') episodeTorrentMap[ep.episode_id] = ref
|
||||
}
|
||||
if (t.type === 'pack_saison') {
|
||||
const rs: any[] = t.resolved_seasons ?? []
|
||||
if (rs.length > 1) {
|
||||
packEpisodesTorrents.push(t)
|
||||
} else {
|
||||
const sids = rs.length > 0 ? rs.map((r: any) => r.season_id) : [t.season_id]
|
||||
for (const sid of sids) { if (sid) seasonTorrentMap[sid] = ref }
|
||||
}
|
||||
} else if (t.type === 'pack_integrale') {
|
||||
integraleTorrents.push(t)
|
||||
} else if (t.type === 'pack_episodes') {
|
||||
packEpisodesTorrents.push(t)
|
||||
}
|
||||
}
|
||||
|
||||
let promotedIntegrale: any = null
|
||||
if (integraleTorrents.length === 0 && allSeasonIds.size > 0) {
|
||||
for (const t of torrents.filter(t => t.type === 'pack_saison')) {
|
||||
const rs: any[] = t.resolved_seasons ?? []
|
||||
const tIds = new Set<number>(rs.length > 0 ? rs.map((r: any) => r.season_id) : t.season_id ? [t.season_id] : [])
|
||||
if ([...allSeasonIds].every(sid => tIds.has(sid))) { promotedIntegrale = t; break }
|
||||
}
|
||||
}
|
||||
|
||||
if (integraleTorrents.length > 0 || promotedIntegrale) {
|
||||
for (const key of Object.keys(seasonTorrentMap)) delete seasonTorrentMap[Number(key)]
|
||||
if (promotedIntegrale) {
|
||||
const idx = packEpisodesTorrents.indexOf(promotedIntegrale)
|
||||
if (idx !== -1) packEpisodesTorrents.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function packEpisodesLabel(t: any): string {
|
||||
const eps: any[] = t.resolved_episodes ?? []
|
||||
if (!eps.length) return t.raw
|
||||
const bySeason = new Map<number, number[]>()
|
||||
for (const ep of eps) {
|
||||
const sn = ep.season_number ?? 0
|
||||
if (!bySeason.has(sn)) bySeason.set(sn, [])
|
||||
bySeason.get(sn)!.push(ep.episode_number)
|
||||
}
|
||||
return [...bySeason.entries()].sort(([a], [b]) => a - b).map(([sn, nums]) => {
|
||||
const s = nums.sort((a, b) => a - b)
|
||||
const label = sn === 0 ? 'S0' : `S${sn}`
|
||||
return s[0] === s[s.length-1] ? `${label} Ep ${s[0]}` : `${label} Eps ${s[0]}-${s[s.length-1]}`
|
||||
}).join(' · ')
|
||||
}
|
||||
|
||||
// Charger organized.json pour les badges d'état
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
// Construire un set des episode_id organisés
|
||||
// On croise resolved_episodes de chaque torrent avec organized.json
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
for (const t of torrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
const files = t.torrent_files ?? []
|
||||
const orgFiles = organized[hash] ?? {}
|
||||
if (Object.keys(orgFiles).length === 0) continue
|
||||
// Au moins un fichier organisé pour ce torrent → on marque les épisodes résolus
|
||||
for (const ep of t.resolved_episodes ?? []) {
|
||||
// Vérifier si le fichier de cet épisode est organisé
|
||||
const epFile = files.find((f: any) =>
|
||||
f.episode_ids?.includes(ep.episode_id) || f.episode_number === ep.episode_number
|
||||
)
|
||||
if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) {
|
||||
organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
|
||||
const eps = season.episodes.map((ep: any) => ({
|
||||
...ep,
|
||||
available : availableEpisodeIds.has(ep.id),
|
||||
torrent : episodeTorrentMap[ep.id] ?? null,
|
||||
organized : organizedEpisodeIds.has(ep.id),
|
||||
}))
|
||||
// State saison : none / partial / complete
|
||||
const total = eps.filter((e: any) => e.available).length
|
||||
const orgCount = eps.filter((e: any) => e.organized).length
|
||||
const seasonOrganizedState =
|
||||
orgCount === 0 ? 'none' :
|
||||
orgCount >= total && total > 0 ? 'complete' : 'partial'
|
||||
return {
|
||||
...season,
|
||||
torrent : seasonTorrentMap[season.id] ?? null,
|
||||
organized_state : seasonOrganizedState,
|
||||
organized_count : orgCount,
|
||||
episodes : eps,
|
||||
}
|
||||
})
|
||||
|
||||
const heroBundles: any[] = [
|
||||
...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw })),
|
||||
...(promotedIntegrale ? [{ label: 'Intégrale', torrent_url: promotedIntegrale.torrent_url, magnet: promotedIntegrale.magnet, raw: promotedIntegrale.raw }] : []),
|
||||
...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw }))
|
||||
]
|
||||
|
||||
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: heroBundles })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Download ───────────────────────────────────────────────────
|
||||
app.post('/api/download', requireAuth, async (req, res) => {
|
||||
const url = req.body.torrent_url ?? req.body.magnet
|
||||
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
|
||||
const results = await dispatchDownload(url)
|
||||
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
|
||||
})
|
||||
|
||||
// ── Downloads — liste torrents Fankai ──────────────────────────
|
||||
app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const torrents = await dispatchList(category ?? 'fankai')
|
||||
|
||||
// Enrichir avec l'état d'organisation
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
let torrentFinal: any[] = []
|
||||
try {
|
||||
const tfPath = path.join(process.cwd(), 'data', 'torrent_final.json')
|
||||
if (fs.existsSync(tfPath))
|
||||
torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
const enriched = torrents.map((t: any) => {
|
||||
const orgFiles = organized[t.hash] ?? {}
|
||||
const meta = torrentFinal.find((m: any) =>
|
||||
m.infohash?.toLowerCase() === t.hash?.toLowerCase()
|
||||
)
|
||||
const totalFiles = (meta?.torrent_files?.length ?? 0) || 1
|
||||
const doneFiles = Object.keys(orgFiles).length
|
||||
let organizeState: 'none' | 'partial' | 'done' = 'none'
|
||||
if (doneFiles >= totalFiles) organizeState = 'done'
|
||||
else if (doneFiles > 0) organizeState = 'partial'
|
||||
|
||||
// Récupérer les erreurs depuis recentOrganized
|
||||
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||
const errorFiles = notif?.errorFiles ?? []
|
||||
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles }
|
||||
})
|
||||
|
||||
res.json(enriched)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Résultats d'organisation récents (pour notifications frontend) ──
|
||||
interface OrganizeNotif {
|
||||
hash : string
|
||||
name : string
|
||||
done : number
|
||||
skipped : number
|
||||
errors : number
|
||||
errorFiles : { file: string; error: string }[]
|
||||
at : string
|
||||
}
|
||||
const recentOrganized: OrganizeNotif[] = []
|
||||
const MAX_NOTIFS = 20
|
||||
|
||||
function pushNotif(n: OrganizeNotif) {
|
||||
recentOrganized.unshift(n)
|
||||
if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop()
|
||||
}
|
||||
|
||||
app.get('/api/organize/recent', requireAuth, (_req, res) => {
|
||||
res.json(recentOrganized)
|
||||
})
|
||||
|
||||
// Vider les notifs lues
|
||||
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
|
||||
recentOrganized.length = 0
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
|
||||
app.post('/api/organize', requireAuth, async (req, res) => {
|
||||
const { hash, name, save_path } = req.body
|
||||
if (!hash || !name || !save_path) {
|
||||
res.status(400).json({ error: 'hash, name et save_path requis' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await organizeTorrent(hash, name, save_path)
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Torrents status ────────────────────────────────────────────
|
||||
// ── Logs ──────────────────────────────────────────────────────
|
||||
app.get('/api/logs', requireAuth, (req, res) => {
|
||||
const limit = Number(req.query.limit) || 100
|
||||
const level = (req.query.level as string) || 'all'
|
||||
const source = (req.query.source as string) || undefined
|
||||
const entries = readLogs({ limit, level: level as any, source })
|
||||
res.json({ entries, size: logsFileSize() })
|
||||
})
|
||||
|
||||
app.post('/api/logs/clear', requireAuth, (_req, res) => {
|
||||
clearLogs()
|
||||
logger.info('api', 'Logs effacés')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── System info ────────────────────────────────────────────────
|
||||
app.get('/api/system', requireAuth, (_req, res) => {
|
||||
const isDocker = fs.existsSync('/.dockerenv')
|
||||
res.json({ isDocker })
|
||||
})
|
||||
|
||||
app.get('/api/browse', requireAuth, (req, res) => {
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
const dirs = entries
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.filter(n => !n.startsWith('.'))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
const parent = dirPath === '/' ? null : path.dirname(dirPath)
|
||||
res.json({ path: dirPath, parent, dirs })
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/torrents/status', requireAuth, (_req, res) => {
|
||||
try {
|
||||
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
|
||||
const raw = fs.readFileSync(TORRENTS_PATH, 'utf-8').trim()
|
||||
if (!raw || raw === '[]') { res.json({ exists: true, count: 0, empty: true }); return }
|
||||
const data = JSON.parse(raw)
|
||||
const count = Array.isArray(data) ? data.length : 0
|
||||
res.json({ exists: true, count, empty: count === 0 })
|
||||
} catch { res.json({ exists: false, count: 0, empty: true }) }
|
||||
})
|
||||
|
||||
// ── Update torrent_final.json ──────────────────────────────────
|
||||
app.post('/api/update', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const response = await fetch(GITHUB_RAW_URL)
|
||||
if (!response.ok) throw new Error(`GitHub a répondu ${response.status}`)
|
||||
const data = await response.json() as unknown[]
|
||||
if (!Array.isArray(data)) throw new Error('Format invalide')
|
||||
fs.mkdirSync(path.dirname(TORRENTS_PATH), { recursive: true })
|
||||
fs.writeFileSync(TORRENTS_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
res.json({ ok: true, count: data.length })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/scan', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { mediaPath } = readSettings()
|
||||
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
|
||||
const result = await scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH)
|
||||
logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`)
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── SPA fallback (prod) — doit être après toutes les routes API ─
|
||||
// ── Application web (toutes les autres routes) ─────────────────
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
app.get('*path', (_req, res) => {
|
||||
res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
|
||||
})
|
||||
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) })
|
||||
}
|
||||
|
||||
// ── Start ──────────────────────────────────────────────────────
|
||||
app.listen(PORT, async () => {
|
||||
// Télécharger torrent_final.json si absent
|
||||
if (!fs.existsSync(TORRENTS_PATH)) {
|
||||
try {
|
||||
console.log('[fankarr] torrent_final.json absent → téléchargement...')
|
||||
const response = await fetch(GITHUB_RAW_URL)
|
||||
if (!response.ok) throw new Error(`GitHub a répondu ${response.status}`)
|
||||
const data = await response.json() as unknown[]
|
||||
if (!Array.isArray(data)) throw new Error('Format invalide')
|
||||
fs.mkdirSync(path.dirname(TORRENTS_PATH), { recursive: true })
|
||||
fs.writeFileSync(TORRENTS_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
console.log(`[fankarr] ${data.length} torrents téléchargés`)
|
||||
} catch (err) {
|
||||
console.warn(`[fankarr] ⚠ Téléchargement échoué: ${err instanceof Error ? err.message : err}`)
|
||||
// ── Démarrage ──────────────────────────────────────────────────
|
||||
const server = http.createServer({ maxHeaderSize: 32768 }, app)
|
||||
|
||||
server.listen(PORT, async () => {
|
||||
const url = `http://localhost:${PORT}`
|
||||
console.log(`\n FanKarr \x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\\n`)
|
||||
logger.info('api', `Serveur démarré sur ${url}`)
|
||||
try {
|
||||
const available = await readAvailable()
|
||||
logger.info('api', `Catalogue chargé : ${available.length} séries`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Impossible de charger le catalogue (available.json) au démarrage : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
const { mediaPath } = readSettings()
|
||||
|
||||
// Ancien format d'organized.json (valeurs texte) : réinitialisation, l'analyse le reconstruit
|
||||
try {
|
||||
const raw = readOrganized()
|
||||
const isOldFormat = Object.values(raw).some((entries: any) =>
|
||||
Object.values(entries).some(v => typeof v === 'string')
|
||||
)
|
||||
if (isOldFormat) {
|
||||
logger.info('api', 'Migration organized.json : ancien format détecté, fichier réinitialisé')
|
||||
writeOrganized({})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('api', `Migration organized.json annulée : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
const count = Array.isArray(data) ? data.length : 0
|
||||
if (count === 0) console.warn('[fankarr] ⚠ torrent_final.json est vide')
|
||||
else console.log(`[fankarr] ${count} torrents chargés`)
|
||||
} catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') }
|
||||
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
|
||||
const raw = readOrganized()
|
||||
let patched = 0
|
||||
for (const episodes of Object.values(raw)) {
|
||||
for (const entry of Object.values(episodes)) {
|
||||
if (!entry.dest_dir && entry.dest_path) {
|
||||
entry.dest_dir = path.dirname(entry.dest_path)
|
||||
patched++
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patched > 0) {
|
||||
writeOrganized(raw)
|
||||
logger.info('api', `Migration organized.json : ${patched} entrée(s) complétée(s) (dest_dir)`)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('api', `Échec de la migration dest_dir : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
// Scan initial du mediaPath pour reconnaître les fichiers déjà présents
|
||||
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
|
||||
const { mediaPath } = readSettings()
|
||||
scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err =>
|
||||
logger.error('organize', `scanMediaPath échoué: ${err}`)
|
||||
)
|
||||
loadCatalog()
|
||||
.then(async seriesData => {
|
||||
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
|
||||
dedupeOrganizedEpisodes(ORGANIZED_PATH)
|
||||
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
|
||||
})
|
||||
.catch(err => logger.error('api', `Échec de l'analyse initiale de la médiathèque : ${err instanceof Error ? err.message : err}`))
|
||||
|
||||
// Auto-organise toutes les 30s
|
||||
const autoOrganize = async () => {
|
||||
autoImportSchedule.lastRunAt = new Date().toISOString()
|
||||
planNextAutoImport()
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const infohashMap = await readInfohashMap()
|
||||
const seriesData = await loadCatalog()
|
||||
await autoOrganizeAll(
|
||||
() => dispatchList(category ?? 'fankai'),
|
||||
(result) => {
|
||||
() => dispatchList(category ?? 'fankai', infohashMap),
|
||||
seriesData,
|
||||
async (result) => {
|
||||
if (result.done > 0 || result.errors > 0) {
|
||||
pushNotif({ ...result, at: new Date().toISOString() })
|
||||
}
|
||||
// Demandes actives passées en « disponible » dès qu'un fichier est importé
|
||||
if (result.done > 0 && result.serieId != null) {
|
||||
try {
|
||||
const toComplete = readRequests().filter(r =>
|
||||
r.serieId === result.serieId &&
|
||||
(r.status === 'pending' || r.status === 'approved')
|
||||
)
|
||||
for (const r of toComplete) {
|
||||
completeRequest(r.id)
|
||||
logger.info('requests', `Demande « ${r.serieName} » passée en disponible (import automatique)`)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('api', `Impossible de passer en « disponible » les demandes de la série ${result.serieId} : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
const { organizeMode, deleteTorrentOnMove } = readSettings()
|
||||
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
|
||||
const removeResult = await dispatchRemove(result.hash, false)
|
||||
if (removeResult.ok) {
|
||||
logger.info('api', `Torrent « ${result.name} » retiré du client après déplacement`)
|
||||
} else {
|
||||
logger.warn('api', `Échec du retrait de « ${result.name} » après déplacement : ${removeResult.error ?? 'aucun client n\'a pu le supprimer'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[organize] Erreur auto-organise:', err)
|
||||
logger.error('api', `Échec de l'import automatique : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
// Premier run 30s après démarrage, puis toutes les 30s
|
||||
|
||||
planNextAutoImport(10_000)
|
||||
setTimeout(() => {
|
||||
autoOrganize()
|
||||
setInterval(autoOrganize, 30_000)
|
||||
setInterval(autoOrganize, AUTO_IMPORT_INTERVAL_MS)
|
||||
}, 10_000)
|
||||
|
||||
setTimeout(() => {
|
||||
checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification des mises à jour NFO : ${err instanceof Error ? err.message : err}`))
|
||||
setInterval(() => {
|
||||
checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification des mises à jour NFO : ${err instanceof Error ? err.message : err}`))
|
||||
}, 60 * 60_000)
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const seriesData = await loadCatalog()
|
||||
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
||||
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
||||
if (updated > 0)
|
||||
logger.info('api', `Migration automatique des IDs : ${updated} ID(s) mis à jour`)
|
||||
const { removed } = dedupeOrganizedEpisodes(organizedPath)
|
||||
if (removed > 0)
|
||||
logger.info('api', `Dédoublonnage automatique : ${removed} entrée(s) redondante(s) supprimée(s)`)
|
||||
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
|
||||
if (renamed > 0)
|
||||
logger.info('api', `Renommage automatique : ${renamed} fichier(s) renommé(s)`)
|
||||
} catch (err) {
|
||||
logger.error('api', `Échec de la mise à jour horaire des imports : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}, 60 * 60_000)
|
||||
|
||||
setTimeout(() => {
|
||||
runRssSync().catch(err => logger.error('rss-sync', `Échec de la surveillance des nouveaux épisodes : ${err instanceof Error ? err.message : err}`))
|
||||
setInterval(() => {
|
||||
runRssSync().catch(err => logger.error('rss-sync', `Échec de la surveillance des nouveaux épisodes : ${err instanceof Error ? err.message : err}`))
|
||||
}, 6 * 60 * 60_000)
|
||||
}, 60_000)
|
||||
|
||||
// Sans effet si Jellyfin n'est pas configuré
|
||||
setTimeout(() => {
|
||||
runJellyfinSync().catch(err => logger.error('jellyfin', `Échec de la synchronisation Jellyfin : ${err instanceof Error ? err.message : err}`))
|
||||
setInterval(() => {
|
||||
runJellyfinSync().catch(err => logger.error('jellyfin', `Échec de la synchronisation Jellyfin : ${err instanceof Error ? err.message : err}`))
|
||||
}, 60 * 60_000)
|
||||
}, 2 * 60_000)
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
|
||||
export interface Invite {
|
||||
code : string
|
||||
createdBy: string // userId de l'admin
|
||||
createdAt: string
|
||||
expiresAt: string | null // null = pas d'expiration
|
||||
maxUses : number | null // null = utilisations illimitées
|
||||
uses : number
|
||||
note? : string
|
||||
}
|
||||
|
||||
const INVITES_PATH = path.join(DATA_DIR, 'invites.json')
|
||||
|
||||
// ── Fichier des invitations ───────────────────────────────────
|
||||
|
||||
export function readInvites(): Invite[] {
|
||||
try {
|
||||
if (!fs.existsSync(INVITES_PATH)) return []
|
||||
return JSON.parse(fs.readFileSync(INVITES_PATH, 'utf-8')) as Invite[]
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function writeInvites(invites: Invite[]): void {
|
||||
fs.mkdirSync(path.dirname(INVITES_PATH), { recursive: true })
|
||||
fs.writeFileSync(INVITES_PATH, JSON.stringify(invites, null, 2))
|
||||
}
|
||||
|
||||
// ── Gestion des invitations ───────────────────────────────────
|
||||
|
||||
export function createInvite(opts: {
|
||||
createdBy: string
|
||||
expiresInHours?: number | null
|
||||
maxUses?: number | null
|
||||
note?: string
|
||||
}): Invite {
|
||||
const invite: Invite = {
|
||||
code : crypto.randomBytes(16).toString('hex'),
|
||||
createdBy: opts.createdBy,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: opts.expiresInHours
|
||||
? new Date(Date.now() + opts.expiresInHours * 3_600_000).toISOString()
|
||||
: null,
|
||||
maxUses : opts.maxUses === undefined ? 1 : opts.maxUses,
|
||||
uses : 0,
|
||||
note : opts.note,
|
||||
}
|
||||
writeInvites([...readInvites(), invite])
|
||||
logger.info('invites', `Invitation créée : ${invite.code.slice(0, 8)}… (utilisations max : ${invite.maxUses ?? '∞'}, expiration : ${invite.expiresAt ?? 'jamais'})`)
|
||||
return invite
|
||||
}
|
||||
|
||||
export function deleteInvite(code: string): void {
|
||||
const invites = readInvites()
|
||||
if (!invites.find(i => i.code === code)) throw new Error('Invitation introuvable')
|
||||
writeInvites(invites.filter(i => i.code !== code))
|
||||
}
|
||||
|
||||
export function findInvite(code: string): Invite | undefined {
|
||||
return readInvites().find(i => i.code === code)
|
||||
}
|
||||
|
||||
export function validateInvite(code: string): { valid: true; invite: Invite } | { valid: false; reason: string } {
|
||||
const invite = findInvite(code)
|
||||
if (!invite) return { valid: false, reason: 'Ce lien d\'invitation n\'est pas valide' }
|
||||
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
|
||||
return { valid: false, reason: 'Ce lien d\'invitation a expiré' }
|
||||
}
|
||||
if (invite.maxUses !== null && invite.uses >= invite.maxUses) {
|
||||
return { valid: false, reason: 'Ce lien d\'invitation a atteint son nombre maximum d\'utilisations' }
|
||||
}
|
||||
return { valid: true, invite }
|
||||
}
|
||||
|
||||
export function consumeInvite(code: string): void {
|
||||
const invites = readInvites()
|
||||
const invite = invites.find(i => i.code === code)
|
||||
if (!invite) return
|
||||
invite.uses++
|
||||
writeInvites(invites)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
const GITHUB_BASE =
|
||||
process.env.GITHUB_BASE?.trim() ||
|
||||
'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
||||
|
||||
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
|
||||
|
||||
interface CacheEntry<T> { data: T; expiresAt: number; fetchedAt: number }
|
||||
const _cache = new Map<string, CacheEntry<any>>()
|
||||
|
||||
export function cacheGet<T>(key: string): T | null {
|
||||
const entry = _cache.get(key)
|
||||
if (!entry) return null
|
||||
if (Date.now() > entry.expiresAt) { _cache.delete(key); return null }
|
||||
return entry.data as T
|
||||
}
|
||||
|
||||
export function cacheSet<T>(key: string, data: T): void {
|
||||
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS, fetchedAt: Date.now() })
|
||||
}
|
||||
|
||||
export function cacheFetchedAt(key: string): string | null {
|
||||
const entry = _cache.get(key)
|
||||
return entry && Date.now() <= entry.expiresAt ? new Date(entry.fetchedAt).toISOString() : null
|
||||
}
|
||||
|
||||
export function cacheClear(): void { _cache.clear() }
|
||||
|
||||
export function cacheSize(): number { return _cache.size }
|
||||
|
||||
/** force : ignore le cache mémoire et celui du CDN de raw.githubusercontent (environ 5 min). */
|
||||
export async function githubGet(urlPath: string, force = false): Promise<any> {
|
||||
if (!force) {
|
||||
const cached = cacheGet(urlPath)
|
||||
if (cached) return cached
|
||||
}
|
||||
const url = force ? `${GITHUB_BASE}/${urlPath}?_=${Date.now()}` : `${GITHUB_BASE}/${urlPath}`
|
||||
const init = force ? { cache: 'no-store' as const, headers: { 'Cache-Control': 'no-cache' } } : undefined
|
||||
const res = await fetch(url, init)
|
||||
if (!res.ok) throw new Error(`GitHub : HTTP ${res.status} sur ${urlPath}`)
|
||||
const data = await res.json()
|
||||
cacheSet(urlPath, data)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function readAvailable(force = false): Promise<number[]> {
|
||||
try { return await githubGet('available.json', force) as number[] }
|
||||
catch { return [] }
|
||||
}
|
||||
|
||||
export async function readInfohashMap(force = false): Promise<Record<string, string>> {
|
||||
try { return await githubGet('infohash_map.json', force) as Record<string, string> }
|
||||
catch { return {} }
|
||||
}
|
||||
|
||||
export async function readSerieData(serieId: number, force = false): Promise<any | null> {
|
||||
try { return await githubGet(`series/${serieId}.json`, force) }
|
||||
catch { return null }
|
||||
}
|
||||
|
||||
export async function loadEnrichedSeriesData(force = false): Promise<any[]> {
|
||||
const ids = await readAvailable(force)
|
||||
const allSd = await Promise.all(ids.map(id => readSerieData(id, force)))
|
||||
return allSd.filter(Boolean)
|
||||
}
|
||||
|
||||
export function loadOrganized(): Record<string, Record<string, any>> {
|
||||
try {
|
||||
const p = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
|
||||
import { readSettings } from '../settings.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const CLIENT_HEADER = 'MediaBrowser Client="FanKarr", Device="Server", DeviceId="fankarr-server", Version="1.0"'
|
||||
|
||||
function adminHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
'X-Emby-Token': token,
|
||||
'Authorization': `${CLIENT_HEADER}, Token="${token}"`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
function userHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
'Authorization': `${CLIENT_HEADER}, Token="${token}"`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
// ── Types Jellyfin ────────────────────────────────────────────
|
||||
|
||||
export interface JellyfinUser {
|
||||
Id : string
|
||||
Name : string
|
||||
HasPassword : boolean
|
||||
Policy : { IsAdministrator: boolean; IsDisabled: boolean }
|
||||
}
|
||||
|
||||
// ── Test de connexion ─────────────────────────────────────────
|
||||
|
||||
export async function testJellyfinConnection(url: string, token: string): Promise<{ ok: boolean; version?: string; error?: string }> {
|
||||
try {
|
||||
const res = await fetch(`${url.replace(/\/$/, '')}/System/Info`, {
|
||||
headers: adminHeaders(token),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `Jellyfin a répondu HTTP ${res.status} : vérifiez l'URL et la clé API` }
|
||||
const data = await res.json()
|
||||
return { ok: true, version: data.Version }
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'Erreur réseau' }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Récupération des utilisateurs Jellyfin ────────────────────
|
||||
|
||||
export async function fetchJellyfinUsers(): Promise<JellyfinUser[]> {
|
||||
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
|
||||
if (!jellyfinUrl || !jellyfinAdminToken) throw new Error('Jellyfin non configuré (URL ou clé API manquante)')
|
||||
|
||||
const base = jellyfinUrl.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/Users`, { headers: adminHeaders(jellyfinAdminToken) })
|
||||
if (!res.ok) throw new Error(`Impossible de récupérer les utilisateurs Jellyfin (HTTP ${res.status})`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ── Validation d'un jeton utilisateur Jellyfin ────────────────
|
||||
|
||||
export interface JellyfinTokenInfo {
|
||||
valid : boolean
|
||||
userId? : string
|
||||
username?: string
|
||||
error? : string
|
||||
}
|
||||
|
||||
export async function validateJellyfinToken(jellyfinUserId: string, jellyfinToken: string): Promise<JellyfinTokenInfo> {
|
||||
const { jellyfinUrl } = readSettings()
|
||||
if (!jellyfinUrl) return { valid: false, error: 'Jellyfin non configuré sur ce serveur FanKarr' }
|
||||
|
||||
try {
|
||||
const base = jellyfinUrl.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/Users/${jellyfinUserId}`, {
|
||||
headers: userHeaders(jellyfinToken),
|
||||
})
|
||||
if (res.status === 401 || res.status === 403) return { valid: false, error: 'Jeton Jellyfin invalide ou expiré' }
|
||||
if (!res.ok) return { valid: false, error: `Jellyfin a répondu HTTP ${res.status}` }
|
||||
|
||||
const user: JellyfinUser = await res.json()
|
||||
if (user.Policy?.IsDisabled) return { valid: false, error: 'Compte Jellyfin désactivé' }
|
||||
|
||||
logger.debug('jellyfin', `Jeton valide pour « ${user.Name} » (${user.Id.slice(0, 8)}…)`)
|
||||
return { valid: true, userId: user.Id, username: user.Name }
|
||||
} catch (err) {
|
||||
return { valid: false, error: err instanceof Error ? err.message : 'Erreur réseau' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { getGitlabTitle } from '../gitlab-map.js'
|
||||
import { loadEnrichedSeriesData, loadOrganized } from './github-cache.js'
|
||||
|
||||
export const GITLAB_API_NFO = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
|
||||
export const GITLAB_RAW_NFO = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
|
||||
|
||||
export interface NfoUpdateNotif {
|
||||
serieTitle: string; commitSha: string; commitMsg: string
|
||||
updatedAt: string; status: 'updated' | 'error'; error?: string
|
||||
}
|
||||
|
||||
export const recentNfoUpdates: NfoUpdateNotif[] = []
|
||||
const MAX_NFO_NOTIFS = 30
|
||||
const NFO_COMMITS_PATH = path.join(DATA_DIR, 'nfo_commits.json')
|
||||
|
||||
export function loadNfoCommits(): Record<string, string> {
|
||||
try { if (!fs.existsSync(NFO_COMMITS_PATH)) return {}; return JSON.parse(fs.readFileSync(NFO_COMMITS_PATH, 'utf-8')) }
|
||||
catch { return {} }
|
||||
}
|
||||
|
||||
export function saveNfoCommits(commits: Record<string, string>) {
|
||||
fs.mkdirSync(path.dirname(NFO_COMMITS_PATH), { recursive: true })
|
||||
fs.writeFileSync(NFO_COMMITS_PATH, JSON.stringify(commits, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export async function getLatestGitlabCommit(gitlabTitle: string): Promise<{ sha: string; message: string } | null> {
|
||||
try {
|
||||
const res = await fetch(`${GITLAB_API_NFO}/commits?path=${encodeURIComponent('pack/' + gitlabTitle)}&per_page=1&ref_name=main`, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!res.ok) return null
|
||||
const data: any[] = await res.json()
|
||||
if (!data.length) return null
|
||||
return { sha: data[0].id, message: data[0].title ?? data[0].message ?? '' }
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export async function downloadNfoFolder(gitlabTitle: string, destRoot: string): Promise<void> {
|
||||
let files: any[] = [], page = 1
|
||||
while (true) {
|
||||
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
|
||||
if (!Array.isArray(batch) || batch.length === 0) break
|
||||
files.push(...batch); if (batch.length < 100) break; page++
|
||||
}
|
||||
let downloaded = 0, skipped = 0
|
||||
for (const entry of files.filter((f: any) => f.type === 'blob')) {
|
||||
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
||||
const destPath = path.join(destRoot, relativePath)
|
||||
try {
|
||||
const res = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!res.ok) { skipped++; continue }
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
fs.writeFileSync(destPath, Buffer.from(await res.arrayBuffer()))
|
||||
downloaded++
|
||||
} catch { skipped++ }
|
||||
}
|
||||
logger.info('nfo-update', `NFO de « ${gitlabTitle} » : ${downloaded} fichier(s) mis à jour, ${skipped} échec(s)`)
|
||||
}
|
||||
|
||||
export async function checkNfoUpdates() {
|
||||
const { mediaPath, nfoSupport } = readSettings()
|
||||
if (!nfoSupport) return
|
||||
const organized = loadOrganized()
|
||||
if (Object.keys(organized).length === 0) return
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const knownHashes = new Set(Object.keys(organized))
|
||||
const serieTitles = new Set<string>()
|
||||
for (const sd of seriesData) {
|
||||
const allTorrents = [...(sd.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => s.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => (s.episodes ?? []).flatMap((e: any) => e.torrents ?? []))]
|
||||
if (allTorrents.some((t: any) => knownHashes.has(t.infohash?.toLowerCase()))) {
|
||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||
if (rawTitle) serieTitles.add(rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim())
|
||||
}
|
||||
}
|
||||
if (serieTitles.size === 0) return
|
||||
const commits = loadNfoCommits()
|
||||
let hasChanges = false
|
||||
for (const serieTitle of serieTitles) {
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
const latest = await getLatestGitlabCommit(gitlabTitle)
|
||||
if (!latest || commits[gitlabTitle] === latest.sha) continue
|
||||
logger.info('nfo-update', `Mise à jour des NFO disponible pour « ${gitlabTitle} » (${latest.sha.slice(0, 8)})`)
|
||||
try {
|
||||
await downloadNfoFolder(gitlabTitle, path.join(mediaPath, serieTitle))
|
||||
commits[gitlabTitle] = latest.sha
|
||||
hasChanges = true
|
||||
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'updated' })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
logger.error('nfo-update', `Échec de la mise à jour des NFO de « ${gitlabTitle} » : ${msg}`)
|
||||
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'error', error: msg })
|
||||
}
|
||||
if (recentNfoUpdates.length > MAX_NFO_NOTIFS) recentNfoUpdates.pop()
|
||||
}
|
||||
if (hasChanges) saveNfoCommits(commits)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
|
||||
export interface OrganizeNotif {
|
||||
hash: string; name: string; serieId?: number | null; done: number; skipped: number; errors: number
|
||||
errorFiles: { file: string; error: string }[]; at: string
|
||||
}
|
||||
|
||||
export interface RecentSerieImport { serieId: number; episodes: number; at: string }
|
||||
|
||||
const HISTORY_PATH = path.join(DATA_DIR, 'import-history.json')
|
||||
const MAX_HISTORY = 200
|
||||
const RECENT_WINDOW_MS = 7 * 24 * 60 * 60_000
|
||||
|
||||
function loadHistory(): OrganizeNotif[] {
|
||||
try {
|
||||
if (!fs.existsSync(HISTORY_PATH)) return []
|
||||
const data = JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf-8'))
|
||||
return Array.isArray(data) ? data.slice(0, MAX_HISTORY) : []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function saveHistory(): void {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(HISTORY_PATH), { recursive: true })
|
||||
fs.writeFileSync(HISTORY_PATH, JSON.stringify(recentOrganized, null, 2))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Du plus récent au plus ancien
|
||||
export const recentOrganized: OrganizeNotif[] = loadHistory()
|
||||
|
||||
export function pushNotif(n: OrganizeNotif) {
|
||||
recentOrganized.unshift(n)
|
||||
if (recentOrganized.length > MAX_HISTORY) recentOrganized.length = MAX_HISTORY
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
export function clearNotifs() {
|
||||
recentOrganized.length = 0
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
export function recentImportsBySerie(): RecentSerieImport[] {
|
||||
const bySerie = new Map<number, RecentSerieImport>()
|
||||
for (const n of recentOrganized) {
|
||||
if (n.serieId == null || n.done <= 0) continue
|
||||
const current = bySerie.get(n.serieId)
|
||||
if (!current) { bySerie.set(n.serieId, { serieId: n.serieId, episodes: n.done, at: n.at }); continue }
|
||||
if (Date.parse(current.at) - Date.parse(n.at) <= RECENT_WINDOW_MS) current.episodes += n.done
|
||||
}
|
||||
return [...bySerie.values()]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { threadId } from 'worker_threads'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
|
||||
export type Organized = Record<string, Record<string, any>>
|
||||
|
||||
export const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
|
||||
// Un fichier existant mais illisible lève une erreur : ne jamais repartir de {} avant d'écrire
|
||||
export function readOrganized(file = ORGANIZED_PATH): Organized {
|
||||
if (!fs.existsSync(file)) return {}
|
||||
const raw = fs.readFileSync(file, 'utf-8')
|
||||
let data: unknown
|
||||
try { data = JSON.parse(raw) }
|
||||
catch (err) { throw new Error(`Fichier de suivi des imports (organized.json) corrompu : ${err instanceof Error ? err.message : err}`) }
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Fichier de suivi des imports (organized.json) corrompu : contenu inattendu')
|
||||
return data as Organized
|
||||
}
|
||||
|
||||
export function writeOrganized(data: Organized, file = ORGANIZED_PATH): void {
|
||||
for (const hash of Object.keys(data))
|
||||
if (!data[hash] || Object.keys(data[hash]).length === 0) delete data[hash]
|
||||
const tmp = `${file}.${process.pid}-${threadId}.tmp`
|
||||
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8')
|
||||
renameWithRetry(tmp, file)
|
||||
}
|
||||
|
||||
// Répercute des déplacements de fichiers sur toutes les entrées qui les suivent, tous hashes confondus
|
||||
export function retargetEntries(data: Organized, moves: Map<string, string>): number {
|
||||
let updated = 0
|
||||
for (const eps of Object.values(data)) {
|
||||
for (const entry of Object.values(eps)) {
|
||||
const to = entry?.dest_path ? moves.get(entry.dest_path) : undefined
|
||||
if (!to) continue
|
||||
entry.dest_path = to
|
||||
entry.dest_filename = path.basename(to)
|
||||
entry.dest_dir = path.dirname(to)
|
||||
updated++
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
// Lecture-modification-écriture synchrone ; fn renvoie false pour ne rien écrire
|
||||
export function updateOrganized(fn: (data: Organized) => boolean | void, file = ORGANIZED_PATH): void {
|
||||
const data = readOrganized(file)
|
||||
if (fn(data) !== false) writeOrganized(data, file)
|
||||
}
|
||||
|
||||
function renameWithRetry(from: string, to: string, attempts = 5): void {
|
||||
for (let i = 1; ; i++) {
|
||||
try { fs.renameSync(from, to); return }
|
||||
catch (err: any) {
|
||||
if (i >= attempts || !['EPERM', 'EACCES', 'EBUSY'].includes(err?.code)) throw err
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export type RequestTorrent = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null }
|
||||
|
||||
/** Torrents à envoyer : épisodes ciblés, sinon saisons ciblées, sinon toute la série. */
|
||||
export function resolveRequestTorrents(serieData: any, seasons: number[], episodes: number[]): RequestTorrent[] {
|
||||
const toDownload: RequestTorrent[] = []
|
||||
const seenHashes = new Set<string>()
|
||||
|
||||
const allPackTorrents: any[] = [
|
||||
...(serieData.torrents ?? []),
|
||||
...(serieData.seasons ?? []).flatMap((s: any) => s.torrents ?? []),
|
||||
]
|
||||
|
||||
function addTorrent(t: any, file_index?: number | null, file_path?: string | null) {
|
||||
const key = `${t.infohash ?? t.magnet ?? t.torrent_url}:${file_index ?? ''}`
|
||||
if (seenHashes.has(key)) return
|
||||
seenHashes.add(key)
|
||||
toDownload.push({ url: t.torrent_url, magnet: t.magnet ?? null, infohash: t.infohash ?? null, file_index: file_index ?? null, file_path: file_path ?? null })
|
||||
}
|
||||
|
||||
function addEpisodesByIndex(epList: any[]) {
|
||||
for (const ep of epList) {
|
||||
if (ep.torrents?.length > 0) {
|
||||
addTorrent(ep.torrents[0])
|
||||
} else if (ep.paths?.length > 0) {
|
||||
const p = ep.paths[0]
|
||||
const pack = allPackTorrents.find((t: any) => t.infohash?.toLowerCase() === p.infohash?.toLowerCase())
|
||||
if (pack) addTorrent(pack, p.file_index ?? null, p.path ?? null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (episodes.length > 0) {
|
||||
// ── Épisodes ciblés ───────────────────────────────────────
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
const targets = (season.episodes ?? []).filter((ep: any) => episodes.includes(ep.id))
|
||||
addEpisodesByIndex(targets)
|
||||
}
|
||||
} else if (seasons.length > 0) {
|
||||
// ── Saisons ciblées ───────────────────────────────────────
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
if (!seasons.includes(season.season_number)) continue
|
||||
if ((season.torrents ?? []).length > 0) {
|
||||
addTorrent(season.torrents[0])
|
||||
} else {
|
||||
addEpisodesByIndex(season.episodes ?? [])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Toutes les saisons : intégrale en priorité ────────────
|
||||
if ((serieData.torrents ?? []).length > 0) {
|
||||
addTorrent(serieData.torrents[0])
|
||||
} else if ((serieData.seasons ?? []).some((s: any) => s.torrents?.length > 0)) {
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
if (season.torrents?.length > 0) addTorrent(season.torrents[0])
|
||||
}
|
||||
} else {
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
addEpisodesByIndex(season.episodes ?? [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toDownload
|
||||
}
|
||||
|
||||
export function countRequestedEpisodes(serieData: any, seasons: number[], episodes: number[]): number {
|
||||
const keys = new Set<string>()
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const inScope = episodes.length > 0
|
||||
? episodes.includes(ep.id)
|
||||
: seasons.length === 0 || seasons.includes(season.season_number)
|
||||
if (inScope) keys.add(`${season.season_number}:${ep.episode_number}`)
|
||||
}
|
||||
}
|
||||
return keys.size
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/** Surveillance : envoie au client les épisodes ajoutés au catalogue après activation (toutes les 6 h). */
|
||||
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { readInfohashMap } from './github-cache.js'
|
||||
import { loadCatalogStatus } from './serie-helpers.js'
|
||||
import { dispatchDownload, dispatchList } from '../torrent-clients/index.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface SyncedSerie {
|
||||
serieId : number
|
||||
serieName: string
|
||||
addedAt : string // date ISO d'activation de la surveillance
|
||||
}
|
||||
|
||||
type SyncedMap = Record<number, SyncedSerie>
|
||||
|
||||
// ── Persistance ───────────────────────────────────────────────
|
||||
|
||||
const SYNC_FILE = path.join(DATA_DIR, 'rss-synced.json')
|
||||
|
||||
export function loadSynced(): SyncedMap {
|
||||
try {
|
||||
if (fs.existsSync(SYNC_FILE))
|
||||
return JSON.parse(fs.readFileSync(SYNC_FILE, 'utf-8')) as SyncedMap
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
|
||||
function saveSynced(map: SyncedMap): void {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
fs.writeFileSync(SYNC_FILE, JSON.stringify(map, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export function setSync(serieId: number, serieName: string, enabled: boolean): SyncedMap {
|
||||
const map = loadSynced()
|
||||
if (enabled) {
|
||||
map[serieId] = { serieId, serieName, addedAt: new Date().toISOString() }
|
||||
} else {
|
||||
delete map[serieId]
|
||||
}
|
||||
saveSynced(map)
|
||||
return map
|
||||
}
|
||||
|
||||
// Une série déjà surveillée garde sa date d'activation
|
||||
export function setSyncBulk(series: { id: number; name: string }[], enabled: boolean): { changed: number; map: SyncedMap } {
|
||||
const map = loadSynced()
|
||||
let changed = 0
|
||||
for (const { id, name } of series) {
|
||||
if (enabled && !map[id]) { map[id] = { serieId: id, serieName: name, addedAt: new Date().toISOString() }; changed++ }
|
||||
if (!enabled && map[id]) { delete map[id]; changed++ }
|
||||
}
|
||||
if (changed > 0) saveSynced(map)
|
||||
return { changed, map }
|
||||
}
|
||||
|
||||
export function isSynced(serieId: number): boolean {
|
||||
return !!loadSynced()[serieId]
|
||||
}
|
||||
|
||||
const normalizeName = (name: string) =>
|
||||
name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
|
||||
|
||||
// Série recréée sous un nouvel ID et même nom : la surveillance suit ; renvoie celles sans série
|
||||
export function reconcileSynced(catalog: any[]): SyncedSerie[] {
|
||||
const map = loadSynced()
|
||||
const ids = new Set(catalog.map(sd => sd.id))
|
||||
const orphans: SyncedSerie[] = []
|
||||
let changed = false
|
||||
for (const entry of Object.values(map)) {
|
||||
if (ids.has(entry.serieId)) continue
|
||||
const matches = catalog.filter(sd => normalizeName(sd.title ?? '') === normalizeName(entry.serieName))
|
||||
if (matches.length === 1 && !map[matches[0].id]) {
|
||||
delete map[entry.serieId]
|
||||
map[matches[0].id] = { ...entry, serieId: matches[0].id, serieName: matches[0].title }
|
||||
logger.info('rss-sync', `Surveillance de « ${entry.serieName} » déplacée de la série ${entry.serieId} vers ${matches[0].id}`)
|
||||
changed = true
|
||||
} else {
|
||||
orphans.push(entry)
|
||||
}
|
||||
}
|
||||
if (changed) saveSynced(map)
|
||||
return orphans
|
||||
}
|
||||
|
||||
// ── Épisodes déjà importés ────────────────────────────────────
|
||||
|
||||
function readOrganized(): Record<string, Record<string, any>> {
|
||||
try {
|
||||
const p = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
|
||||
function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string, any>>): Set<number> {
|
||||
const ids = new Set<number>()
|
||||
|
||||
// Sous n'importe quel hash : évite de retélécharger un épisode importé via un torrent disparu du scraper
|
||||
const tracked = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? [])
|
||||
if (tracked.has(String(ep.id))) ids.add(ep.id)
|
||||
|
||||
for (const t of sd.torrents ?? []) {
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? [])
|
||||
if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id)
|
||||
}
|
||||
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of season.episodes ?? [])
|
||||
if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id)
|
||||
}
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of ep.torrents ?? []) {
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id)
|
||||
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
if (pathEntry) {
|
||||
const fname = pathEntry.path?.split('/').pop()
|
||||
if (fname && orgFiles[fname] !== undefined) ids.add(ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// ── Vérification des séries surveillées ───────────────────────
|
||||
|
||||
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> {
|
||||
if (Object.keys(loadSynced()).length === 0) return { sent: 0, skipped: 0, errors: 0 }
|
||||
|
||||
const { series: seriesData, complete } = await loadCatalogStatus()
|
||||
if (complete) reconcileSynced(seriesData)
|
||||
const synced = loadSynced()
|
||||
const ids = Object.keys(synced).map(Number)
|
||||
|
||||
logger.info('rss-sync', `Surveillance : vérification de ${ids.length} série(s)`)
|
||||
|
||||
const organized = readOrganized()
|
||||
|
||||
const activeHashes = new Set<string>()
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const infohashMap = await readInfohashMap()
|
||||
const active = await dispatchList(category ?? 'fankai', infohashMap)
|
||||
for (const t of active) if (t.hash) activeHashes.add(t.hash.toLowerCase())
|
||||
} catch (err) {
|
||||
logger.warn('rss-sync', `Impossible de lire les torrents actifs : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
let errors = 0
|
||||
|
||||
for (const serieId of ids) {
|
||||
const syncedEntry = synced[serieId]
|
||||
const sd = seriesData.find((s: any) => s.id === serieId)
|
||||
if (!sd) {
|
||||
logger.warn('rss-sync', `Série ${serieId} (« ${syncedEntry.serieName} ») absente du catalogue : retirez la surveillance dans Paramètres › Catalogue`)
|
||||
continue
|
||||
}
|
||||
|
||||
const title = sd.title ?? sd.show_title ?? String(serieId)
|
||||
const activatedAt = new Date(syncedEntry.addedAt)
|
||||
const organizedIds = getOrganizedEpisodeIds(sd, organized)
|
||||
|
||||
logger.info('rss-sync', `Vérification de « ${title} » (surveillée depuis le ${activatedAt.toLocaleDateString('fr-FR')})`)
|
||||
|
||||
const candidateIds = new Set<number>()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (!ep.date_added) continue
|
||||
const epDate = new Date(ep.date_added)
|
||||
if (epDate <= activatedAt) continue
|
||||
if (organizedIds.has(ep.id)) continue
|
||||
const hasTorrent = (ep.torrents ?? []).some((t: any) => t.fankai && (t.torrent_url || t.magnet))
|
||||
if (!hasTorrent) continue
|
||||
candidateIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateIds.size === 0) {
|
||||
logger.info('rss-sync', `« ${title} » : aucun nouvel épisode depuis l'activation`)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.info('rss-sync', `« ${title} » : ${candidateIds.size} épisode(s) à télécharger`)
|
||||
|
||||
const toDownload: { url: string; file_index?: number | null; file_path?: string | null; infohash?: string | null; label: string }[] = []
|
||||
const coveredIds = new Set<number>()
|
||||
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
if (!t.fankai) continue
|
||||
const url = t.torrent_url ?? t.magnet
|
||||
if (!url) continue
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (hash && activeHashes.has(hash)) { skipped++; continue }
|
||||
|
||||
const coversSomeCandidate = (season.episodes ?? []).some((ep: any) =>
|
||||
candidateIds.has(ep.id) && !coveredIds.has(ep.id)
|
||||
)
|
||||
if (!coversSomeCandidate) { skipped++; continue }
|
||||
|
||||
toDownload.push({ url, infohash: hash ?? null, label: `S${season.season_number} ${title}` })
|
||||
for (const ep of season.episodes ?? []) coveredIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (!candidateIds.has(ep.id) || coveredIds.has(ep.id)) continue
|
||||
|
||||
for (const t of ep.torrents ?? []) {
|
||||
if (!t.fankai) continue
|
||||
const url = t.torrent_url ?? t.magnet
|
||||
if (!url) continue
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (hash && activeHashes.has(hash)) { skipped++; continue }
|
||||
|
||||
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === hash)
|
||||
toDownload.push({
|
||||
url,
|
||||
infohash : hash ?? null,
|
||||
file_index: pathEntry?.file_index ?? null,
|
||||
file_path : pathEntry?.path ?? null,
|
||||
label : `S${season.season_number}E${ep.episode_number} ${title}`,
|
||||
})
|
||||
coveredIds.add(ep.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dl of toDownload) {
|
||||
try {
|
||||
const results = await dispatchDownload(dl.url, {
|
||||
file_index: dl.file_index,
|
||||
file_path : dl.file_path,
|
||||
infohash : dl.infohash,
|
||||
})
|
||||
if (results.some((r: any) => r.ok)) {
|
||||
logger.info('rss-sync', `Envoyé au client : ${dl.label}`)
|
||||
sent++
|
||||
} else {
|
||||
logger.warn('rss-sync', `Échec de l'envoi : ${dl.label}`)
|
||||
errors++
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('rss-sync', `Erreur à l'envoi de ${dl.label} : ${err instanceof Error ? err.message : err}`)
|
||||
errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('rss-sync', `Surveillance terminée : ${sent} envoyé(s), ${skipped} ignoré(s), ${errors} erreur(s)`)
|
||||
return { sent, skipped, errors }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const AUTO_IMPORT_INTERVAL_MS = 5 * 60_000
|
||||
|
||||
export const autoImportSchedule = {
|
||||
lastRunAt: null as string | null,
|
||||
nextRunAt: null as string | null,
|
||||
}
|
||||
|
||||
export function planNextAutoImport(delayMs = AUTO_IMPORT_INTERVAL_MS) {
|
||||
autoImportSchedule.nextRunAt = new Date(Date.now() + delayMs).toISOString()
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import path from 'path'
|
||||
import { logger } from '../logger.js'
|
||||
import { githubGet, loadEnrichedSeriesData, readAvailable } from './github-cache.js'
|
||||
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
|
||||
export async function fankaiGet(endpoint: string): Promise<any> {
|
||||
const res = await fetch(`${FANKAI_API}${endpoint}`)
|
||||
if (!res.ok) throw new Error(`API Fankai : HTTP ${res.status} sur ${endpoint}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchSerieFromApi(serieId: number): Promise<any> {
|
||||
const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${serieId}`), fankaiGet(`/series/${serieId}/seasons`)])
|
||||
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
|
||||
return {
|
||||
...serie,
|
||||
seasons: await Promise.all(seasons.map(async (season: any) => {
|
||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function serieHasEpisodes(sd: any): boolean {
|
||||
return (sd?.seasons ?? []).some((s: any) => (s.episodes ?? []).length > 0)
|
||||
}
|
||||
|
||||
export function serieFolderName(title: string): string {
|
||||
return title.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
// Même règle que seasonFolder() du worker
|
||||
export function seasonFolderName(seasonNumber: number, englishDirectory: boolean): string {
|
||||
return seasonNumber === 0 ? 'Specials' : (englishDirectory ? `Season ${String(seasonNumber).padStart(2, '0')}` : `Saison ${seasonNumber}`)
|
||||
}
|
||||
|
||||
const errorMessage = (err: unknown) => err instanceof Error ? err.message : String(err)
|
||||
|
||||
// Le scraper écarte les épisodes qu'il n'associe à aucun fichier de torrent : on les reprend de l'API, sans torrent
|
||||
function addApiEpisodes(sd: any, api: any): number {
|
||||
const known = new Set<number>()
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? []) {
|
||||
known.add(ep.id)
|
||||
for (const v of Object.values<any>(ep.codec_variants ?? {})) known.add(v.id)
|
||||
}
|
||||
let added = 0
|
||||
for (const apiSeason of api.seasons ?? []) {
|
||||
const eps = (apiSeason.episodes ?? []).filter((ep: any) => !known.has(ep.id))
|
||||
if (eps.length === 0) continue
|
||||
let season = sd.seasons.find((s: any) => s.id === apiSeason.id)
|
||||
if (!season) sd.seasons.push(season = { ...apiSeason, torrents: [], episodes: [] })
|
||||
season.episodes.push(...eps.map((ep: any) => ({ ...ep, torrents: [], paths: [] })))
|
||||
season.episodes.sort((a: any, b: any) => a.episode_number - b.episode_number)
|
||||
added += eps.length
|
||||
}
|
||||
sd.seasons.sort((a: any, b: any) => a.season_number - b.season_number)
|
||||
return added
|
||||
}
|
||||
|
||||
// Données scraper complétées par l'API (titre, épisodes manquants), ou repli complet sur l'API quand le scraper n'a pas (encore) la série
|
||||
export async function resolveSerieData(serieId: number, action?: string): Promise<any | null> {
|
||||
const [scraper, api] = await Promise.allSettled([githubGet(`series/${serieId}.json`), fetchSerieFromApi(serieId)])
|
||||
if (scraper.status === 'fulfilled' && serieHasEpisodes(scraper.value)) {
|
||||
if (api.status !== 'fulfilled') return scraper.value
|
||||
const sd = { ...structuredClone(scraper.value), title: api.value.title ?? scraper.value.title }
|
||||
const added = addApiEpisodes(sd, api.value)
|
||||
if (added > 0 && action) logger.warn('api', `${action} : ${added} épisode(s) de la série ${serieId} absent(s) du scraper, repris de l'API Fankai`)
|
||||
return sd
|
||||
}
|
||||
const scraperError = scraper.status === 'rejected' ? errorMessage(scraper.reason) : 'aucun épisode'
|
||||
if (api.status === 'rejected') {
|
||||
logger.error('api', `${action ?? 'Lecture de la série'} : série ${serieId} introuvable (scraper : ${scraperError}, API Fankai : ${errorMessage(api.reason)})`)
|
||||
return null
|
||||
}
|
||||
if (action) logger.warn('api', `${action} : série ${serieId} absente du scraper (${scraperError}), données reprises de l'API Fankai`)
|
||||
return api.value
|
||||
}
|
||||
|
||||
let lastCatalogWarn = 0
|
||||
|
||||
// Scraper complété par l'API Fankai ; complete = false si une source a échoué (ne rien supprimer alors)
|
||||
export async function loadCatalogStatus(force = false): Promise<{ series: any[]; complete: boolean }> {
|
||||
const [seriesData, availableIds] = await Promise.all([loadEnrichedSeriesData(force), readAvailable(force)])
|
||||
const scraperComplete = availableIds.length > 0 && seriesData.length === availableIds.length
|
||||
let apiSeries: any[]
|
||||
try {
|
||||
const data = await fankaiGet('/series')
|
||||
apiSeries = Array.isArray(data) ? data : (data.series ?? [])
|
||||
} catch (err) {
|
||||
if (Date.now() - lastCatalogWarn > 60 * 60_000) {
|
||||
lastCatalogWarn = Date.now()
|
||||
logger.warn('api', `Catalogue : API Fankai injoignable, titres du scraper conservés (${errorMessage(err)})`)
|
||||
}
|
||||
return { series: seriesData, complete: false }
|
||||
}
|
||||
const titles = new Map<number, string>(apiSeries.filter(s => s.title).map(s => [s.id, s.title]))
|
||||
const catalog = seriesData.map(sd => titles.has(sd.id) ? { ...sd, title: titles.get(sd.id) } : sd)
|
||||
// Scraper vide = injoignable : ne pas interroger l'API série par série
|
||||
if (seriesData.length === 0) return { series: catalog, complete: false }
|
||||
const known = new Set(seriesData.map(sd => sd.id))
|
||||
const missing = await Promise.all(apiSeries.filter(s => !known.has(s.id)).map(s => fetchSerieFromApi(s.id).catch(() => null)))
|
||||
return { series: [...catalog, ...missing.filter(Boolean)], complete: scraperComplete && missing.every(Boolean) }
|
||||
}
|
||||
|
||||
export async function loadCatalog(force = false): Promise<any[]> {
|
||||
return (await loadCatalogStatus(force)).series
|
||||
}
|
||||
|
||||
export function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
|
||||
if (!url) return null
|
||||
const stripped = url.replace(/^https?:\/\//, '')
|
||||
return `https://wsrv.nl/?url=${stripped}&w=${w}&q=${q}`
|
||||
}
|
||||
|
||||
export function normalizeSerie(serie: any): any {
|
||||
return { ...serie, poster_image: imgProxy(serie.poster_image ?? serie.images?.poster ?? null, 300), fanart_image: imgProxy(serie.fanart_image ?? serie.images?.fanart ?? null, 1280, 60) }
|
||||
}
|
||||
|
||||
export function normalizeSeason(season: any): any {
|
||||
return { ...season, poster_image: imgProxy(season.poster_image ?? season.images?.poster ?? null, 300) }
|
||||
}
|
||||
|
||||
export function normalizeEpisode(ep: any): any {
|
||||
return { ...ep, thumb_image: imgProxy(ep.thumb_image ?? ep.thumbnail ?? null, 400) }
|
||||
}
|
||||
|
||||
export function extractTorrentsFromSerieData(sd: any): any[] {
|
||||
const result: any[] = []
|
||||
if (!sd) return result
|
||||
for (const t of sd.torrents ?? []) {
|
||||
result.push({ ...t, raw: t.title, type: 'pack_integrale', serie_id: sd.id, serie_title: sd.title, _serieLevel: true, _serieData: sd, _torrentIdx: (sd.torrents ?? []).indexOf(t) })
|
||||
}
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
result.push({ ...t, raw: t.title, type: 'pack_saison', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, _seasonLevel: true, _season: season, _torrentIdx: (season.torrents ?? []).indexOf(t) })
|
||||
}
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of ep.torrents ?? []) {
|
||||
result.push({ ...t, raw: t.title, type: 'episode', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, episode_id: ep.id, episode_number: ep.episode_number, _episodeLevel: true, _episode: ep, _torrentIdx: (ep.torrents ?? []).indexOf(t) })
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function resolveEpNaming(ep: any, hash?: string | null): {
|
||||
formatted_name : string | null
|
||||
nfo_filename : string | null
|
||||
original_filename: string | null
|
||||
} {
|
||||
const h = hash?.toLowerCase() ?? null
|
||||
const pathEntry = h
|
||||
? (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === h)
|
||||
: null
|
||||
return {
|
||||
formatted_name : pathEntry?.formatted_name ?? ep.formatted_name ?? null,
|
||||
nfo_filename : pathEntry?.nfo_filename ?? ep.nfo_filename ?? null,
|
||||
original_filename: pathEntry?.original_filename ?? ep.original_filename ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function computeExpectedName(
|
||||
ep : any,
|
||||
orgEntry: any,
|
||||
hash : string | null,
|
||||
nfoSupport: boolean,
|
||||
): { currentName: string; expectedName: string; needsRename: boolean } {
|
||||
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
|
||||
const srcExt = path.extname(currentName)
|
||||
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, hash)
|
||||
const expectedName = nfoSupport
|
||||
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
|
||||
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
|
||||
return { currentName, expectedName, needsRename: expectedName !== currentName }
|
||||
}
|
||||
|
||||
/** Dédoublonne par episode_number : la première variante (x264, x265…) garde les torrents des autres. */
|
||||
export function deduplicateEpisodes(episodes: any[]): any[] {
|
||||
const map = new Map<number, any>()
|
||||
for (const ep of episodes) {
|
||||
const key = ep.episode_number
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { ...ep, torrents: [...(ep.torrents ?? [])] })
|
||||
} else {
|
||||
const primary = map.get(key)!
|
||||
const seenHashes = new Set(primary.torrents.map((t: any) => t.infohash).filter(Boolean))
|
||||
for (const t of ep.torrents ?? []) {
|
||||
if (!t.infohash || !seenHashes.has(t.infohash)) {
|
||||
primary.torrents.push(t)
|
||||
if (t.infohash) seenHashes.add(t.infohash)
|
||||
}
|
||||
}
|
||||
primary.torrent = primary.torrents[0] ?? null
|
||||
primary.available = primary.available || ep.available
|
||||
primary.organized = primary.organized || ep.organized
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
export function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] {
|
||||
const resolved: any[] = []
|
||||
const h = hash.toLowerCase()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const paths: any[] = ep.paths ?? []
|
||||
let filePath: string | null = null
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
|
||||
else if (p?.infohash?.toLowerCase() === h) { filePath = p.path.replace(/\\/g, '/'); break }
|
||||
}
|
||||
if (!filePath) continue
|
||||
const filename = filePath.split('/').pop() ?? filePath
|
||||
resolved.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, season_id: season.id, filename, original_filename: filename })
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function computeSerieDownloadState(
|
||||
serieData : any | null,
|
||||
organized : Record<string, Record<string, any>>,
|
||||
activeTorrents: Set<string>,
|
||||
counts = serieData ? countOrganizedEpisodes(serieData, organized) : { total: 0, organized: 0 },
|
||||
): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
if (!serieData) return 'none'
|
||||
if (extractTorrentsFromSerieData(serieData).some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
|
||||
if (counts.total === 0 || counts.organized === 0) return 'none'
|
||||
if (counts.organized >= counts.total) return 'complete'
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
export function countOrganizedEpisodes(serieData: any, organized: Record<string, Record<string, any>>): { total: number; organized: number } {
|
||||
// Épisodes uniques par (saison, numéro) : x264 et x265 comptent pour un
|
||||
const uniqueEpKeys = new Set<string>()
|
||||
const epIdToKey = new Map<number, string>()
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const key = `${season.season_number}:${ep.episode_number}`
|
||||
uniqueEpKeys.add(key)
|
||||
epIdToKey.set(ep.id, key)
|
||||
}
|
||||
}
|
||||
|
||||
const organizedEpKeys = new Set<string>()
|
||||
|
||||
for (const t of extractTorrentsFromSerieData(serieData)) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (!hash) continue
|
||||
const orgFiles = organized[hash] ?? {}
|
||||
if (Object.keys(orgFiles).length === 0) continue
|
||||
for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) {
|
||||
const isOrganized = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
||||
if (isOrganized) {
|
||||
const key = epIdToKey.get(ep.episode_id)
|
||||
if (key) organizedEpKeys.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trackedIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
|
||||
for (const [id, key] of epIdToKey)
|
||||
if (trackedIds.has(String(id))) organizedEpKeys.add(key)
|
||||
|
||||
return { total: uniqueEpKeys.size, organized: organizedEpKeys.size }
|
||||
}
|
||||
@@ -1,38 +1,40 @@
|
||||
/**
|
||||
* logger.ts
|
||||
* =========
|
||||
* Logger centralisé — écrit dans data/logs.jsonl (une ligne JSON par event)
|
||||
* Niveaux : debug | info | warn | error
|
||||
* En prod (NODE_ENV=production) les lignes debug ne sont pas écrites sur disque.
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
export interface LogEntry {
|
||||
at : string // ISO timestamp
|
||||
at : string // date ISO
|
||||
level : LogLevel
|
||||
source : string // 'organize' | 'api' | 'scraper' | ...
|
||||
source : string // 'api', 'organize', 'torrent-clients', 'rss-sync'…
|
||||
msg : string
|
||||
meta? : Record<string, any>
|
||||
}
|
||||
|
||||
const LOGS_PATH = path.join(process.cwd(), 'data', 'logs.jsonl')
|
||||
const MAX_LINES = 2000 // rotation auto au-delà
|
||||
const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
|
||||
const MAX_LINES = 2000 // rotation automatique au-delà
|
||||
const ROTATE_SLACK = 200 // marge avant rotation, pour ne pas réécrire le fichier à chaque ligne
|
||||
const IS_PROD = process.env.NODE_ENV === 'production'
|
||||
|
||||
// ── Écriture ──────────────────────────────────────────────────
|
||||
|
||||
let lineCount: number | null = null
|
||||
|
||||
function countLines(): number {
|
||||
if (!fs.existsSync(LOGS_PATH)) return 0
|
||||
return fs.readFileSync(LOGS_PATH, 'utf-8').split('\n').filter(Boolean).length
|
||||
}
|
||||
|
||||
function writeLine(entry: LogEntry) {
|
||||
try {
|
||||
lineCount ??= countLines()
|
||||
fs.appendFileSync(LOGS_PATH, JSON.stringify(entry) + '\n', 'utf-8')
|
||||
// Rotation si trop grand
|
||||
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
|
||||
const lines = content.split('\n').filter(Boolean)
|
||||
if (lines.length > MAX_LINES) {
|
||||
fs.writeFileSync(LOGS_PATH, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8')
|
||||
if (++lineCount > MAX_LINES + ROTATE_SLACK) {
|
||||
const lines = fs.readFileSync(LOGS_PATH, 'utf-8').split('\n').filter(Boolean).slice(-MAX_LINES)
|
||||
fs.writeFileSync(LOGS_PATH, lines.join('\n') + '\n', 'utf-8')
|
||||
lineCount = lines.length
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -42,14 +44,12 @@ function writeLine(entry: LogEntry) {
|
||||
export function log(level: LogLevel, source: string, msg: string, meta?: Record<string, any>) {
|
||||
const entry: LogEntry = { at: new Date().toISOString(), level, source, msg, meta }
|
||||
|
||||
// Console
|
||||
const prefix = `[${source}]`
|
||||
if (level === 'error') console.error(prefix, msg, meta ?? '')
|
||||
else if (level === 'warn') console.warn(prefix, msg, meta ?? '')
|
||||
else if (level === 'debug' && !IS_PROD) console.log(prefix, msg, meta ?? '')
|
||||
else if (level === 'info') console.log(prefix, msg, meta ?? '')
|
||||
|
||||
// Disque — pas les debug en prod
|
||||
if (level === 'debug' && IS_PROD) return
|
||||
writeLine(entry)
|
||||
}
|
||||
@@ -64,9 +64,9 @@ export const logger = {
|
||||
// ── Lecture pour l'API ────────────────────────────────────────
|
||||
|
||||
export interface LogsReadOptions {
|
||||
limit? : number // nb de lignes (défaut 100)
|
||||
level? : LogLevel | 'all' // filtre niveau
|
||||
source? : string // filtre source
|
||||
limit? : number // nombre de lignes (100 par défaut)
|
||||
level? : LogLevel | 'all'
|
||||
source? : string
|
||||
}
|
||||
|
||||
export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
|
||||
@@ -75,29 +75,25 @@ export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
|
||||
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
|
||||
let lines = content.split('\n').filter(Boolean)
|
||||
|
||||
// Parse
|
||||
let entries: LogEntry[] = []
|
||||
for (const line of lines) {
|
||||
try { entries.push(JSON.parse(line)) } catch {}
|
||||
}
|
||||
|
||||
// Filtres
|
||||
if (opts.level && opts.level !== 'all')
|
||||
entries = entries.filter(e => e.level === opts.level)
|
||||
if (opts.source)
|
||||
entries = entries.filter(e => e.source === opts.source)
|
||||
|
||||
// Plus récents en premier
|
||||
entries.reverse()
|
||||
|
||||
// Limite
|
||||
const limit = opts.limit ?? 100
|
||||
return entries.slice(0, limit)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export function clearLogs(): void {
|
||||
try { fs.writeFileSync(LOGS_PATH, '', 'utf-8') } catch {}
|
||||
try { fs.writeFileSync(LOGS_PATH, '', 'utf-8'); lineCount = 0 } catch {}
|
||||
}
|
||||
|
||||
export function logsFileSize(): number {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readSettings, writeSettings } from './settings.js'
|
||||
import { markAllToursSeen } from './users.js'
|
||||
import { listClients } from './torrent-clients/index.js'
|
||||
import { logger } from './logger.js'
|
||||
|
||||
export const ONBOARDING_STEPS = ['welcome', 'paths', 'client', 'import', 'media-server', 'catalog', 'recap'] as const
|
||||
export type OnboardingStep = typeof ONBOARDING_STEPS[number]
|
||||
|
||||
export interface OnboardingState {
|
||||
step : OnboardingStep | null
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
export function isOnboardingStep(value: unknown): value is OnboardingStep {
|
||||
return typeof value === 'string' && (ONBOARDING_STEPS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
export function readOnboarding(): OnboardingState {
|
||||
const { onboardingStep, onboardingCompletedAt } = readSettings()
|
||||
return { step: isOnboardingStep(onboardingStep) ? onboardingStep : null, completedAt: onboardingCompletedAt }
|
||||
}
|
||||
|
||||
// Installation configurée avant l'assistant : on ne l'impose pas
|
||||
export function migrateOnboarding(): boolean {
|
||||
const settings = readSettings()
|
||||
if (settings.onboardingCompletedAt || settings.onboardingStep) return false
|
||||
if (!settings.mediaPath && listClients().length === 0) return false
|
||||
|
||||
const now = new Date().toISOString()
|
||||
writeSettings({ onboardingCompletedAt: now })
|
||||
markAllToursSeen(now)
|
||||
logger.info('onboarding', 'Installation existante détectée : assistant marqué comme terminé')
|
||||
return true
|
||||
}
|
||||
|
||||
export function startOnboarding(): void {
|
||||
if (readSettings().onboardingCompletedAt) return
|
||||
writeSettings({ onboardingStep: 'welcome' })
|
||||
}
|
||||
|
||||
export function advanceOnboarding(step?: OnboardingStep, complete = false): OnboardingState {
|
||||
const current = readOnboarding()
|
||||
const patch: { onboardingStep?: string; onboardingCompletedAt?: string } = {}
|
||||
|
||||
if (step && (!current.step || ONBOARDING_STEPS.indexOf(step) > ONBOARDING_STEPS.indexOf(current.step))) {
|
||||
patch.onboardingStep = step
|
||||
}
|
||||
if (complete) {
|
||||
patch.onboardingStep = 'recap'
|
||||
patch.onboardingCompletedAt = new Date().toISOString()
|
||||
logger.info('onboarding', 'Assistant de configuration terminé')
|
||||
}
|
||||
if (Object.keys(patch).length > 0) writeSettings(patch)
|
||||
return readOnboarding()
|
||||
}
|
||||
@@ -1,82 +1,191 @@
|
||||
/**
|
||||
* organize-worker.ts
|
||||
* ==================
|
||||
* Worker thread dédié à l'organisation des fichiers.
|
||||
* Tourne dans un thread séparé pour ne pas bloquer le event loop Express.
|
||||
*
|
||||
* Messages reçus (parentPort.on) : { type: 'run', torrents: TorrentInfo[] }
|
||||
* Messages envoyés (parentPort.postMessage) :
|
||||
* { type: 'log', level: 'info'|'warn'|'error', msg: string }
|
||||
* { type: 'result', hash: string, name: string, done, skipped, errors }
|
||||
* { type: 'done' }
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import fsp from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { parentPort } from 'worker_threads'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { getGitlabTitle } from './gitlab-map.js'
|
||||
import { readOrganized } from './lib/organized-store.js'
|
||||
|
||||
// Base directory : binaire Bun ou cwd (Docker/dev)
|
||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
const BASE_DIR = _isBunBinary
|
||||
? path.dirname((process as any).execPath)
|
||||
: process.cwd()
|
||||
|
||||
const TORRENTS_PATH = path.join(BASE_DIR, 'data', 'torrent_final.json')
|
||||
const ORGANIZED_PATH = path.join(BASE_DIR, 'data', 'organized.json')
|
||||
|
||||
// ─── Utils log ────────────────────────────────────────────────
|
||||
// Le worker tourne dans un thread séparé — il passe les logs au thread principal
|
||||
// qui les écrit dans le fichier via logger.ts
|
||||
|
||||
// ─── Journal ──────────────────────────────────────────────────
|
||||
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
|
||||
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
|
||||
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
|
||||
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
|
||||
|
||||
// ─── Settings (lu directement, pas d'import circulaire) ──────
|
||||
// ─── Types ────────────────────────────────────────────────────
|
||||
export interface OrgEntry {
|
||||
at : string
|
||||
season : number
|
||||
episode : number
|
||||
episode_id : number
|
||||
src_filename : string
|
||||
dest_filename: string
|
||||
dest_path : string
|
||||
dest_dir : string
|
||||
}
|
||||
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
|
||||
// organized.json : Record<hash, Record<episode_id_string, OrgEntry>>
|
||||
type Organized = Record<string, Record<string, OrgEntry>>
|
||||
|
||||
// ─── Réglages ─────────────────────────────────────────────────
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean, englishDirectory: boolean } {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'data', 'settings.json')
|
||||
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
|
||||
const p = path.join(DATA_DIR, 'settings.json')
|
||||
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false, englishDirectory: false }
|
||||
return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
|
||||
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false , englishDirectory: false } }
|
||||
}
|
||||
|
||||
// ─── Organized log ────────────────────────────────────────────
|
||||
|
||||
function loadOrganized(): Record<string, Record<string, string>> {
|
||||
try {
|
||||
if (!fs.existsSync(ORGANIZED_PATH)) return {}
|
||||
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
} catch { return {} }
|
||||
// ─── Suivi des imports ────────────────────────────────────────
|
||||
function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
|
||||
parentPort?.postMessage({ type: 'mark', hash, episodeId, entry })
|
||||
}
|
||||
|
||||
function markOrganized(hash: string, filename: string) {
|
||||
const data = loadOrganized()
|
||||
if (!data[hash]) data[hash] = {}
|
||||
data[hash][filename] = new Date().toISOString()
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
function isOrganized(hash: string, episodeId: number): boolean {
|
||||
return !!readOrganized()[hash]?.[String(episodeId)]
|
||||
}
|
||||
|
||||
function isOrganized(hash: string, filename: string): boolean {
|
||||
return !!loadOrganized()[hash]?.[filename]
|
||||
// ─── Recherche du torrent dans le catalogue ───────────────────
|
||||
|
||||
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
|
||||
const h = hash.toLowerCase()
|
||||
for (const sd of seriesData) {
|
||||
for (const t of sd.torrents ?? []) {
|
||||
if (t.infohash?.toLowerCase() === h)
|
||||
return { torrent: t, serieData: sd }
|
||||
}
|
||||
|
||||
const seasonsWithHash = (sd.seasons ?? []).filter((s: any) =>
|
||||
s.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
|
||||
)
|
||||
if (seasonsWithHash.length === 1) {
|
||||
const season = seasonsWithHash[0]
|
||||
const t = season.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||
} else if (seasonsWithHash.length > 1) {
|
||||
const t = seasonsWithHash[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||
return { torrent: t, serieData: sd }
|
||||
}
|
||||
|
||||
for (const season of sd.seasons ?? []) {
|
||||
const matchingEpisodes = season.episodes?.filter((ep: any) =>
|
||||
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
|
||||
) ?? []
|
||||
|
||||
if (matchingEpisodes.length === 1) {
|
||||
const ep = matchingEpisodes[0]
|
||||
const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
|
||||
} else if (matchingEpisodes.length > 1) {
|
||||
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Torrent lookup ───────────────────────────────────────────
|
||||
/** Hash non résolu (ex. dbid_X Synology) : recherche par torrent_name ou title, renvoie le vrai infohash. */
|
||||
function findTorrentByName(
|
||||
name : string,
|
||||
seriesData: any[],
|
||||
): { torrent: any; serieData: any; infohash: string } | null {
|
||||
const n = name.toLowerCase().trim()
|
||||
if (!n) return null
|
||||
|
||||
function findTorrentByHash(hash: string): any | null {
|
||||
try {
|
||||
if (!fs.existsSync(TORRENTS_PATH)) return null
|
||||
const torrents = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
return torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) ?? null
|
||||
} catch { return null }
|
||||
for (const sd of seriesData) {
|
||||
// Pack intégral (torrents au niveau série)
|
||||
for (const t of sd.torrents ?? []) {
|
||||
if (!t.infohash) continue
|
||||
if (
|
||||
t.torrent_name?.toLowerCase().trim() === n ||
|
||||
t.title?.toLowerCase().trim() === n
|
||||
) return { torrent: t, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||
}
|
||||
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
if (!t.infohash) continue
|
||||
if (
|
||||
t.torrent_name?.toLowerCase().trim() === n ||
|
||||
t.title?.toLowerCase().trim() === n
|
||||
) return { torrent: { ...t, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||
}
|
||||
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of ep.torrents ?? []) {
|
||||
if (!t.infohash) continue
|
||||
if (
|
||||
t.torrent_name?.toLowerCase().trim() === n ||
|
||||
t.title?.toLowerCase().trim() === n
|
||||
) return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Dossiers exclus (bonus, musique, images) ────────────────
|
||||
function buildFileMap(
|
||||
serieData : any,
|
||||
hash : string,
|
||||
nfoSupport : boolean,
|
||||
seasonFilter?: number,
|
||||
): Map<string, { season_number: number; episode_number: number; episode_id: number; nfo_filename: string; fullPath: string }> {
|
||||
const map = new Map<string, { season_number: number; episode_number: number; episode_id: number; nfo_filename: string; fullPath: string }>()
|
||||
const h = hash.toLowerCase()
|
||||
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const paths: any[] = ep.paths ?? []
|
||||
let matchedPath : string | null = null
|
||||
let matchedPathObj: any = null
|
||||
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') {
|
||||
if (!matchedPath) matchedPath = p.replace(/\\/g, '/')
|
||||
} else if (p?.infohash?.toLowerCase() === h) {
|
||||
matchedPath = p.path.replace(/\\/g, '/')
|
||||
matchedPathObj = p
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedPath) continue
|
||||
const filename = matchedPath.split('/').pop() ?? matchedPath
|
||||
|
||||
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
|
||||
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
|
||||
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
|
||||
const fmtName = rawFmt?.trim()
|
||||
? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: null
|
||||
|
||||
if (!nfoSupport && !fmtName) {
|
||||
warn(`formatted_name absent pour l'épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) : nom NFO ou nom d'origine utilisé`)
|
||||
}
|
||||
|
||||
const srcExt = path.extname(filename)
|
||||
const resolvedName = nfoSupport
|
||||
? (rawNfo ?? rawOriginal ?? filename)
|
||||
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
|
||||
const destName = swapExtension(resolvedName, srcExt)
|
||||
|
||||
map.set(filename, {
|
||||
season_number : season.season_number,
|
||||
episode_number: ep.episode_number,
|
||||
episode_id : ep.id,
|
||||
nfo_filename : destName,
|
||||
fullPath : matchedPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ─── Dossiers exclus ──────────────────────────────────────────
|
||||
const EXCLUDED_FOLDERS = new Set([
|
||||
'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork',
|
||||
'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced',
|
||||
@@ -94,289 +203,386 @@ function isInExcludedFolder(filePath: string[]): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Map correction titres API → titres GitLab ───────────────
|
||||
|
||||
const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
|
||||
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
|
||||
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
|
||||
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
|
||||
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
|
||||
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
|
||||
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
|
||||
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
|
||||
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
|
||||
'Tower Of God Henshū' : 'Tower of God Henshū',
|
||||
}
|
||||
|
||||
function getGitlabSerieTitle(serieTitle: string): string {
|
||||
return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ─── GitLab NFO downloader ────────────────────────────────────
|
||||
|
||||
// ─── Téléchargement des NFO ───────────────────────────────────
|
||||
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
|
||||
const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
|
||||
|
||||
async function fetchJson(url: string): Promise<any> {
|
||||
const { default: nodeFetch } = await import('node-fetch')
|
||||
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function fetchBinary(url: string): Promise<Buffer> {
|
||||
const { default: nodeFetch } = await import('node-fetch')
|
||||
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return Buffer.from(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
|
||||
const gitlabTitle = getGitlabSerieTitle(serieTitle)
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
const folderPath = `pack/${gitlabTitle}`
|
||||
log(`Téléchargement des NFO et images depuis GitLab pour « ${gitlabTitle} »`)
|
||||
|
||||
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
|
||||
|
||||
// Lister récursivement tous les fichiers du dossier
|
||||
let files: any[]
|
||||
let files: any[] = []
|
||||
try {
|
||||
files = await fetchJson(
|
||||
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&ref=main`
|
||||
)
|
||||
let page = 1
|
||||
while (true) {
|
||||
const batch = await fetchJson(
|
||||
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&page=${page}&ref=main`
|
||||
)
|
||||
if (!Array.isArray(batch) || batch.length === 0) break
|
||||
files.push(...batch)
|
||||
if (batch.length < 100) break
|
||||
page++
|
||||
}
|
||||
} catch (err) {
|
||||
warn(`[nfo] Dossier GitLab introuvable pour "${gitlabTitle}": ${err instanceof Error ? err.message : err}`)
|
||||
warn(`Dossier GitLab introuvable pour « ${gitlabTitle} » : ${err instanceof Error ? err.message : err}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
warn(`[nfo] Aucun fichier trouvé sur GitLab pour "${gitlabTitle}"`)
|
||||
return
|
||||
}
|
||||
if (files.length === 0) { warn(`Aucun fichier NFO trouvé pour « ${gitlabTitle} »`); return }
|
||||
|
||||
const fileEntries = files.filter((f: any) => f.type === 'blob')
|
||||
log(`[nfo] ${fileEntries.length} fichiers à télécharger`)
|
||||
log(`${fileEntries.length} fichier(s) NFO et images à télécharger pour « ${gitlabTitle} »`)
|
||||
|
||||
let downloaded = 0, skipped = 0, failed = 0
|
||||
for (const entry of fileEntries) {
|
||||
// entry.path = "pack/Black Lagoon Henshū/Saison 1/fichier.nfo"
|
||||
// On retire le préfixe "pack/{gitlabTitle}/" pour obtenir le chemin relatif
|
||||
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
||||
const destPath = path.join(destRoot, relativePath)
|
||||
const destDir = path.dirname(destPath)
|
||||
|
||||
// Skip si déjà présent
|
||||
if (fs.existsSync(destPath)) continue
|
||||
|
||||
if (fs.existsSync(destPath)) { skipped++; continue }
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
|
||||
const data = await fetchBinary(rawUrl)
|
||||
fs.writeFileSync(destPath, data)
|
||||
log(`[nfo] ✓ ${relativePath}`)
|
||||
downloaded++
|
||||
} catch (err) {
|
||||
warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`)
|
||||
warn(`Échec du téléchargement de « ${relativePath} » : ${err instanceof Error ? err.message : err}`)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
log(`[nfo] Dossier GitLab synchronisé pour "${serieTitle}"`)
|
||||
log(`NFO de « ${gitlabTitle} » : ${downloaded} téléchargé(s), ${skipped} déjà présent(s)${failed > 0 ? `, ${failed} échec(s)` : ''}`)
|
||||
}
|
||||
|
||||
// ─── Filesystem ops ───────────────────────────────────────────
|
||||
// ─── Fichiers ─────────────────────────────────────────────────
|
||||
|
||||
function swapExtension(filename: string, ext: string): string {
|
||||
const currentExt = path.extname(filename)
|
||||
if (!currentExt || currentExt === ext) return filename
|
||||
return filename.slice(0, -currentExt.length) + ext
|
||||
}
|
||||
|
||||
function seasonFolder(n: number): string {
|
||||
return `Saison ${n}`
|
||||
const { englishDirectory } = readSettings()
|
||||
return n === 0 ? 'Specials' : (englishDirectory ? `Season ${String(n).padStart(2, '0')}` : `Saison ${n}`)
|
||||
}
|
||||
|
||||
/** Supprime un fichier en réessayant si EBUSY (fichier ouvert sous Windows). */
|
||||
async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void> {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
await fsp.unlink(filePath)
|
||||
return
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'EBUSY' && attempt < maxAttempts - 1) {
|
||||
warn(`Fichier « ${path.basename(filePath)} » occupé, nouvelle tentative (${attempt + 1}/${maxAttempts - 1})`)
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryHardlink(src: string, dest: string): boolean {
|
||||
try { fs.linkSync(src, dest); return true }
|
||||
catch (err) {
|
||||
warn(`[organize] hardlink échoué (${err instanceof Error ? err.message : err})`)
|
||||
debug(`Hardlink impossible (${err instanceof Error ? err.message : err}), copie du fichier`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Organise un torrent ──────────────────────────────────────
|
||||
function sanitizeDirName(name: string): string {
|
||||
return name
|
||||
.replace(/:/g, ' -')
|
||||
.replace(/[<>"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
async function organizeTorrent(hash: string, name: string, savePath: string) {
|
||||
// ─── Import d'un torrent ──────────────────────────────────────
|
||||
async function organizeTorrent(
|
||||
hash : string,
|
||||
name : string,
|
||||
savePath : string,
|
||||
seriesData : any[],
|
||||
completedFileNames: Set<string> | null = null,
|
||||
) {
|
||||
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
|
||||
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
|
||||
|
||||
log(`[organize] ── ${name} ──`)
|
||||
log(`[organize] hash=${hash} save_path=${savePath} mode=${organizeMode}`)
|
||||
|
||||
const torrent = findTorrentByHash(hash)
|
||||
if (!torrent) {
|
||||
error(`[organize] ✗ Hash ${hash} introuvable dans torrent_final.json`)
|
||||
const found = findTorrentByHash(hash, seriesData)
|
||||
if (!found) {
|
||||
error(`Hash ${hash} introuvable dans le catalogue`)
|
||||
throw new Error(`Torrent introuvable : ${hash}`)
|
||||
}
|
||||
log(`[organize] ✓ Trouvé : ${torrent.raw}`)
|
||||
|
||||
const serieTitle = torrent.serie_title || torrent.show_title || name
|
||||
const torrentFiles: any[] = torrent.torrent_files ?? []
|
||||
log(`[organize] serie=${serieTitle} fichiers=${torrentFiles.length}`)
|
||||
const { torrent, serieData } = found
|
||||
const rawTitle = serieData.title ?? serieData.show_title ?? name
|
||||
const serieTitle = sanitizeDirName(rawTitle)
|
||||
|
||||
debug(`Série identifiée : « ${serieTitle} » (mode : ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
|
||||
|
||||
// ── Téléchargement NFO/images depuis GitLab ───────────────
|
||||
if (nfoSupport) {
|
||||
const serieDestRoot = path.join(mediaPath, serieTitle)
|
||||
await downloadGitlabFolder(serieTitle, serieDestRoot)
|
||||
await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
|
||||
}
|
||||
|
||||
// ── Cas fichier unique ────────────────────────────────────
|
||||
if (torrentFiles.length === 0) {
|
||||
log(`[organize] torrent_files vide → tentative fichier unique`)
|
||||
const candidates = [path.join(savePath, name), path.join(completePath, name)]
|
||||
let src: string | null = null
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) log(`[organize] ✓ source trouvée: ${c}`)
|
||||
else debug(`[organize] candidate absent: ${c}`)
|
||||
if (fs.existsSync(c)) { src = c; break }
|
||||
}
|
||||
if (!src) throw new Error(`Source introuvable pour "${name}"`)
|
||||
|
||||
const seasonNum = torrent.resolved_episodes?.[0]?.season_number ?? 1
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
|
||||
const dest = path.join(destDir, name)
|
||||
log(`[organize] dest: ${dest}`)
|
||||
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (organizeMode === 'hardlink') {
|
||||
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
||||
} else {
|
||||
await fsp.copyFile(src, dest)
|
||||
await fsp.unlink(src)
|
||||
// ── Torrent d'un seul épisode ─────────────────────────────
|
||||
if (torrent._episode) {
|
||||
const ep = torrent._episode
|
||||
const season = torrent._season
|
||||
const paths: any[] = ep.paths ?? []
|
||||
let filePath : string | null = null
|
||||
let matchedPathObj: any = null
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
|
||||
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) {
|
||||
filePath = p.path.replace(/\\/g, '/')
|
||||
matchedPathObj = p
|
||||
break
|
||||
}
|
||||
log(`[organize] ✓ ${organizeMode} OK`)
|
||||
} else {
|
||||
log(`[organize] → dest existe déjà, skip`)
|
||||
}
|
||||
markOrganized(hash, name)
|
||||
return { ...result, total: 1, done: 1 }
|
||||
}
|
||||
const filename = filePath?.split('/').pop() ?? name
|
||||
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
|
||||
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
|
||||
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
|
||||
const fmtName = rawFmt?.trim()
|
||||
? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: null
|
||||
|
||||
// ── Map filename → season ─────────────────────────────────
|
||||
// Priorité : season_number dans torrent_files (packs multi-saisons)
|
||||
// Fallback : resolved_episodes matchés par episode_number (torrents individuels)
|
||||
const filenameSeasonMap = new Map<string, number>()
|
||||
const srcExt = path.extname(filename)
|
||||
const resolvedName = nfoSupport
|
||||
? (rawNfo ?? rawOriginal ?? filename)
|
||||
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
|
||||
const destName = swapExtension(resolvedName, srcExt)
|
||||
|
||||
// Index resolved_episodes par episode_number pour lookup rapide
|
||||
const epByNum = new Map<number, number>() // ep_number → season_number
|
||||
for (const ep of torrent.resolved_episodes ?? []) {
|
||||
if (ep.episode_number !== undefined && ep.season_number !== undefined)
|
||||
epByNum.set(ep.episode_number, ep.season_number)
|
||||
}
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
||||
const dest = path.join(destDir, destName)
|
||||
|
||||
for (const tf of torrentFiles) {
|
||||
const sn: number | null = tf.season_number ?? null
|
||||
if (sn !== null) {
|
||||
filenameSeasonMap.set(tf.filename, sn)
|
||||
} else if (tf.num !== undefined && epByNum.has(tf.num)) {
|
||||
filenameSeasonMap.set(tf.filename, epByNum.get(tf.num)!)
|
||||
} else if (torrent.season_number !== undefined) {
|
||||
filenameSeasonMap.set(tf.filename, torrent.season_number)
|
||||
}
|
||||
}
|
||||
if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 }
|
||||
|
||||
result.total = torrentFiles.length
|
||||
|
||||
for (const tf of torrentFiles) {
|
||||
const filename = tf.filename
|
||||
const filePath: string[] = tf.path
|
||||
|
||||
// Skip les fichiers dans des dossiers bonus/musique/images
|
||||
if (isInExcludedFolder(filePath)) {
|
||||
log(`[organize] skip bonus: ${filename}`)
|
||||
result.skipped++
|
||||
continue
|
||||
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
|
||||
warn(`Ignoré (téléchargement incomplet ou fichier non sélectionné) : ${filename}`)
|
||||
return { ...result, total: 1, skipped: 1 }
|
||||
}
|
||||
|
||||
if (isOrganized(hash, filename)) { result.skipped++; continue }
|
||||
|
||||
const candidates = [
|
||||
path.join(savePath, name, ...filePath.slice(0, -1), filename),
|
||||
path.join(savePath, ...filePath.slice(0, -1), filename),
|
||||
path.join(savePath, name, filename),
|
||||
const candidates = filePath ? [
|
||||
path.join(savePath, filePath),
|
||||
path.join(completePath, filePath),
|
||||
path.join(savePath, filename),
|
||||
path.join(completePath, filename),
|
||||
] : [
|
||||
path.join(savePath, filename),
|
||||
path.join(completePath, name, ...filePath.slice(0, -1), filename),
|
||||
path.join(completePath, name, filename),
|
||||
path.join(completePath, filename),
|
||||
]
|
||||
|
||||
let src: string | null = null
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) { src = c; break }
|
||||
}
|
||||
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
|
||||
|
||||
if (!src) {
|
||||
const tried = candidates.map(c => ` • ${c}`).join('\n')
|
||||
error(`[organize] ✗ ${filename} introuvable:\n${tried}`)
|
||||
result.errors.push({ file: filename, error: 'Source introuvable' })
|
||||
error(`Fichier source introuvable : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
|
||||
return { ...result, total: 1, errors: [{ file: filename, error: `Fichier source introuvable (chemins essayés : ${candidates.join(', ')})` }] }
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (organizeMode === 'copy') {
|
||||
await fsp.copyFile(src, dest)
|
||||
} else if (organizeMode === 'hardlink') {
|
||||
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
||||
} else {
|
||||
await fsp.copyFile(src, dest)
|
||||
markOrganized(hash, ep.id, {
|
||||
at: new Date().toISOString(), season: season.season_number ?? 1,
|
||||
episode: ep.episode_number, episode_id: ep.id,
|
||||
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir,
|
||||
})
|
||||
await unlinkWithRetry(src)
|
||||
log(`${destName} importé dans ${path.basename(destDir)}`)
|
||||
return { ...result, total: 1, done: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
markOrganized(hash, ep.id, {
|
||||
at: new Date().toISOString(), season: season.season_number ?? 1,
|
||||
episode: ep.episode_number, episode_id: ep.id,
|
||||
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir,
|
||||
})
|
||||
log(`${destName} importé dans ${path.basename(destDir)}`)
|
||||
return { ...result, total: 1, done: 1 }
|
||||
}
|
||||
|
||||
// ── Pack saison ou intégrale ──────────────────────────────
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter)
|
||||
|
||||
if (fileMap.size === 0) {
|
||||
warn(`Aucun fichier de « ${name} » ne correspond au catalogue`)
|
||||
return result
|
||||
}
|
||||
|
||||
result.total = fileMap.size
|
||||
|
||||
for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) {
|
||||
if (isOrganized(hash, episode_id)) { result.skipped++; continue }
|
||||
|
||||
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
|
||||
warn(`Ignoré (téléchargement incomplet ou fichier non sélectionné) : ${filename}`)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const seasonNum = filenameSeasonMap.get(filename) ?? 1
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
|
||||
const dest = path.join(destDir, filename)
|
||||
const candidates = [
|
||||
path.join(savePath, fullPath),
|
||||
path.join(completePath, fullPath),
|
||||
path.join(savePath, filename),
|
||||
path.join(completePath, filename),
|
||||
]
|
||||
let src: string | null = null
|
||||
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
|
||||
|
||||
if (!src) {
|
||||
// Pas encore téléchargé ou hors sélection (priorité 0)
|
||||
warn(`Ignoré (fichier absent du disque) : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number))
|
||||
const dest = path.join(destDir, nfo_filename)
|
||||
|
||||
if (fs.existsSync(dest)) {
|
||||
markOrganized(hash, filename)
|
||||
markOrganized(hash, episode_id, {
|
||||
at: new Date().toISOString(), season: season_number,
|
||||
episode: episode_number, episode_id,
|
||||
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
|
||||
})
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (organizeMode === 'hardlink') {
|
||||
if (organizeMode === 'copy') {
|
||||
await fsp.copyFile(src, dest)
|
||||
markOrganized(hash, episode_id, {
|
||||
at: new Date().toISOString(), season: season_number,
|
||||
episode: episode_number, episode_id,
|
||||
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
|
||||
})
|
||||
} else if (organizeMode === 'hardlink') {
|
||||
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
||||
markOrganized(hash, episode_id, {
|
||||
at: new Date().toISOString(), season: season_number,
|
||||
episode: episode_number, episode_id,
|
||||
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
|
||||
})
|
||||
} else {
|
||||
await fsp.copyFile(src, dest)
|
||||
await fsp.unlink(src)
|
||||
markOrganized(hash, episode_id, {
|
||||
at: new Date().toISOString(), season: season_number,
|
||||
episode: episode_number, episode_id,
|
||||
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
|
||||
})
|
||||
await unlinkWithRetry(src)
|
||||
}
|
||||
markOrganized(hash, filename)
|
||||
result.done++
|
||||
log(`[organize] ✓ ${filename} → Saison ${String(seasonNum).padStart(3,'0')}`)
|
||||
debug(`${nfo_filename} importé dans ${path.basename(destDir)}`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
error(`[organize] ✗ ${filename}: ${msg}`)
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
error(`Échec de l'import de « ${filename} » : ${msg}`)
|
||||
result.errors.push({ file: filename, error: msg })
|
||||
}
|
||||
}
|
||||
|
||||
log(`[organize] ── Résultat ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Boucle principale du worker ─────────────────────────────
|
||||
|
||||
// ─── Boucle principale ────────────────────────────────────────
|
||||
parentPort?.on('message', async (msg: any) => {
|
||||
if (msg.type !== 'run') return
|
||||
|
||||
const torrents: any[] = msg.torrents
|
||||
const organized = loadOrganized()
|
||||
const torrents: any[] = msg.torrents
|
||||
const seriesData: any[] = msg.seriesData ?? []
|
||||
const { nfoSupport } = readSettings()
|
||||
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() as Organized }
|
||||
catch (err) {
|
||||
error(`Import annulé : ${err instanceof Error ? err.message : err}`)
|
||||
parentPort?.postMessage({ type: 'done' })
|
||||
return
|
||||
}
|
||||
|
||||
for (const t of torrents) {
|
||||
if (t.state !== 'seeding') continue
|
||||
|
||||
const torrent = findTorrentByHash(t.hash)
|
||||
if (!torrent) {
|
||||
warn(`[organize] hash ${t.hash} (${t.name}) non trouvé`)
|
||||
let found = findTorrentByHash(t.hash, seriesData)
|
||||
let effectiveHash = t.hash
|
||||
|
||||
// Hash non résolu (ex. dbid_X Synology) : recherche par nom de dossier
|
||||
if (!found && t.name) {
|
||||
const byName = findTorrentByName(t.name, seriesData)
|
||||
if (byName) {
|
||||
found = byName
|
||||
effectiveHash = byName.infohash
|
||||
debug(`Hash résolu par nom de torrent : « ${t.name} » (${effectiveHash.slice(0, 8)}…)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
debug(`Torrent « ${t.name} » (${t.hash.slice(0, 8)}…) absent du catalogue (pas encore à jour ?)`)
|
||||
continue
|
||||
}
|
||||
|
||||
const files: any[] = torrent.torrent_files ?? []
|
||||
// Pour fichier unique : vérifier via le name directement
|
||||
const allDone = files.length > 0
|
||||
? files.every((f: any) => organized[t.hash]?.[f.filename])
|
||||
: !!organized[t.hash]?.[t.name]
|
||||
const { torrent, serieData } = found
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, effectiveHash, nfoSupport, seasonFilter)
|
||||
const orgHash = organized[effectiveHash] ?? {}
|
||||
const allDone = fileMap.size > 0
|
||||
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
|
||||
: Object.keys(orgHash).length > 0
|
||||
|
||||
if (allDone) continue
|
||||
if (allDone) {
|
||||
debug(`« ${t.name} » déjà entièrement importé`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path)
|
||||
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
|
||||
// Fichiers terminés (nom seul) ; null si le client ne donne pas les noms
|
||||
let completedFileNames: Set<string> | null = null
|
||||
if (Array.isArray(t.files) && t.files.length > 0) {
|
||||
const hasNames = t.files.some((f: any) => f.name && String(f.name).trim())
|
||||
if (hasNames) {
|
||||
completedFileNames = new Set(
|
||||
t.files
|
||||
.filter((f: any) => (f.progress ?? 0) >= 1 && f.name && String(f.name).trim())
|
||||
.map((f: any) => {
|
||||
const parts = String(f.name).replace(/\\/g, '/').split('/')
|
||||
return (parts[parts.length - 1] ?? '').toLowerCase()
|
||||
})
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await organizeTorrent(effectiveHash, t.name, t.save_path, seriesData, completedFileNames)
|
||||
parentPort?.postMessage({ type: 'result', hash: effectiveHash, name: t.name, serieId: serieData.id ?? null, ...result })
|
||||
} catch (err) {
|
||||
error(`[organize] Erreur ${t.name}: ${err instanceof Error ? err.message : err}`)
|
||||
error(`Échec de l'import de « ${t.name} » :${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
/**
|
||||
* organize.ts
|
||||
* ===========
|
||||
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
|
||||
* Le worker gère tout le filesystem (copy, hardlink, move).
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { logger } from './logger.js'
|
||||
import { readSettings } from './settings.js'
|
||||
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from './lib/organized-store.js'
|
||||
|
||||
// Un seul worker à la fois
|
||||
let workerRunning = false
|
||||
export let workerRunning = false
|
||||
const _prevStates = new Map<string, string>()
|
||||
|
||||
// Résolution du chemin du worker
|
||||
// En binaire Bun compilé : le worker est à côté de l'exécutable
|
||||
// En Docker/dev : à côté du fichier courant
|
||||
function resolveWorkerPath(): string {
|
||||
// Si Bun est présent → le worker est toujours à côté de l'exécutable
|
||||
// (que ce soit un binaire compilé ou `bun run` en dev)
|
||||
// En Node/tsx → on utilise import.meta.url
|
||||
if (typeof (globalThis as any).Bun !== 'undefined') {
|
||||
return path.join(path.dirname((process as any).execPath), 'organize-worker.js')
|
||||
}
|
||||
@@ -27,61 +17,163 @@ function resolveWorkerPath(): string {
|
||||
}
|
||||
|
||||
export interface OrganizeResult {
|
||||
serieId?: number | null
|
||||
total : number
|
||||
skipped : number
|
||||
done : number
|
||||
errors : { file: string; error: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan initial du mediaPath au démarrage.
|
||||
* Parcourt récursivement le dossier Jellyfin et marque dans organized.json
|
||||
* tous les fichiers déjà présents qui correspondent à un torrent connu.
|
||||
*/
|
||||
type WorkerResult = { hash: string; name: string; serieId?: number | null; total: number; done: number; skipped: number; errors: { file: string; error: string }[] }
|
||||
|
||||
// ── File d'attente des workers ────────────────────────────────
|
||||
let _queue: Promise<unknown> = Promise.resolve()
|
||||
let _queued = 0
|
||||
const _pendingManual = new Map<string, Promise<OrganizeResult>>()
|
||||
|
||||
function enqueue<T>(job: () => Promise<T>): Promise<T> {
|
||||
_queued++
|
||||
const run = _queue.then(job).finally(() => { _queued-- })
|
||||
_queue = run.catch(() => {})
|
||||
return run
|
||||
}
|
||||
|
||||
function runWorker(torrents: any[], seriesData: any[], onResult: (r: WorkerResult) => void): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
workerRunning = true
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
let settled = false
|
||||
const finish = (err?: Error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
workerRunning = false
|
||||
worker.terminate()
|
||||
err ? reject(err) : resolve()
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
|
||||
} else if (msg.type === 'mark') {
|
||||
try {
|
||||
updateOrganized(data => { (data[msg.hash] ??= {})[String(msg.episodeId)] = msg.entry })
|
||||
} catch (err) {
|
||||
logger.error('organize', `Échec de l'enregistrement de l'import (épisode ${msg.episodeId}) :${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
} else if (msg.type === 'result') {
|
||||
onResult(msg)
|
||||
} else if (msg.type === 'done') {
|
||||
finish()
|
||||
}
|
||||
})
|
||||
worker.on('error', err => finish(err))
|
||||
worker.on('exit', code => finish(code > 1 ? new Error(`Arrêt inattendu du worker (code ${code})`) : undefined))
|
||||
|
||||
worker.postMessage({ type: 'run', torrents, seriesData })
|
||||
})
|
||||
}
|
||||
|
||||
// ── Analyse de la médiathèque ─────────────────────────────────
|
||||
|
||||
function swapExt(filename: string, ext: string): string {
|
||||
const cur = path.extname(filename)
|
||||
if (!cur || cur === ext) return filename
|
||||
return filename.slice(0, -cur.length) + ext
|
||||
}
|
||||
|
||||
export let lastScan: { at: string; found: number; added: number } | null = null
|
||||
|
||||
export async function scanMediaPath(
|
||||
mediaPath : string,
|
||||
torrentsPath: string,
|
||||
organizedPath: string
|
||||
mediaPath : string,
|
||||
organizedPath: string,
|
||||
seriesData : any[] = []
|
||||
): Promise<{ found: number; added: number }> {
|
||||
const result = { found: 0, added: 0 }
|
||||
|
||||
if (!fs.existsSync(mediaPath)) {
|
||||
logger.warn('organize', `scanMediaPath: ${mediaPath} inexistant, skip`)
|
||||
logger.warn('organize', `Dossier de la médiathèque introuvable : ${mediaPath}`)
|
||||
return result
|
||||
}
|
||||
|
||||
// Charger torrent_final.json
|
||||
let torrents: any[] = []
|
||||
try {
|
||||
if (!fs.existsSync(torrentsPath)) return result
|
||||
torrents = JSON.parse(fs.readFileSync(torrentsPath, 'utf-8'))
|
||||
} catch {
|
||||
logger.error('organize', 'scanMediaPath: impossible de lire torrent_final.json')
|
||||
logger.info('organize', `Analyse de la médiathèque : ${mediaPath}`)
|
||||
|
||||
const filenameIndex = new Map<string, {
|
||||
hashes : string[]
|
||||
srcFilename : string
|
||||
episodeId : number
|
||||
season : number
|
||||
episode : number
|
||||
destFilename: string
|
||||
}>()
|
||||
|
||||
for (const sd of seriesData) {
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
|
||||
const idx = (filename: string, hash: string, destFilename?: string) => {
|
||||
const base = filename.replace(/\.[^.]+$/, '')
|
||||
if (!base) return
|
||||
const existing = filenameIndex.get(base)
|
||||
if (existing && existing.episodeId === ep.id) {
|
||||
if (!existing.hashes.includes(hash)) existing.hashes.push(hash)
|
||||
return
|
||||
}
|
||||
filenameIndex.set(base, {
|
||||
hashes : [hash],
|
||||
srcFilename : filename,
|
||||
episodeId : ep.id,
|
||||
season : season.season_number,
|
||||
episode : ep.episode_number,
|
||||
destFilename: destFilename ?? filename,
|
||||
})
|
||||
}
|
||||
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p !== 'object' || !p.infohash || !p.path) continue
|
||||
const hash = p.infohash.toLowerCase()
|
||||
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
|
||||
if (!srcFilename) continue
|
||||
idx(srcFilename, hash)
|
||||
const pathNfo = p.nfo_filename ?? ep.nfo_filename
|
||||
if (pathNfo) idx(pathNfo, hash, pathNfo)
|
||||
const pathFmt = p.formatted_name ?? ep.formatted_name
|
||||
if (pathFmt?.trim()) {
|
||||
const fmtBase = pathFmt.replace(/[<>:"/\\|?*]/g, '').trim()
|
||||
idx(fmtBase, hash, fmtBase)
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackHash = ep.paths?.[0]?.infohash?.toLowerCase() ?? 'manual'
|
||||
if (ep.original_filename) idx(ep.original_filename, fallbackHash, ep.original_filename)
|
||||
if (ep.nfo_filename) idx(ep.nfo_filename, fallbackHash, ep.nfo_filename)
|
||||
if (ep.formatted_name?.trim()) {
|
||||
const fmtBase = ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim()
|
||||
idx(fmtBase, fallbackHash, fmtBase)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let organized: Organized
|
||||
try { organized = readOrganized(organizedPath) }
|
||||
catch (err) {
|
||||
logger.error('organize', `Analyse annulée : ${err instanceof Error ? err.message : err}`)
|
||||
return result
|
||||
}
|
||||
|
||||
// Construire un index filename → { hash, infohash }
|
||||
const filenameIndex = new Map<string, string>() // filename → infohash
|
||||
for (const t of torrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (!hash) continue
|
||||
for (const f of t.torrent_files ?? []) {
|
||||
if (f.filename) filenameIndex.set(f.filename, hash)
|
||||
}
|
||||
// Fichier unique (torrent_files vide) → on indexe par raw name
|
||||
if ((t.torrent_files ?? []).length === 0 && t.raw) {
|
||||
filenameIndex.set(t.raw, hash)
|
||||
const presentFiles = new Set<string>()
|
||||
let noMatch = 0
|
||||
|
||||
const trackedByPath = new Map<string, string[]>()
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const [episodeId, entry] of Object.entries(episodes)) {
|
||||
if (!entry?.dest_path) continue
|
||||
const keys = trackedByPath.get(entry.dest_path) ?? []
|
||||
keys.push(`${hash}:${episodeId}`)
|
||||
trackedByPath.set(entry.dest_path, keys)
|
||||
}
|
||||
}
|
||||
|
||||
// Charger organized.json existant
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
// Parcourir récursivement mediaPath
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
|
||||
@@ -90,42 +182,128 @@ export async function scanMediaPath(
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(full)
|
||||
} else if (entry.isFile() && entry.name.endsWith('.mkv')) {
|
||||
} else if (entry.isFile() && /\.(mkv|mp4|avi|m4v|mov|wmv)$/i.test(entry.name)) {
|
||||
result.found++
|
||||
const hash = filenameIndex.get(entry.name)
|
||||
if (!hash) continue
|
||||
if (organized[hash]?.[entry.name]) continue // déjà connu
|
||||
// Marquer comme organisé
|
||||
|
||||
// Fichier déjà suivi (sous n'importe quel hash) : ne pas créer d'entrée en double
|
||||
const tracked = trackedByPath.get(full)
|
||||
if (tracked) {
|
||||
for (const key of tracked) presentFiles.add(key)
|
||||
continue
|
||||
}
|
||||
|
||||
const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '')
|
||||
let match = filenameIndex.get(nameWithoutExt)
|
||||
|
||||
// À défaut, un nom de l'index qui préfixe le fichier (ex. "X.S01E01.MULTI.1080p" pour "X.S01E01.MULTI.1080p.x264-FANKAI")
|
||||
if (!match) {
|
||||
const lower = nameWithoutExt.toLowerCase()
|
||||
for (const [key, val] of filenameIndex.entries()) {
|
||||
if (lower.startsWith(key.toLowerCase() + '.') || lower === key.toLowerCase()) {
|
||||
match = val
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
noMatch++
|
||||
const prefix = nameWithoutExt.slice(0, 20).toLowerCase()
|
||||
const close = [...filenameIndex.keys()].filter(k => k.toLowerCase().startsWith(prefix)).slice(0, 3)
|
||||
const closeStr = close.length > 0 ? ` (candidats : ${close.map(c => `« ${c} »`).join(', ')})` : ' (aucun candidat proche)'
|
||||
logger.warn('organize', `Analyse : aucune correspondance pour « ${nameWithoutExt} »${closeStr}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const { hashes, srcFilename, episodeId, season, episode } = match
|
||||
const hash = hashes.find(h => organized[h]?.[String(episodeId)]) ?? hashes[0]
|
||||
presentFiles.add(`${hash}:${episodeId}`)
|
||||
|
||||
if (organized[hash]?.[String(episodeId)]) continue
|
||||
|
||||
if (!organized[hash]) organized[hash] = {}
|
||||
organized[hash][entry.name] = new Date().toISOString()
|
||||
organized[hash][String(episodeId)] = {
|
||||
at : new Date().toISOString(),
|
||||
season,
|
||||
episode,
|
||||
episode_id : episodeId,
|
||||
src_filename : srcFilename,
|
||||
dest_filename: entry.name,
|
||||
dest_path : full,
|
||||
}
|
||||
result.added++
|
||||
logger.debug('organize', `Analyse : « ${entry.name} » associé à l'épisode ${episodeId} (S${season}E${episode})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(mediaPath)
|
||||
|
||||
if (result.added > 0) {
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, ${result.added} ajoutés à organized.json`)
|
||||
} else {
|
||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`)
|
||||
const mediaEmpty = result.found === 0 && Object.keys(organized).length > 0
|
||||
if (mediaEmpty) logger.warn('organize', `Analyse : aucun fichier trouvé dans ${mediaPath}, entrées existantes conservées`)
|
||||
|
||||
// Supprimer uniquement les entrées connues dans l'index et absentes du disque
|
||||
const allEpisodeIds = new Set(
|
||||
[...filenameIndex.values()].flatMap(v => v.hashes.map(h => `${h}:${v.episodeId}`))
|
||||
)
|
||||
|
||||
let removed = 0
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
if (mediaEmpty) break
|
||||
for (const episodeId of Object.keys(episodes)) {
|
||||
if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) {
|
||||
delete organized[hash][episodeId]
|
||||
removed++
|
||||
}
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
||||
}
|
||||
|
||||
const { autoUnimportMissing } = readSettings()
|
||||
let autoRemoved = 0
|
||||
if (autoUnimportMissing && !mediaEmpty) {
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
|
||||
const destPath = entry?.dest_path
|
||||
if (destPath && !fs.existsSync(destPath)) {
|
||||
delete (organized[hash] as any)[episodeId]
|
||||
autoRemoved++
|
||||
logger.info('organize', `Retrait automatique : fichier introuvable « ${destPath} »`)
|
||||
}
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
||||
}
|
||||
}
|
||||
|
||||
if (result.added > 0 || removed > 0 || autoRemoved > 0) {
|
||||
writeOrganized(organized, organizedPath)
|
||||
}
|
||||
|
||||
lastScan = { at: new Date().toISOString(), ...result }
|
||||
logger.info('organize', `Analyse terminée : ${result.found} fichier(s), ${result.added} ajouté(s), ${removed} entrée(s) obsolète(s) retirée(s) du suivi${autoRemoved > 0 ? `, ${autoRemoved} retirée(s) (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} sans correspondance` : ''}`)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance l'auto-organisation dans un worker thread.
|
||||
* listFn est appelée dans le thread principal (accès qBittorrent),
|
||||
* puis les données sont passées au worker.
|
||||
*/
|
||||
// ── Import automatique ────────────────────────────────────────
|
||||
|
||||
export async function autoOrganizeAll(
|
||||
listFn : () => Promise<any[]>,
|
||||
onResult?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
|
||||
listFn : () => Promise<any[]>,
|
||||
seriesData : any[],
|
||||
onResult ?: (r: { hash: string; name: string; serieId?: number | null; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
|
||||
): Promise<void> {
|
||||
if (workerRunning) {
|
||||
console.log('[organize] Worker déjà en cours, skip')
|
||||
const { autoImport, mediaPath } = readSettings()
|
||||
if (!autoImport) {
|
||||
logger.debug('organize', 'Import automatique désactivé')
|
||||
return
|
||||
}
|
||||
if (!mediaPath?.trim()) {
|
||||
logger.debug('organize', 'Médiathèque non configurée')
|
||||
return
|
||||
}
|
||||
|
||||
if (_queued > 0) {
|
||||
logger.debug('organize', 'Import déjà en cours')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -133,94 +311,330 @@ export async function autoOrganizeAll(
|
||||
try {
|
||||
torrents = await listFn()
|
||||
} catch (err) {
|
||||
console.error('[organize] Impossible de récupérer la liste des torrents:', err)
|
||||
logger.error('organize', `Impossible de récupérer la liste des torrents : ${err instanceof Error ? err.message : err}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (torrents.length === 0) return
|
||||
|
||||
const isFirstRun = _prevStates.size === 0
|
||||
const seedingCount = torrents.filter(t => t.state === 'seeding').length
|
||||
|
||||
const newlySeeding = torrents.filter(t => {
|
||||
const prev = _prevStates.get(t.hash)
|
||||
return t.state === 'seeding' && (prev === undefined || prev !== 'seeding')
|
||||
})
|
||||
|
||||
for (const t of torrents) _prevStates.set(t.hash, t.state)
|
||||
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) {
|
||||
logger.error('organize', `Import automatique annulé : ${err instanceof Error ? err.message : err}`)
|
||||
return
|
||||
}
|
||||
|
||||
const hasUnorganized = torrents.some(t =>
|
||||
t.state === 'seeding' && !organized[t.hash?.toLowerCase()]
|
||||
)
|
||||
|
||||
if (!isFirstRun && newlySeeding.length === 0 && !hasUnorganized) return
|
||||
if (seedingCount === 0) return
|
||||
|
||||
console.log(`[organize] Lancement worker (${seedingCount} torrents en seeding)`)
|
||||
workerRunning = true
|
||||
|
||||
const workerPath = resolveWorkerPath()
|
||||
|
||||
const worker = new Worker(workerPath)
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
if (msg.level === 'error') console.error(msg.msg)
|
||||
else if (msg.level === 'warn') console.warn(msg.msg)
|
||||
else console.log(msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
|
||||
onResult?.({
|
||||
hash : msg.hash,
|
||||
name : msg.name,
|
||||
done : msg.done,
|
||||
skipped : msg.skipped,
|
||||
errors : msg.errors.length,
|
||||
errorFiles : msg.errors,
|
||||
})
|
||||
} else if (msg.type === 'done') {
|
||||
console.log('[organize] Worker terminé')
|
||||
workerRunning = false
|
||||
worker.terminate()
|
||||
if (newlySeeding.length > 0 && !isFirstRun) {
|
||||
logger.info('organize', `${newlySeeding.length} torrent(s) terminé(s) : lancement de l'import automatique`)
|
||||
for (const t of newlySeeding) {
|
||||
logger.debug('organize', `Torrent terminé : « ${t.name} »`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
logger.debug('organize', `Import automatique : vérification de ${seedingCount} torrent(s) terminé(s)`)
|
||||
}
|
||||
|
||||
worker.on('error', (err) => {
|
||||
console.error('[organize] Worker erreur:', err)
|
||||
workerRunning = false
|
||||
})
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (code !== 0) console.error(`[organize] Worker exit code ${code}`)
|
||||
workerRunning = false
|
||||
})
|
||||
|
||||
// Envoyer les torrents au worker
|
||||
worker.postMessage({ type: 'run', torrents })
|
||||
try {
|
||||
await enqueue(() => runWorker(torrents, seriesData, msg => {
|
||||
if (msg.errors.length > 0) {
|
||||
logger.warn('organize', `« ${msg.name} » : ${msg.done} importé(s), ${msg.skipped} ignoré(s), ${msg.errors.length} erreur(s)`)
|
||||
for (const e of msg.errors) {
|
||||
logger.error('organize', `Erreur sur « ${e.file} » : ${e.error}`)
|
||||
}
|
||||
} else if (msg.done > 0) {
|
||||
logger.info('organize', `« ${msg.name} » : ${msg.done} fichier(s) importé(s), ${msg.skipped} ignoré(s)`)
|
||||
} else {
|
||||
logger.debug('organize', `« ${msg.name} » : rien à importer (${msg.skipped} ignoré(s))`)
|
||||
}
|
||||
onResult?.({
|
||||
hash : msg.hash,
|
||||
name : msg.name,
|
||||
serieId : msg.serieId ?? null,
|
||||
done : msg.done,
|
||||
skipped : msg.skipped,
|
||||
errors : msg.errors.length,
|
||||
errorFiles: msg.errors,
|
||||
})
|
||||
}))
|
||||
logger.debug('organize', 'Worker d\'import terminé')
|
||||
} catch (err) {
|
||||
logger.error('organize', `Erreur du worker : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Organisation manuelle d'un torrent (appelée depuis l'API /organize).
|
||||
* Lance aussi dans un worker thread.
|
||||
*/
|
||||
// ── Migration des IDs d'épisodes ──────────────────────────────
|
||||
// Les IDs du scraper changent : on les retrouve via (infohash, saison, épisode)
|
||||
|
||||
export async function migrateOrganizedEpisodeIds(
|
||||
organizedPath: string,
|
||||
seriesData : any[]
|
||||
): Promise<{ updated: number; orphaned: number }> {
|
||||
|
||||
// infohash : Map<"saison:épisode", ID actuel>
|
||||
const hashEpMap = new Map<string, Map<string, number>>()
|
||||
const validIds = new Set<number>()
|
||||
|
||||
for (const sd of seriesData) {
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
validIds.add(ep.id)
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p !== 'object' || !p.infohash) continue
|
||||
const h = p.infohash.toLowerCase()
|
||||
const key = `${season.season_number}:${ep.episode_number}`
|
||||
if (!hashEpMap.has(h)) hashEpMap.set(h, new Map())
|
||||
hashEpMap.get(h)!.set(key, ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let organized: Organized
|
||||
try { organized = readOrganized(organizedPath) }
|
||||
catch (err) {
|
||||
logger.error('organize', `Migration des IDs annulée :${err instanceof Error ? err.message : err}`)
|
||||
return { updated: 0, orphaned: 0 }
|
||||
}
|
||||
|
||||
let updated = 0
|
||||
let orphaned = 0
|
||||
let changed = false
|
||||
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
if (hash === 'manual') continue
|
||||
const epMap = hashEpMap.get(hash.toLowerCase())
|
||||
|
||||
const newEpisodes: Record<string, any> = {}
|
||||
for (const [oldIdStr, entry] of Object.entries(episodes as Record<string, any>)) {
|
||||
const oldId = Number(oldIdStr)
|
||||
|
||||
if (validIds.has(oldId)) {
|
||||
newEpisodes[oldIdStr] = entry
|
||||
continue
|
||||
}
|
||||
|
||||
const key = `${entry.season}:${entry.episode}`
|
||||
const newId = epMap?.get(key)
|
||||
|
||||
if (newId && newId !== oldId) {
|
||||
if (newEpisodes[String(newId)] || (episodes as any)[String(newId)]) {
|
||||
changed = true
|
||||
logger.info('organize', `Migration des IDs : entrée de l'épisode ${oldId} supprimée, ${newId} déjà présent (${hash.slice(0, 8)}…, S${entry.season}E${entry.episode})`)
|
||||
} else {
|
||||
newEpisodes[String(newId)] = { ...entry, episode_id: newId }
|
||||
updated++
|
||||
changed = true
|
||||
logger.info('organize', `Migration des IDs : épisode ${oldId} remplacé par ${newId} (${hash.slice(0, 8)}…, S${entry.season}E${entry.episode})`)
|
||||
}
|
||||
} else {
|
||||
orphaned++
|
||||
newEpisodes[oldIdStr] = entry
|
||||
logger.warn('organize', `Migration des IDs : épisode ${oldId} introuvable dans le catalogue (${hash.slice(0, 8)}…, S${entry.season}E${entry.episode})`)
|
||||
}
|
||||
}
|
||||
|
||||
organized[hash] = newEpisodes
|
||||
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
writeOrganized(organized, organizedPath)
|
||||
logger.info('organize', `Migration des IDs terminée : ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolu(s)` : ''}`)
|
||||
} else {
|
||||
logger.debug('organize', `Migration des IDs : aucun changement${orphaned > 0 ? ` (${orphaned} ID(s) inconnu(s) conservé(s))` : ''}`)
|
||||
}
|
||||
|
||||
return { updated, orphaned }
|
||||
}
|
||||
|
||||
// ── Dédoublonnage ─────────────────────────────────────────────
|
||||
export function dedupeOrganizedEpisodes(
|
||||
organizedPath: string,
|
||||
): { removed: number } {
|
||||
let organized: Organized
|
||||
try { organized = readOrganized(organizedPath) }
|
||||
catch (err) {
|
||||
logger.error('organize', `Dédoublonnage annulé : ${err instanceof Error ? err.message : err}`)
|
||||
return { removed: 0 }
|
||||
}
|
||||
|
||||
// episode_id : [{ hash, entry }]
|
||||
const byEpisode = new Map<string, { hash: string; entry: any }[]>()
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
|
||||
if (!byEpisode.has(episodeId)) byEpisode.set(episodeId, [])
|
||||
byEpisode.get(episodeId)!.push({ hash, entry })
|
||||
}
|
||||
}
|
||||
|
||||
let removed = 0
|
||||
for (const [episodeId, candidates] of byEpisode) {
|
||||
if (candidates.length < 2) continue
|
||||
|
||||
const present = candidates.filter(c => c.entry?.dest_path && fs.existsSync(c.entry.dest_path))
|
||||
if (present.length === 0) continue
|
||||
|
||||
for (const c of candidates) {
|
||||
if (present.includes(c)) continue
|
||||
delete (organized[c.hash] as any)[episodeId]
|
||||
removed++
|
||||
logger.info('organize', `Dédoublonnage : épisode ${episodeId} retiré de ${c.hash.slice(0, 8)}… (fichier absent, conservé sous ${present[0].hash.slice(0, 8)}…)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
writeOrganized(organized, organizedPath)
|
||||
logger.info('organize', `Dédoublonnage terminé : ${removed} entrée(s) redondante(s) supprimée(s)`)
|
||||
}
|
||||
|
||||
return { removed }
|
||||
}
|
||||
|
||||
// ── Renommage selon le catalogue ──────────────────────────────
|
||||
|
||||
export async function syncFilenameChanges(
|
||||
seriesData : any[],
|
||||
organizedPath: string,
|
||||
): Promise<{ renamed: number; errors: number }> {
|
||||
const { nfoSupport } = readSettings()
|
||||
let organized: Organized
|
||||
try { organized = readOrganized(organizedPath) }
|
||||
catch (err) {
|
||||
logger.error('organize', `Renommage selon le catalogue annulé :${err instanceof Error ? err.message : err}`)
|
||||
return { renamed: 0, errors: 0 }
|
||||
}
|
||||
|
||||
let renamed = 0
|
||||
let errors = 0
|
||||
let changed = false
|
||||
|
||||
for (const sd of seriesData) {
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
let orgEntry: any = null
|
||||
let orgHash: string | null = null
|
||||
for (const [hash, eps] of Object.entries(organized)) {
|
||||
if ((eps as any)[String(ep.id)]) {
|
||||
orgEntry = (eps as any)[String(ep.id)]
|
||||
orgHash = hash
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!orgEntry || !orgHash) continue
|
||||
|
||||
const currentName = orgEntry.dest_path
|
||||
? path.basename(orgEntry.dest_path)
|
||||
: orgEntry.dest_filename
|
||||
const srcExt = path.extname(currentName)
|
||||
const matchedPathEntry = (ep.paths ?? []).find((p: any) =>
|
||||
typeof p === 'object' && p.infohash?.toLowerCase() === orgHash
|
||||
)
|
||||
const rawFmt = matchedPathEntry?.formatted_name ?? ep.formatted_name
|
||||
const rawNfo = matchedPathEntry?.nfo_filename ?? ep.nfo_filename
|
||||
const expectedName: string = nfoSupport
|
||||
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
|
||||
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
|
||||
|
||||
if (expectedName === currentName) continue
|
||||
|
||||
const oldPath = orgEntry.dest_path
|
||||
if (!oldPath || !fs.existsSync(oldPath)) continue
|
||||
|
||||
const newPath = path.join(path.dirname(oldPath), expectedName)
|
||||
try {
|
||||
if (fs.existsSync(newPath)) {
|
||||
logger.warn('organize', `Renommage ignoré : « ${expectedName} » existe déjà`)
|
||||
continue
|
||||
}
|
||||
fs.renameSync(oldPath, newPath)
|
||||
;(organized[orgHash] as any)[String(ep.id)] = {
|
||||
...orgEntry,
|
||||
dest_filename: expectedName,
|
||||
dest_path : newPath,
|
||||
at : new Date().toISOString(),
|
||||
}
|
||||
retargetEntries(organized, new Map([[oldPath, newPath]]))
|
||||
renamed++
|
||||
changed = true
|
||||
logger.info('organize', `Renommage : « ${currentName} » en « ${expectedName} »`)
|
||||
} catch (err) {
|
||||
errors++
|
||||
logger.error('organize', `Échec du renommage de l'épisode ${ep.id} :${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
writeOrganized(organized, organizedPath)
|
||||
|
||||
if (renamed > 0 || errors > 0)
|
||||
logger.info('organize', `Noms de fichiers mis à jour : ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
|
||||
|
||||
return { renamed, errors }
|
||||
}
|
||||
|
||||
// ── Import manuel ─────────────────────────────────────────────
|
||||
|
||||
export async function organizeTorrent(
|
||||
hash : string,
|
||||
name : string,
|
||||
savePath: string
|
||||
hash : string,
|
||||
name : string,
|
||||
savePath : string,
|
||||
seriesData: any[],
|
||||
files? : { name?: string; progress: number; priority?: number }[],
|
||||
): Promise<OrganizeResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const workerPath = resolveWorkerPath()
|
||||
const worker = new Worker(workerPath)
|
||||
const key = hash.toLowerCase()
|
||||
const pending = _pendingManual.get(key)
|
||||
if (pending) {
|
||||
logger.info('organize', `Import manuel de « ${name} » déjà en file d'attente`)
|
||||
return pending
|
||||
}
|
||||
logger.info('organize', `Import manuel ${_queued > 0 ? 'mis en file d\'attente' : 'lancé'} pour « ${name} » (${hash})`)
|
||||
|
||||
// On envoie un seul torrent "fictif seeding"
|
||||
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
|
||||
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
|
||||
// Progression par fichier : le worker ignore les fichiers incomplets (évite EBUSY sous Windows)
|
||||
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding', files: files ?? [] }
|
||||
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
if (msg.level === 'error') console.error(msg.msg)
|
||||
else if (msg.level === 'warn') console.warn(msg.msg)
|
||||
else console.log(msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
const job = enqueue(async () => {
|
||||
_pendingManual.delete(key)
|
||||
try {
|
||||
await runWorker([fakeTorrent], seriesData, msg => {
|
||||
result.serieId = msg.serieId ?? null
|
||||
result.total = msg.total
|
||||
result.skipped = msg.skipped
|
||||
result.done = msg.done
|
||||
result.errors = msg.errors
|
||||
} else if (msg.type === 'done') {
|
||||
worker.terminate()
|
||||
resolve(result)
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => { worker.terminate(); reject(err) })
|
||||
worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exit ${code}`)) })
|
||||
|
||||
worker.postMessage({ type: 'run', torrents: [fakeTorrent] })
|
||||
if (msg.errors.length > 0) {
|
||||
logger.warn('organize', `Import de « ${name} » : ${msg.done} importé(s), ${msg.errors.length} erreur(s)`)
|
||||
for (const e of msg.errors) {
|
||||
logger.error('organize', `Erreur sur « ${e.file} » : ${e.error}`)
|
||||
}
|
||||
} else {
|
||||
logger.info('organize', `Import de « ${name} » terminé : ${msg.done} fichier(s) importé(s), ${msg.skipped} ignoré(s)`)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('organize', `Échec de l'import de « ${name} » :${err instanceof Error ? err.message : err}`)
|
||||
throw err
|
||||
}
|
||||
return result
|
||||
})
|
||||
_pendingManual.set(key, job)
|
||||
return job
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
import { findById } from './users.js'
|
||||
|
||||
export type RequestStatus = 'pending' | 'approved' | 'rejected' | 'completed'
|
||||
|
||||
export interface Requester {
|
||||
userId : string
|
||||
username : string
|
||||
seasons : number[] // numéros de saison ; [] = toutes les saisons disponibles
|
||||
episodes : number[] // IDs d'épisodes ciblés ; [] = aucun
|
||||
requestedAt: string
|
||||
}
|
||||
|
||||
export interface SerieRequest {
|
||||
id : string
|
||||
serieId : number
|
||||
serieName : string
|
||||
requesters : Requester[]
|
||||
status : RequestStatus
|
||||
rejectionMessage?: string
|
||||
createdAt : string
|
||||
updatedAt : string
|
||||
}
|
||||
|
||||
const REQUESTS_PATH = path.join(DATA_DIR, 'requests.json')
|
||||
|
||||
// ── Fichier des demandes ──────────────────────────────────────
|
||||
|
||||
export function readRequests(): SerieRequest[] {
|
||||
try {
|
||||
if (!fs.existsSync(REQUESTS_PATH)) return []
|
||||
return JSON.parse(fs.readFileSync(REQUESTS_PATH, 'utf-8')) as SerieRequest[]
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function writeRequests(requests: SerieRequest[]): void {
|
||||
fs.mkdirSync(path.dirname(REQUESTS_PATH), { recursive: true })
|
||||
fs.writeFileSync(REQUESTS_PATH, JSON.stringify(requests, null, 2))
|
||||
}
|
||||
|
||||
/** Saisons demandées par l'ensemble des demandeurs ([] = toutes). */
|
||||
export function mergedSeasons(req: SerieRequest): number[] {
|
||||
if (req.requesters.some(r => r.seasons.length === 0 && r.episodes.length === 0)) return []
|
||||
const all = new Set<number>()
|
||||
req.requesters.forEach(r => r.seasons.forEach(s => all.add(s)))
|
||||
return [...all].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
export function mergedEpisodes(req: SerieRequest): number[] {
|
||||
const all = new Set<number>()
|
||||
req.requesters.forEach(r => (r.episodes ?? []).forEach(e => all.add(e)))
|
||||
return [...all].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
// ── Création et suivi ─────────────────────────────────────────
|
||||
|
||||
export function upsertRequest(
|
||||
userId : string,
|
||||
serieId : number,
|
||||
serieName: string,
|
||||
seasons : number[], // [] = toutes les saisons
|
||||
episodes : number[] = [],
|
||||
): SerieRequest {
|
||||
const user = findById(userId)
|
||||
if (!user) throw new Error('Utilisateur introuvable')
|
||||
|
||||
const requests = readRequests()
|
||||
|
||||
const existing = requests.find(r =>
|
||||
r.serieId === serieId &&
|
||||
r.status !== 'rejected' &&
|
||||
r.status !== 'completed'
|
||||
)
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
if (existing) {
|
||||
const idx = existing.requesters.findIndex(r => r.userId === userId)
|
||||
if (idx >= 0) {
|
||||
const r = existing.requesters[idx]
|
||||
const newIsAll = seasons.length === 0 && episodes.length === 0
|
||||
const curIsAll = r.seasons.length === 0 && (r.episodes ?? []).length === 0
|
||||
if (newIsAll || curIsAll) {
|
||||
// Si l'une couvre toute la série : plus de filtre saison ni épisode
|
||||
r.seasons = []
|
||||
r.episodes = []
|
||||
} else {
|
||||
r.seasons = [...new Set([...r.seasons, ...seasons ])].sort((a, b) => a - b)
|
||||
r.episodes = [...new Set([...(r.episodes ?? []), ...episodes ])].sort((a, b) => a - b)
|
||||
}
|
||||
r.requestedAt = now
|
||||
} else {
|
||||
existing.requesters.push({ userId, username: user.username, seasons, episodes, requestedAt: now })
|
||||
}
|
||||
existing.updatedAt = now
|
||||
writeRequests(requests)
|
||||
logger.info('requests', `Demande de « ${serieName} » complétée par « ${user.username} »`)
|
||||
return existing
|
||||
}
|
||||
|
||||
const request: SerieRequest = {
|
||||
id : crypto.randomUUID(),
|
||||
serieId,
|
||||
serieName,
|
||||
requesters: [{ userId, username: user.username, seasons, episodes, requestedAt: now }],
|
||||
status : 'pending',
|
||||
createdAt : now,
|
||||
updatedAt : now,
|
||||
}
|
||||
writeRequests([...requests, request])
|
||||
logger.info('requests', `Nouvelle demande de « ${serieName} » par « ${user.username} »`)
|
||||
return request
|
||||
}
|
||||
|
||||
export function approveRequest(id: string): SerieRequest {
|
||||
const requests = readRequests()
|
||||
const req = requests.find(r => r.id === id)
|
||||
if (!req) throw new Error('Demande introuvable')
|
||||
req.status = 'approved'
|
||||
req.rejectionMessage = undefined
|
||||
req.updatedAt = new Date().toISOString()
|
||||
writeRequests(requests)
|
||||
logger.info('requests', `Demande « ${req.serieName} » approuvée`)
|
||||
return req
|
||||
}
|
||||
|
||||
export function rejectRequest(id: string, message?: string): SerieRequest {
|
||||
const requests = readRequests()
|
||||
const req = requests.find(r => r.id === id)
|
||||
if (!req) throw new Error('Demande introuvable')
|
||||
req.status = 'rejected'
|
||||
req.rejectionMessage = message
|
||||
req.updatedAt = new Date().toISOString()
|
||||
writeRequests(requests)
|
||||
logger.info('requests', `Demande « ${req.serieName} » refusée`)
|
||||
return req
|
||||
}
|
||||
|
||||
export function completeRequest(id: string): SerieRequest {
|
||||
const requests = readRequests()
|
||||
const req = requests.find(r => r.id === id)
|
||||
if (!req) throw new Error('Demande introuvable')
|
||||
req.status = 'completed'
|
||||
req.updatedAt = new Date().toISOString()
|
||||
writeRequests(requests)
|
||||
return req
|
||||
}
|
||||
|
||||
export function deleteRequest(id: string): void {
|
||||
const requests = readRequests()
|
||||
if (!requests.find(r => r.id === id)) throw new Error('Demande introuvable')
|
||||
writeRequests(requests.filter(r => r.id !== id))
|
||||
}
|
||||
|
||||
/** Retire l'utilisateur d'une demande en attente ; supprime la demande s'il était seul. */
|
||||
export function withdrawRequest(id: string, userId: string): { deleted: boolean; request: SerieRequest | null } {
|
||||
const requests = readRequests()
|
||||
const req = requests.find(r => r.id === id)
|
||||
if (!req || !req.requesters.some(r => r.userId === userId)) throw new Error('Demande introuvable')
|
||||
if (req.status !== 'pending') throw new Error('Seule une demande en attente peut être annulée')
|
||||
|
||||
const username = req.requesters.find(r => r.userId === userId)!.username
|
||||
req.requesters = req.requesters.filter(r => r.userId !== userId)
|
||||
if (req.requesters.length === 0) {
|
||||
writeRequests(requests.filter(r => r.id !== id))
|
||||
logger.info('requests', `Demande « ${req.serieName} » annulée par « ${username} »`)
|
||||
return { deleted: true, request: null }
|
||||
}
|
||||
req.updatedAt = new Date().toISOString()
|
||||
writeRequests(requests)
|
||||
logger.info('requests', `« ${username} » retiré de la demande « ${req.serieName} »`)
|
||||
return { deleted: false, request: req }
|
||||
}
|
||||
|
||||
export function getRequestsForUser(userId: string): SerieRequest[] {
|
||||
return readRequests().filter(r => r.requesters.some(req => req.userId === userId))
|
||||
}
|
||||
|
||||
export function getPendingCount(): number {
|
||||
return readRequests().filter(r => r.status === 'pending').length
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { dispatchDownload, dispatchList, dispatchRemove } from '../torrent-clients/index.js'
|
||||
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
|
||||
import { extractTorrentsFromSerieData, buildResolvedEpisodes } from '../lib/serie-helpers.js'
|
||||
import { recentOrganized } from '../lib/notifs.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/download', requireAuth, async (req, res) => {
|
||||
// Préférer le fichier .torrent au magnet : métadonnées immédiates, pas de dépendance DHT/trackers
|
||||
const torrent_url: string | null = req.body.torrent_url ?? null
|
||||
const magnet : string | null = req.body.magnet ?? null
|
||||
const url = torrent_url ?? magnet
|
||||
if (!url) { res.status(400).json({ error: 'Lien du torrent ou magnet requis' }); return }
|
||||
const file_index: number | null = req.body.file_index != null ? Number(req.body.file_index) : null
|
||||
const file_path : string | null = req.body.file_path ?? null
|
||||
const infohash : string | null = req.body.infohash ?? null
|
||||
logger.info('api', `Téléchargement demandé via ${torrent_url ? '.torrent' : 'magnet'}${file_path ? ` (${file_path})` : ''}`)
|
||||
const results = await dispatchDownload(url, { file_index, file_path, infohash, magnet })
|
||||
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
|
||||
})
|
||||
|
||||
router.delete('/torrent/:hash', requireAuth, async (req, res) => {
|
||||
const hash = String(req.params.hash)
|
||||
const deleteFiles = req.query.deleteFiles === 'true'
|
||||
if (!hash) { res.status(400).json({ error: 'Hash du torrent requis' }); return }
|
||||
try {
|
||||
const result = await dispatchRemove(hash, deleteFiles)
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
logger.error('api', `Échec de la suppression du torrent : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/downloads', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const infohashMap = await readInfohashMap()
|
||||
const torrents = await dispatchList(category ?? 'fankai', infohashMap)
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const enriched = await Promise.all(torrents.map(async (t: any) => {
|
||||
const orgFiles = organized[t.hash] ?? {}
|
||||
let totalFiles = 1
|
||||
let serieName: string | undefined
|
||||
let serieId: number | undefined
|
||||
let episodes: any[] = []
|
||||
try {
|
||||
const availableIds = await readAvailable()
|
||||
for (const id of availableIds) {
|
||||
const sd = await readSerieData(id)
|
||||
if (!sd) continue
|
||||
const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
|
||||
if (match) {
|
||||
const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
|
||||
totalFiles = resolved.length || 1
|
||||
serieName = sd.title ?? sd.show_title
|
||||
serieId = sd.id
|
||||
episodes = resolved
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
const doneFiles = Object.keys(orgFiles).length
|
||||
const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
|
||||
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||
const imported = recentOrganized.find(n => n.hash === t.hash && n.done > 0)
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [], importedAt: imported?.at ?? null, serieId, serieName, episodes }
|
||||
}))
|
||||
res.json(enriched)
|
||||
} catch (err) {
|
||||
logger.error('api', `Échec du chargement des téléchargements : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,743 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { requireAuth, requireAdmin } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { getGitlabTitle } from '../gitlab-map.js'
|
||||
import {
|
||||
resolveEpNaming, computeExpectedName, resolveSerieData, loadCatalog, loadCatalogStatus,
|
||||
serieFolderName, seasonFolderName, fetchSerieFromApi,
|
||||
} from '../lib/serie-helpers.js'
|
||||
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
|
||||
import { dispatchRemove } from '../torrent-clients/index.js'
|
||||
import { readRequests, deleteRequest } from '../requests.js'
|
||||
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from '../lib/organized-store.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const serieNotFound = (id: unknown) => `Série ${id} introuvable dans le catalogue Fankai. Synchronisez le catalogue ou consultez les journaux.`
|
||||
const ORGANIZED_UNREADABLE = 'Fichier de suivi des imports (organized.json) illisible'
|
||||
const UNEXPECTED_ERROR = 'Erreur inattendue, consultez les journaux'
|
||||
|
||||
// Entrée d'un épisode sous n'importe quel hash (torrent absent des paths du scraper)
|
||||
function findEntry(organized: Organized, episodeId: number): { hash: string; entry: any } | null {
|
||||
for (const [hash, eps] of Object.entries(organized))
|
||||
if (eps[String(episodeId)]) return { hash, entry: eps[String(episodeId)] }
|
||||
return null
|
||||
}
|
||||
|
||||
// Entrées dont l'épisode n'existe plus dans le catalogue
|
||||
function findOrphans(organized: Organized, catalog: any[]) {
|
||||
const known = new Set<string>()
|
||||
for (const sd of catalog)
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? []) known.add(String(ep.id))
|
||||
const orphans: { hash: string; episode_id: number; season: number | null; episode: number | null; dest_path: string | null }[] = []
|
||||
for (const [hash, eps] of Object.entries(organized))
|
||||
for (const [episodeId, entry] of Object.entries(eps))
|
||||
if (!known.has(episodeId))
|
||||
orphans.push({ hash, episode_id: Number(episodeId), season: entry?.season ?? null, episode: entry?.episode ?? null, dest_path: entry?.dest_path ?? null })
|
||||
return orphans
|
||||
}
|
||||
|
||||
// Épisodes publiés sur l'API mais écartés par le scraper : pas des orphelins. null si l'API ne répond pas
|
||||
async function apiOnlyEpisodes(orphans: ReturnType<typeof findOrphans>, catalog: any[], mediaPath: string): Promise<Set<string> | null> {
|
||||
const idByFolder = new Map(catalog.map(sd => [serieFolderName(sd.title ?? sd.show_title ?? ''), sd.id]))
|
||||
const serieIds = new Set<number>()
|
||||
for (const o of orphans) {
|
||||
if (!o.dest_path || !mediaPath) continue
|
||||
const id = idByFolder.get(path.relative(mediaPath, o.dest_path).split(path.sep)[0])
|
||||
if (id != null) serieIds.add(id)
|
||||
}
|
||||
try {
|
||||
const series = await Promise.all([...serieIds].map(fetchSerieFromApi))
|
||||
return new Set(series.flatMap(sd => (sd.seasons ?? []).flatMap((s: any) => (s.episodes ?? []).map((ep: any) => String(ep.id)))))
|
||||
} catch (err) {
|
||||
logger.warn('api', `Épisodes disparus du catalogue : vérification sur l'API Fankai impossible (${err instanceof Error ? err.message : err})`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Dossiers série (enfants directs de la médiathèque) contenant les fichiers suivis d'une série
|
||||
function serieFolders(sd: any, organized: Organized, mediaPath: string, exclude = new Set<string>()): Map<string, number> {
|
||||
const ids = new Set<string>()
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? []) ids.add(String(ep.id))
|
||||
const folders = new Map<string, number>()
|
||||
if (!mediaPath) return folders
|
||||
for (const eps of Object.values(organized)) {
|
||||
for (const [episodeId, entry] of Object.entries(eps)) {
|
||||
if (!ids.has(episodeId) || !entry?.dest_path) continue
|
||||
const parts = path.relative(mediaPath, entry.dest_path).split(path.sep)
|
||||
if (parts.length < 2 || parts[0] === '..' || path.isAbsolute(parts[0])) continue
|
||||
const folder = path.join(mediaPath, parts[0])
|
||||
if (exclude.has(folder)) continue
|
||||
folders.set(folder, (folders.get(folder) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return folders
|
||||
}
|
||||
|
||||
function otherSerieFolders(catalog: any[], serieId: number, mediaPath: string): Set<string> {
|
||||
return new Set(catalog.filter(sd => sd.id !== serieId).map(sd => path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))))
|
||||
}
|
||||
|
||||
function listFiles(dir: string): string[] {
|
||||
const files: string[] = []
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) files.push(...listFiles(full))
|
||||
else files.push(full)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function removeEmptyDirs(dir: string): void {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true }))
|
||||
if (entry.isDirectory()) removeEmptyDirs(path.join(dir, entry.name))
|
||||
if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir)
|
||||
}
|
||||
|
||||
// ── Import manuel ──────────────────────────────────────────────
|
||||
router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
const { serie_id, items } = req.body
|
||||
if (!serie_id || !Array.isArray(items) || items.length === 0) {
|
||||
res.status(400).json({ error: 'Série ou fichiers à importer manquants' }); return
|
||||
}
|
||||
const { mediaPath, organizeMode, nfoSupport, englishDirectory } = readSettings()
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
|
||||
// Copies asynchrones : modifications réappliquées sur le fichier relu à la fin
|
||||
const ops: ((data: Organized) => void)[] = []
|
||||
const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) }
|
||||
const sd = await resolveSerieData(Number(serie_id), 'Import manuel')
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
|
||||
const serieTitle = serieFolderName(sd.title ?? sd.show_title ?? '')
|
||||
const episodeIndex = new Map<number, { ep: any; season: any }>()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
episodeIndex.set(ep.id, { ep, season })
|
||||
}
|
||||
}
|
||||
const done: number[] = []
|
||||
const errors: { file: string; error: string }[] = []
|
||||
const fail = (file: string, error: string) => {
|
||||
errors.push({ file, error })
|
||||
logger.error('api', `Échec de l'import manuel de « ${file} » : ${error}`)
|
||||
}
|
||||
for (const item of items) {
|
||||
const { file_path, episode_id, hash } = item
|
||||
if (!file_path || !episode_id) { fail(file_path ? path.basename(file_path) : '?', 'Paramètres manquants'); continue }
|
||||
const found = episodeIndex.get(Number(episode_id))
|
||||
if (!found) { fail(path.basename(file_path), `Épisode ${episode_id} introuvable dans la série ${serie_id}`); continue }
|
||||
const { ep, season } = found
|
||||
const srcFilename = path.basename(file_path)
|
||||
const srcExt = path.extname(srcFilename)
|
||||
const { formatted_name: rawFmt, nfo_filename: rawNfo, original_filename: rawOrig } = resolveEpNaming(ep, hash)
|
||||
const fmtName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null
|
||||
const resolvedName = nfoSupport
|
||||
? (rawNfo ?? rawOrig ?? srcFilename)
|
||||
: (fmtName ?? rawNfo ?? rawOrig ?? srcFilename)
|
||||
const resolvedExt = path.extname(resolvedName)
|
||||
const destFilename = resolvedExt && resolvedExt !== srcExt
|
||||
? resolvedName.slice(0, -resolvedExt.length) + srcExt
|
||||
: resolvedName
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolderName(season.season_number, englishDirectory))
|
||||
const destPath = path.join(destDir, destFilename)
|
||||
try {
|
||||
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
|
||||
|
||||
// Réassignation : retirer les entrées qui pointent ce fichier sous un autre épisode
|
||||
for (const [h, eps] of Object.entries(organized)) {
|
||||
for (const [epId, entry] of Object.entries(eps)) {
|
||||
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id))
|
||||
logger.info('api', `Import manuel : entrée obsolète de l'épisode ${epId} retirée (fichier réassigné)`)
|
||||
}
|
||||
}
|
||||
apply(data => {
|
||||
for (const eps of Object.values(data))
|
||||
for (const [epId, entry] of Object.entries(eps))
|
||||
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) delete eps[epId]
|
||||
})
|
||||
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (file_path !== destPath) {
|
||||
const serieRootPath = path.join(mediaPath, serieTitle)
|
||||
const isInSeriePath = file_path.startsWith(serieRootPath + path.sep) || file_path.startsWith(serieRootPath + '/')
|
||||
if (isInSeriePath && fs.existsSync(destPath) && destPath !== file_path) {
|
||||
fs.unlinkSync(destPath)
|
||||
logger.info('api', `Import manuel (réassignation) : suppression de l'ancien fichier « ${destFilename} »`)
|
||||
for (const eps of Object.values(organized)) {
|
||||
for (const [epId, entry] of Object.entries(eps)) {
|
||||
if (entry?.dest_path === destPath)
|
||||
logger.info('api', `Import manuel : entrée obsolète de l'épisode ${epId} retirée (ancien fichier supprimé)`)
|
||||
}
|
||||
}
|
||||
apply(data => {
|
||||
for (const eps of Object.values(data))
|
||||
for (const [epId, entry] of Object.entries(eps))
|
||||
if (entry?.dest_path === destPath) delete eps[epId]
|
||||
})
|
||||
}
|
||||
if (isInSeriePath && !fs.existsSync(destPath)) {
|
||||
fs.renameSync(file_path, destPath)
|
||||
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
|
||||
logger.info('api', `Import manuel : « ${srcFilename} » renommé en « ${destFilename} »`)
|
||||
} else if (!isInSeriePath && !fs.existsSync(destPath)) {
|
||||
if (organizeMode === 'hardlink') {
|
||||
try { fs.linkSync(file_path, destPath) }
|
||||
catch { await fs.promises.copyFile(file_path, destPath) }
|
||||
} else if (organizeMode === 'move') {
|
||||
try { fs.renameSync(file_path, destPath) }
|
||||
catch { await fs.promises.copyFile(file_path, destPath); await fs.promises.unlink(file_path) }
|
||||
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
|
||||
} else {
|
||||
await fs.promises.copyFile(file_path, destPath)
|
||||
}
|
||||
logger.info('api', `Import manuel : « ${srcFilename} » importé vers « ${destPath} »`)
|
||||
}
|
||||
} else {
|
||||
logger.debug('api', `Import manuel : « ${srcFilename} » déjà en place`)
|
||||
}
|
||||
const torrentHash = String(hash || '').toLowerCase() || 'manual'
|
||||
const entry = {
|
||||
at: new Date().toISOString(), season: season.season_number,
|
||||
episode: ep.episode_number, episode_id: ep.id,
|
||||
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
|
||||
}
|
||||
apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry })
|
||||
done.push(episode_id)
|
||||
} catch (err) {
|
||||
fail(srcFilename, err instanceof Error ? err.message : UNEXPECTED_ERROR)
|
||||
}
|
||||
}
|
||||
if (ops.length > 0) {
|
||||
try { updateOrganized(data => { for (const op of ops) op(data) }) }
|
||||
catch (err) {
|
||||
logger.error('api', `Import manuel : échec de l'enregistrement (${err instanceof Error ? err.message : err})`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return
|
||||
}
|
||||
}
|
||||
|
||||
if (nfoSupport && done.length > 0) {
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
;(async () => {
|
||||
try {
|
||||
let files: any[] = [], page = 1
|
||||
while (true) {
|
||||
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
|
||||
if (!Array.isArray(batch) || batch.length === 0) break
|
||||
files.push(...batch); if (batch.length < 100) break; page++
|
||||
}
|
||||
for (const entry of files.filter((f: any) => f.type === 'blob')) {
|
||||
const rel = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
||||
const dest = path.join(mediaPath, serieTitle, rel)
|
||||
if (fs.existsSync(dest)) continue
|
||||
const raw = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${rel.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!raw.ok) continue
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
||||
fs.writeFileSync(dest, Buffer.from(await raw.arrayBuffer()))
|
||||
}
|
||||
logger.info('api', `NFO téléchargés pour « ${gitlabTitle} » (import manuel)`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Échec du téléchargement des NFO de « ${gitlabTitle} » :${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
res.json({ ok: true, done: done.length, errors })
|
||||
})
|
||||
|
||||
// ── Épisodes importés d'une série ──────────────────────────────
|
||||
router.get('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
const serieId = Number(req.params.serieId)
|
||||
try {
|
||||
const sd = await resolveSerieData(serieId)
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
|
||||
const organized = readOrganized()
|
||||
const { nfoSupport } = readSettings()
|
||||
const result: Record<string, any> = {}
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
let entry: any = null
|
||||
let entryHash = 'manual'
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p !== 'object' || !p.infohash) continue
|
||||
const h = p.infohash.toLowerCase()
|
||||
const e = (organized[h] ?? {})[String(ep.id)]
|
||||
if (e) { entry = e; entryHash = h; break }
|
||||
}
|
||||
if (!entry && organized['manual']?.[String(ep.id)]) {
|
||||
entry = organized['manual'][String(ep.id)]
|
||||
}
|
||||
if (!entry) {
|
||||
const found = findEntry(organized, ep.id)
|
||||
if (found) { entry = found.entry; entryHash = found.hash }
|
||||
}
|
||||
if (!entry) continue
|
||||
|
||||
const { needsRename } = computeExpectedName(ep, entry, entryHash, nfoSupport)
|
||||
result[String(ep.id)] = { ...entry, hash: entryHash, needs_rename: needsRename }
|
||||
}
|
||||
}
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Renommage d'un épisode ─────────────────────────────────────
|
||||
router.post('/rename-episode', requireAuth, async (req, res) => {
|
||||
const { serie_id, episode_id, torrent_hash } = req.body
|
||||
if (!serie_id || !episode_id) { res.status(400).json({ error: 'Série ou épisode manquant' }); return }
|
||||
const { nfoSupport } = readSettings()
|
||||
const sd = await resolveSerieData(Number(serie_id), 'Renommage')
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
|
||||
let foundEp: any = null
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (ep.id === Number(episode_id)) { foundEp = { ep, season }; break }
|
||||
}
|
||||
if (foundEp) break
|
||||
}
|
||||
if (!foundEp) { res.status(404).json({ error: 'Épisode introuvable' }); return }
|
||||
const { ep, season } = foundEp
|
||||
const hash = torrent_hash?.toLowerCase() ?? 'manual'
|
||||
let orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)]
|
||||
let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
|
||||
if (!orgEntry) {
|
||||
const found = findEntry(organized, Number(episode_id))
|
||||
if (found) { orgEntry = found.entry; entryHash = found.hash }
|
||||
}
|
||||
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
|
||||
const { currentName, expectedName: newName, needsRename } = computeExpectedName(ep, orgEntry, entryHash, nfoSupport)
|
||||
if (!needsRename) { res.json({ ok: true, renamed: false, message: 'Nom déjà correct' }); return }
|
||||
const oldPath = orgEntry.dest_path
|
||||
const newPath = path.join(path.dirname(oldPath), newName)
|
||||
try {
|
||||
if (!fs.existsSync(oldPath)) throw new Error('Fichier introuvable sur le disque')
|
||||
if (fs.existsSync(newPath)) throw new Error(`Un fichier nommé « ${newName} » existe déjà`)
|
||||
fs.renameSync(oldPath, newPath)
|
||||
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() }
|
||||
retargetEntries(organized, new Map([[oldPath, newPath]]))
|
||||
writeOrganized(organized)
|
||||
logger.info('api', `Renommage : « ${orgEntry.dest_filename} » en « ${newName} »`)
|
||||
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
|
||||
logger.error('api', `Échec du renommage de l'épisode ${episode_id} : ${msg}`)
|
||||
res.status(500).json({ error: msg })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Retrait d'une série ────────────────────────────────────────
|
||||
router.delete('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
const serieId = String(req.params.serieId)
|
||||
const deleteFile = req.query.deleteFile === 'true'
|
||||
const sd = await resolveSerieData(Number(serieId), 'Retrait de la série')
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
|
||||
const episodeIds = new Set<string>()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id))
|
||||
}
|
||||
let removed = 0
|
||||
const errors: string[] = []
|
||||
const emptyHashes: string[] = []
|
||||
let serieFolder: string | null = null
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const epId of Object.keys(episodes)) {
|
||||
if (!episodeIds.has(epId)) continue
|
||||
const entry = episodes[epId]
|
||||
if (!serieFolder && entry) {
|
||||
const dir = entry.dest_dir ?? path.dirname(entry.dest_path)
|
||||
serieFolder = path.dirname(dir)
|
||||
}
|
||||
if (deleteFile && entry?.dest_path && fs.existsSync(entry.dest_path)) {
|
||||
try { fs.unlinkSync(entry.dest_path) } catch { errors.push(entry.dest_path) }
|
||||
}
|
||||
delete organized[hash][epId]
|
||||
removed++
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
|
||||
}
|
||||
writeOrganized(organized)
|
||||
if (deleteFile) {
|
||||
for (const hash of emptyHashes) {
|
||||
if (hash === 'manual') continue
|
||||
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
|
||||
}
|
||||
if (serieFolder && fs.existsSync(serieFolder)) {
|
||||
try {
|
||||
fs.rmSync(serieFolder, { recursive: true, force: true })
|
||||
logger.info('api', `Dossier de la série supprimé : ${serieFolder}`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Impossible de supprimer le dossier de la série « ${serieFolder} » :${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const completedRequests = readRequests().filter(r => r.serieId === Number(serieId) && r.status === 'completed')
|
||||
for (const r of completedRequests) {
|
||||
try { deleteRequest(r.id) } catch {}
|
||||
}
|
||||
if (completedRequests.length > 0)
|
||||
logger.info('api', `Retrait de la série ${serieId} : ${completedRequests.length} demande(s) « disponible » supprimée(s)`)
|
||||
|
||||
logger.info('api', `Retrait de la série ${serieId} : ${removed} épisode(s) retiré(s)${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retiré(s) du client` : ''}`)
|
||||
res.json({ ok: true, removed, errors })
|
||||
})
|
||||
|
||||
// ── Retrait d'une saison ───────────────────────────────────────
|
||||
router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, res) => {
|
||||
const serieId = String(req.params.serieId)
|
||||
const seasonId = Number(req.params.seasonId)
|
||||
const deleteFile = req.query.deleteFile === 'true'
|
||||
const sd = await resolveSerieData(Number(serieId), 'Retrait de la saison')
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
|
||||
const season = sd.seasons?.find((s: any) => s.id === seasonId)
|
||||
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
|
||||
const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.id)) ?? [])
|
||||
let removed = 0
|
||||
const errors: string[] = []
|
||||
const emptyHashes: string[] = []
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const epId of Object.keys(episodes)) {
|
||||
if (!episodeIds.has(epId)) continue
|
||||
const entry = episodes[epId]
|
||||
if (deleteFile && entry?.dest_path && fs.existsSync(entry.dest_path)) {
|
||||
try { fs.unlinkSync(entry.dest_path) } catch { errors.push(entry.dest_path) }
|
||||
}
|
||||
delete organized[hash][epId]
|
||||
removed++
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
|
||||
}
|
||||
writeOrganized(organized)
|
||||
if (deleteFile) {
|
||||
for (const hash of emptyHashes) {
|
||||
if (hash === 'manual') continue
|
||||
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
|
||||
}
|
||||
}
|
||||
logger.info('api', `Retrait de la saison ${seasonId} (série ${serieId}) : ${removed} épisode(s) retiré(s)${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retiré(s) du client` : ''}`)
|
||||
res.json({ ok: true, removed, errors })
|
||||
})
|
||||
|
||||
// ── Retrait d'un épisode ───────────────────────────────────────
|
||||
router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
|
||||
const episodeId = String(req.params.episodeId)
|
||||
const deleteFile = req.query.deleteFile === 'true'
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
|
||||
const found: { hash: string; entry: any }[] = []
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
if (episodes[episodeId]) found.push({ hash, entry: episodes[episodeId] })
|
||||
}
|
||||
if (found.length === 0) { res.status(404).json({ error: 'Épisode non importé' }); return }
|
||||
try {
|
||||
const deletedPaths = new Set<string>()
|
||||
if (deleteFile) {
|
||||
for (const { entry } of found) {
|
||||
const p = entry?.dest_path
|
||||
if (!p || deletedPaths.has(p) || !fs.existsSync(p)) continue
|
||||
fs.unlinkSync(p)
|
||||
deletedPaths.add(p)
|
||||
logger.info('api', `Retrait et suppression du fichier « ${p} »`)
|
||||
}
|
||||
}
|
||||
const emptyHashes: string[] = []
|
||||
for (const { hash } of found) {
|
||||
delete organized[hash][episodeId]
|
||||
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
|
||||
}
|
||||
writeOrganized(organized)
|
||||
if (deleteFile) {
|
||||
for (const hash of emptyHashes) {
|
||||
if (hash === 'manual') continue
|
||||
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
|
||||
}
|
||||
}
|
||||
const removedTorrents = deleteFile ? emptyHashes.filter(h => h !== 'manual') : []
|
||||
logger.info('api', `Retrait de l'épisode ${episodeId}${removedTorrents.length ? `, ${removedTorrents.length} torrent(s) retiré(s) du client` : ''}`)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
|
||||
logger.error('api', `Échec du retrait de l'épisode ${episodeId} : ${msg}`)
|
||||
res.status(500).json({ error: msg })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Récapitulatif des imports ──────────────────────────────────
|
||||
router.get('/organized-summary', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { nfoSupport } = readSettings()
|
||||
const { series: seriesData, complete } = await loadCatalogStatus()
|
||||
const organized = readOrganized()
|
||||
const result: any[] = []
|
||||
for (const sd of seriesData) {
|
||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||
const serieTitle = serieFolderName(rawTitle)
|
||||
const episodes: any[] = []
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
let orgEntry: any = null
|
||||
let orgHash: string | null = null
|
||||
// Chercher d'abord via ep.paths : le hash détermine le formatted_name
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p !== 'object' || !p.infohash) continue
|
||||
const h = p.infohash.toLowerCase()
|
||||
const e = (organized[h] ?? {})[String(ep.id)]
|
||||
if (e) { orgEntry = e; orgHash = h; break }
|
||||
}
|
||||
if (!orgEntry && organized['manual']?.[String(ep.id)]) {
|
||||
orgEntry = organized['manual'][String(ep.id)]; orgHash = 'manual'
|
||||
}
|
||||
if (!orgEntry) {
|
||||
const found = findEntry(organized, ep.id)
|
||||
if (found) { orgEntry = found.entry; orgHash = found.hash }
|
||||
}
|
||||
if (!orgEntry) continue
|
||||
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
|
||||
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
|
||||
episodes.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, title: ep.title, current_name: currentName, expected_name: expectedName, dest_path: orgEntry.dest_path, torrent_hash: orgHash, needs_rename: needsRename, file_exists: fileExists })
|
||||
}
|
||||
}
|
||||
if (episodes.length > 0) {
|
||||
result.push({ serie_id: sd.id, serie_title: rawTitle, serie_title_clean: serieTitle, total: episodes.length, needs_rename: episodes.filter(e => e.needs_rename).length, episodes })
|
||||
}
|
||||
}
|
||||
const candidates = complete ? findOrphans(organized, seriesData) : []
|
||||
const apiOnly = candidates.length > 0 ? await apiOnlyEpisodes(candidates, seriesData, readSettings().mediaPath) : new Set<string>()
|
||||
const orphans = (apiOnly ? candidates : [])
|
||||
.filter(o => !apiOnly?.has(String(o.episode_id)))
|
||||
.map(o => ({ ...o, file_exists: !!o.dest_path && fs.existsSync(o.dest_path) }))
|
||||
res.json({ series: result, nfo_support: nfoSupport, orphans, orphans_checked: complete && !!apiOnly })
|
||||
} catch (err) {
|
||||
logger.error('api', `Échec du récapitulatif des imports : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Retrait des épisodes disparus du catalogue ─────────────────
|
||||
router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => {
|
||||
const only = Array.isArray(req.body?.episode_ids) ? new Set(req.body.episode_ids.map(String)) : null
|
||||
const { series, complete } = await loadCatalogStatus()
|
||||
const catalogIncomplete = 'Catalogue incomplet (scraper GitHub ou API Fankai injoignable), réessayez plus tard'
|
||||
if (!complete) { res.status(503).json({ error: catalogIncomplete }); return }
|
||||
let apiOnly: Set<string> | null
|
||||
try { apiOnly = await apiOnlyEpisodes(findOrphans(readOrganized(), series), series, readSettings().mediaPath) }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
|
||||
if (!apiOnly) { res.status(503).json({ error: catalogIncomplete }); return }
|
||||
let removed = 0
|
||||
try {
|
||||
updateOrganized(data => {
|
||||
for (const o of findOrphans(data, series)) {
|
||||
if (only && !only.has(String(o.episode_id))) continue
|
||||
if (apiOnly.has(String(o.episode_id))) continue
|
||||
delete data[o.hash][String(o.episode_id)]
|
||||
removed++
|
||||
}
|
||||
return removed > 0
|
||||
})
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return
|
||||
}
|
||||
logger.info('api', `Épisodes disparus du catalogue retirés du suivi : ${removed}`)
|
||||
res.json({ ok: true, removed })
|
||||
})
|
||||
|
||||
// ── Dossier de série ───────────────────────────────────────────
|
||||
router.get('/organized-folders', requireAdmin, async (_req, res) => {
|
||||
const { mediaPath } = readSettings()
|
||||
if (!mediaPath) { res.json([]); return }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
|
||||
try {
|
||||
const result: { serie_id: number; serie_title: string; expected: string; current: string[] }[] = []
|
||||
const catalog = await loadCatalog()
|
||||
for (const sd of catalog) {
|
||||
const title = sd.title ?? sd.show_title ?? ''
|
||||
const expected = path.join(mediaPath, serieFolderName(title))
|
||||
const stale = [...serieFolders(sd, organized, mediaPath, otherSerieFolders(catalog, sd.id, mediaPath)).keys()].filter(f => f !== expected && fs.existsSync(f))
|
||||
if (stale.length > 0) result.push({ serie_id: sd.id, serie_title: title, expected, current: stale })
|
||||
}
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
logger.error('api', `Échec de la vérification des dossiers de séries : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/organized/:serieId/folder', requireAuth, async (req, res) => {
|
||||
const serieId = Number(req.params.serieId)
|
||||
const sd = await resolveSerieData(serieId)
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
|
||||
const { mediaPath } = readSettings()
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
|
||||
const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
|
||||
const others = otherSerieFolders(await loadCatalog().catch(() => []), serieId, mediaPath)
|
||||
const current = [...serieFolders(sd, organized, mediaPath, others)].map(([folder, entries]) => ({ path: folder, entries, exists: fs.existsSync(folder) }))
|
||||
res.json({ expected, current, needs_rename: current.some(f => f.exists && f.path !== expected) })
|
||||
})
|
||||
|
||||
router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => {
|
||||
const serieId = Number(req.params.serieId)
|
||||
const sd = await resolveSerieData(serieId, 'Renommage du dossier')
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
|
||||
const { mediaPath } = readSettings()
|
||||
if (!mediaPath) { res.status(400).json({ error: 'Chemin de la médiathèque non configuré' }); return }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
|
||||
let catalog: any[]
|
||||
try { catalog = await loadCatalog() }
|
||||
catch (err) { res.status(503).json({ error: `Catalogue indisponible : ${err instanceof Error ? err.message : err}` }); return }
|
||||
const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
|
||||
const sources = [...serieFolders(sd, organized, mediaPath, otherSerieFolders(catalog, serieId, mediaPath)).keys()].filter(f => f !== expected && fs.existsSync(f))
|
||||
if (sources.length === 0) { res.json({ ok: true, moved: 0, updated: 0 }); return }
|
||||
|
||||
// Tout le contenu (vidéos, NFO, images…) est fusionné dans le dossier attendu, sans jamais écraser
|
||||
const moves = new Map<string, string>()
|
||||
for (const src of sources)
|
||||
for (const file of listFiles(src)) moves.set(file, path.join(expected, path.relative(src, file)))
|
||||
const targets = [...moves.values()]
|
||||
const conflicts = targets.filter((to, i) => fs.existsSync(to) || targets.indexOf(to) !== i)
|
||||
if (conflicts.length > 0) {
|
||||
res.status(409).json({ error: `${conflicts.length} fichier(s) existe(nt) déjà dans « ${path.basename(expected)} »`, conflicts: conflicts.slice(0, 10) }); return
|
||||
}
|
||||
|
||||
const done = new Map<string, string>()
|
||||
let failure: string | null = null
|
||||
for (const [from, to] of moves) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true })
|
||||
fs.renameSync(from, to)
|
||||
done.set(from, to)
|
||||
} catch (err) {
|
||||
failure = `${path.basename(from)} : ${err instanceof Error ? err.message : err}`
|
||||
break
|
||||
}
|
||||
}
|
||||
for (const src of sources) {
|
||||
try { removeEmptyDirs(src) } catch {}
|
||||
}
|
||||
let updated = 0
|
||||
try { updateOrganized(data => { updated = retargetEntries(data, done); return updated > 0 }) }
|
||||
catch (err) {
|
||||
logger.error('api', `Renommage du dossier de la série ${serieId} : fichiers déplacés mais suivi non mis à jour (${err instanceof Error ? err.message : err})`)
|
||||
res.status(500).json({ error: 'Fichiers déplacés mais suivi non mis à jour, lancez une analyse de la médiathèque' }); return
|
||||
}
|
||||
const label = `« ${sources.map(s => path.basename(s)).join(' », « ')} » en « ${path.basename(expected)} »`
|
||||
if (failure) {
|
||||
logger.error('api', `Renommage du dossier ${label} interrompu après ${done.size} fichier(s) : ${failure}`)
|
||||
res.status(500).json({ error: `Déplacement interrompu après ${done.size} fichier(s) : ${failure}`, moved: done.size, updated }); return
|
||||
}
|
||||
logger.info('api', `Dossier de série renommé : ${label} (${done.size} fichier(s), ${updated} entrée(s))`)
|
||||
res.json({ ok: true, moved: done.size, updated })
|
||||
})
|
||||
|
||||
// ── Renommage groupé ───────────────────────────────────────────
|
||||
router.post('/rename-all', requireAdmin, async (req, res) => {
|
||||
const { serie_id, serie_ids } = req.body
|
||||
const onlyIds = Array.isArray(serie_ids) ? new Set(serie_ids.map(Number)) : serie_id ? new Set([Number(serie_id)]) : null
|
||||
const { nfoSupport } = readSettings()
|
||||
const seriesData = await loadCatalog()
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
|
||||
const done: number[] = []
|
||||
const errors: { episode_id: number; error: string }[] = []
|
||||
for (const sd of seriesData) {
|
||||
if (onlyIds && !onlyIds.has(sd.id)) continue
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
let orgEntry: any = null
|
||||
let orgHash: string | null = null
|
||||
for (const [hash, eps] of Object.entries(organized)) {
|
||||
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
|
||||
}
|
||||
if (!orgEntry || !orgHash) continue
|
||||
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
|
||||
if (!needsRename) continue
|
||||
const oldPath = orgEntry.dest_path
|
||||
const newPath = path.join(path.dirname(oldPath), expectedName)
|
||||
try {
|
||||
if (!fs.existsSync(oldPath)) {
|
||||
const msg = `Fichier introuvable : « ${oldPath} »`
|
||||
logger.error('api', `Renommage groupé, épisode ${ep.id} : ${msg}`)
|
||||
errors.push({ episode_id: ep.id, error: msg }); continue
|
||||
}
|
||||
if (fs.existsSync(newPath)) {
|
||||
const msg = `Un fichier nommé « ${expectedName} » existe déjà`
|
||||
logger.warn('api', `Renommage groupé, épisode ${ep.id} : ${msg}`)
|
||||
errors.push({ episode_id: ep.id, error: msg }); continue
|
||||
}
|
||||
fs.renameSync(oldPath, newPath)
|
||||
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() }
|
||||
retargetEntries(organized, new Map([[oldPath, newPath]]))
|
||||
done.push(ep.id)
|
||||
logger.info('api', `Renommage : « ${currentName} » en « ${expectedName} »`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
|
||||
logger.error('api', `Échec du renommage de l'épisode ${ep.id} : ${msg}`)
|
||||
errors.push({ episode_id: ep.id, error: msg })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeOrganized(organized)
|
||||
res.json({ ok: true, done: done.length, errors })
|
||||
})
|
||||
|
||||
// ── Purge des NFO et images ───────────────────────────────────
|
||||
router.post('/purge-nfo', requireAuth, (req, res) => {
|
||||
const { mediaPath } = readSettings()
|
||||
if (!mediaPath || !fs.existsSync(mediaPath)) {
|
||||
res.status(400).json({ error: 'Chemin de la médiathèque non configuré ou introuvable' }); return
|
||||
}
|
||||
|
||||
const NFO_EXTS = new Set(['.nfo', '.png', '.jpg', '.jpeg', '.tbn', '.xml'])
|
||||
let deleted = 0
|
||||
const errors: string[] = []
|
||||
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(full)
|
||||
} else if (entry.isFile() && NFO_EXTS.has(path.extname(entry.name).toLowerCase())) {
|
||||
try {
|
||||
fs.unlinkSync(full)
|
||||
deleted++
|
||||
logger.debug('api', `Purge NFO : « ${full} » supprimé`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
logger.error('api', `Purge NFO : impossible de supprimer « ${full} » : ${msg}`)
|
||||
errors.push(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(mediaPath)
|
||||
logger.info('api', `Purge NFO : ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`)
|
||||
res.json({ ok: true, deleted, errors })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAdmin } from '../auth.js'
|
||||
import {
|
||||
readInvites, createInvite, deleteInvite,
|
||||
validateInvite, consumeInvite,
|
||||
} from '../invites.js'
|
||||
import { createUser, safeUser } from '../users.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// GET /api/invites (admin)
|
||||
router.get('/invites', requireAdmin, (_req, res) => {
|
||||
res.json(readInvites())
|
||||
})
|
||||
|
||||
// POST /api/invites (admin)
|
||||
router.post('/invites', requireAdmin, (req, res) => {
|
||||
const { expiresInHours, maxUses, note } = req.body
|
||||
try {
|
||||
const invite = createInvite({
|
||||
createdBy : req.user!.id,
|
||||
expiresInHours: expiresInHours ?? null,
|
||||
maxUses,
|
||||
note,
|
||||
})
|
||||
res.json(invite)
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/invites/:code (admin)
|
||||
router.delete('/invites/:code', requireAdmin, (req, res) => {
|
||||
try {
|
||||
deleteInvite(String(req.params.code))
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/invites/:code/info (public : page d'inscription)
|
||||
router.get('/invites/:code/info', (req, res) => {
|
||||
const result = validateInvite(req.params.code)
|
||||
if (!result.valid) {
|
||||
res.status(400).json({ valid: false, reason: result.reason }); return
|
||||
}
|
||||
res.json({ valid: true, note: result.invite.note ?? null })
|
||||
})
|
||||
|
||||
// POST /api/invites/:code/register (public : inscription par invitation)
|
||||
router.post('/invites/:code/register', (req, res) => {
|
||||
const result = validateInvite(req.params.code)
|
||||
if (!result.valid) {
|
||||
res.status(400).json({ error: result.reason }); return
|
||||
}
|
||||
|
||||
const { username, password } = req.body
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
res.status(400).json({ error: 'Le mot de passe doit contenir au moins 6 caractères' }); return
|
||||
}
|
||||
|
||||
try {
|
||||
const user = createUser(username, password, 'user')
|
||||
consumeInvite(req.params.code)
|
||||
res.json({ success: true, user: safeUser(user) })
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Router } from 'express'
|
||||
import { readSettings, writeSettings } from '../settings.js'
|
||||
import { readUsers, createUser, safeUser } from '../users.js'
|
||||
import { testJellyfinConnection, fetchJellyfinUsers } from '../lib/jellyfin.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/jellyfin/settings', (_req, res) => {
|
||||
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
|
||||
res.json({
|
||||
jellyfinUrl,
|
||||
// Clé API jamais renvoyée : seulement sa présence
|
||||
hasToken: !!jellyfinAdminToken,
|
||||
})
|
||||
})
|
||||
|
||||
router.post('/jellyfin/settings', (req, res) => {
|
||||
const { jellyfinUrl, jellyfinAdminToken } = req.body
|
||||
const update: Record<string, string> = {}
|
||||
if (jellyfinUrl !== undefined) update.jellyfinUrl = String(jellyfinUrl).trim()
|
||||
if (jellyfinAdminToken !== undefined) update.jellyfinAdminToken = String(jellyfinAdminToken).trim()
|
||||
const updated = writeSettings(update)
|
||||
res.json({ jellyfinUrl: updated.jellyfinUrl, hasToken: !!updated.jellyfinAdminToken })
|
||||
})
|
||||
|
||||
router.post('/jellyfin/test', async (req, res) => {
|
||||
const settings = readSettings()
|
||||
|
||||
const jellyfinUrl = req.body?.jellyfinUrl || settings.jellyfinUrl
|
||||
const jellyfinAdminToken = req.body?.jellyfinAdminToken || settings.jellyfinAdminToken
|
||||
|
||||
if (!jellyfinUrl || !jellyfinAdminToken) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: 'URL et clé API requises'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await testJellyfinConnection(
|
||||
jellyfinUrl,
|
||||
jellyfinAdminToken
|
||||
)
|
||||
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
export async function runJellyfinSync(): Promise<{ created: number; skipped: number; users: string[] }> {
|
||||
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
|
||||
if (!jellyfinUrl || !jellyfinAdminToken) return { created: 0, skipped: 0, users: [] }
|
||||
|
||||
const jellyfinUsers = await fetchJellyfinUsers()
|
||||
const fankarrUsers = readUsers()
|
||||
const results = { created: 0, skipped: 0, users: [] as string[] }
|
||||
|
||||
for (const jUser of jellyfinUsers) {
|
||||
if (jUser.Policy?.IsDisabled) { results.skipped++; continue }
|
||||
|
||||
const exists = fankarrUsers.some(u =>
|
||||
u.username.toLowerCase() === jUser.Name.toLowerCase()
|
||||
)
|
||||
if (exists) { results.skipped++; continue }
|
||||
|
||||
const randomPass = Math.random().toString(36).slice(2, 10) +
|
||||
Math.random().toString(36).slice(2, 10)
|
||||
createUser(jUser.Name, randomPass, 'user')
|
||||
results.created++
|
||||
results.users.push(jUser.Name)
|
||||
logger.info('jellyfin', `Compte créé pour « ${jUser.Name} » (synchronisation Jellyfin)`)
|
||||
}
|
||||
|
||||
logger.info('jellyfin', `Synchronisation Jellyfin : ${results.created} compte(s) créé(s), ${results.skipped} ignoré(s) (existant ou désactivé)`)
|
||||
return results
|
||||
}
|
||||
|
||||
router.post('/jellyfin/sync', async (_req, res) => {
|
||||
try {
|
||||
const results = await runJellyfinSync()
|
||||
res.json(results)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Échec de la synchronisation Jellyfin' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { checkNfoUpdates, recentNfoUpdates } from '../lib/nfo.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/nfo-updates/recent', requireAuth, (_req, res) => {
|
||||
res.json(recentNfoUpdates)
|
||||
})
|
||||
|
||||
router.post('/nfo-updates/check', requireAuth, async (_req, res) => {
|
||||
logger.info('nfo-update', 'Vérification manuelle des mises à jour NFO lancée')
|
||||
checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification manuelle des mises à jour NFO : ${err instanceof Error ? err.message : err}`))
|
||||
res.json({ ok: true, message: 'Vérification lancée en arrière-plan' })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
|
||||
import { loadCatalog } from '../lib/serie-helpers.js'
|
||||
import { recentOrganized, pushNotif, clearNotifs } from '../lib/notifs.js'
|
||||
import { dispatchGetFiles } from '../torrent-clients/index.js'
|
||||
import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule } from '../lib/schedule.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import path from 'path'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const seriesData = await loadCatalog()
|
||||
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
||||
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
logger.error('api', `Échec de la migration des IDs : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/organize/recent', requireAuth, (_req, res) => {
|
||||
res.json(recentOrganized.slice(0, 20))
|
||||
})
|
||||
|
||||
router.get('/organize/schedule', requireAuth, (_req, res) => {
|
||||
res.json({ autoImport: readSettings().autoImport, intervalMs: AUTO_IMPORT_INTERVAL_MS, ...autoImportSchedule })
|
||||
})
|
||||
|
||||
router.post('/organize/recent/clear', requireAuth, (_req, res) => {
|
||||
clearNotifs()
|
||||
logger.info('api', 'Historique des imports effacé')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.post('/organize', requireAuth, async (req, res) => {
|
||||
const { hash, name, save_path } = req.body
|
||||
if (!hash || !name || !save_path) { res.status(400).json({ error: 'Hash, nom et dossier du torrent requis' }); return }
|
||||
try {
|
||||
const seriesData = await loadCatalog()
|
||||
|
||||
// Progression par fichier, facultative (client injoignable toléré)
|
||||
const files = await dispatchGetFiles(hash).catch(() => [])
|
||||
|
||||
const result = await organizeTorrent(hash, name, save_path, seriesData, files)
|
||||
if (result.done > 0 || result.errors.length > 0) {
|
||||
pushNotif({ hash, name, serieId: result.serieId ?? null, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
||||
}
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
logger.error('api', `Échec de l'import manuel de « ${name} » : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Router } from 'express'
|
||||
import { Agent, fetch as undiciFetch } from 'undici'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const PLEX_TV_API = 'https://plex.tv/api/v2'
|
||||
|
||||
// ── Serveur Plex local : certificat non vérifié ───────────────────────────────
|
||||
function getServerFetch(serverUrl: string) {
|
||||
return (url: string, opts: any = {}) =>
|
||||
undiciFetch(url, {
|
||||
...opts,
|
||||
dispatcher: new Agent({ connect: { rejectUnauthorized: false } }),
|
||||
})
|
||||
}
|
||||
|
||||
async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Accept' : 'application/json',
|
||||
'X-Plex-Client-Identifier': 'fankarr',
|
||||
'X-Plex-Product' : 'FanKarr',
|
||||
'X-Plex-Version' : '1.0',
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
logger.debug('plex', `plex.tv HTTP ${res.status} : ${await res.text().catch(() => '')}`)
|
||||
throw new Error(`Plex a répondu HTTP ${res.status}`)
|
||||
}
|
||||
const text = await res.text()
|
||||
return text ? JSON.parse(text) : {}
|
||||
}
|
||||
|
||||
function mapServers(resources: any[]) {
|
||||
return (Array.isArray(resources) ? resources : [])
|
||||
.filter((r: any) => r.product === 'Plex Media Server')
|
||||
.map((r: any) => ({
|
||||
name : r.name,
|
||||
owned : r.owned,
|
||||
connections: (r.connections ?? []).map((c: any) => ({ uri: c.uri, local: c.local, relay: c.relay })),
|
||||
}))
|
||||
}
|
||||
|
||||
router.post('/plex/connect', requireAuth, async (req, res) => {
|
||||
const { username, password, code } = req.body
|
||||
if (!username || !password) { res.status(400).json({ error: 'Email et mot de passe requis' }); return }
|
||||
try {
|
||||
const params = new URLSearchParams({ login: username, password })
|
||||
if (code) params.set('verificationCode', code)
|
||||
const authData = await plexFetch(`${PLEX_TV_API}/users/signin`, {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body : params.toString(),
|
||||
})
|
||||
const token = authData.authToken ?? authData.user?.authToken
|
||||
if (!token) throw new Error('Plex n\'a pas renvoyé de jeton d\'accès')
|
||||
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
|
||||
const servers = mapServers(resources)
|
||||
logger.info('plex', `Connexion Plex réussie pour ${username} (${servers.length} serveur(s))`)
|
||||
res.json({ ok: true, token, servers })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
if (msg.includes('401')) {
|
||||
res.status(401).json({ error: 'Identifiants incorrects, ou code de validation en deux étapes requis', requires2FA: true })
|
||||
} else {
|
||||
logger.error('plex', `Échec de l'authentification Plex : ${msg}`)
|
||||
res.status(401).json({ error: 'Échec de l\'authentification : vérifiez vos identifiants' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/plex/oauth/start', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const data = await plexFetch(`${PLEX_TV_API}/pins`, {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body : new URLSearchParams({ strong: 'true', 'X-Plex-Client-Identifier': 'fankarr' }).toString(),
|
||||
})
|
||||
const pinId = data.id
|
||||
const pinCode = data.code
|
||||
if (!pinId || !pinCode) throw new Error('Plex n\'a pas renvoyé de code PIN')
|
||||
const authUrl = `https://app.plex.tv/auth#?clientID=fankarr&code=${pinCode}&context%5Bdevice%5D%5Bproduct%5D=FanKarr&forwardUrl=https%3A%2F%2Fplex.tv`
|
||||
logger.info('plex', `Connexion Plex démarrée (PIN ${pinId})`)
|
||||
res.json({ ok: true, pinId, pinCode, authUrl })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
logger.error('plex', `Échec du démarrage de la connexion Plex : ${msg}`)
|
||||
res.status(500).json({ error: msg })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
|
||||
const pinId = String(req.params.pinId)
|
||||
try {
|
||||
const data = await plexFetch(`${PLEX_TV_API}/pins/${pinId}?X-Plex-Client-Identifier=fankarr`)
|
||||
const token = data.authToken
|
||||
if (!token) { res.json({ ok: false, pending: true }); return }
|
||||
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
|
||||
const servers = mapServers(resources)
|
||||
logger.info('plex', `Connexion Plex réussie (${servers.length} serveur(s))`)
|
||||
res.json({ ok: true, pending: false, token, servers })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
res.status(500).json({ error: msg })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/plex/setup', requireAuth, async (req, res) => {
|
||||
const { token, serverUrl, libraryName, libraryPath } = req.body
|
||||
if (!token || !serverUrl || !libraryName || !libraryPath) {
|
||||
res.status(400).json({ error: 'Jeton Plex, serveur, nom et dossier de la bibliothèque requis' }); return
|
||||
}
|
||||
|
||||
const serverFetch = getServerFetch(serverUrl)
|
||||
|
||||
const headers: Record<string, string> = { 'X-Plex-Token': token, 'Accept': 'application/json' }
|
||||
const TARGET_AGENT_URI = 'https://metadata.fankai.fr/plex'
|
||||
const steps: { step: string; ok: boolean; message: string }[] = []
|
||||
let agentIdentifier = 'tv.plex.agents.custom.fankai'
|
||||
let groupId: string | null = null
|
||||
let agentSetupOk = false
|
||||
|
||||
// ── Étape 1 : enregistrer l'agent Fankai ─────────────────
|
||||
try {
|
||||
const providersRes = await serverFetch(`${serverUrl}/media/providers/metadata`, { headers })
|
||||
if (providersRes.ok) {
|
||||
const data = await providersRes.json() as any
|
||||
const providers: any[] = data?.MediaContainer?.MetadataAgentProvider ?? []
|
||||
let provider = providers.find((p: any) => p.uri === TARGET_AGENT_URI)
|
||||
if (!provider) {
|
||||
const r = await serverFetch(`${serverUrl}/media/providers/metadata?uri=${encodeURIComponent(TARGET_AGENT_URI)}`, { method: 'POST', headers })
|
||||
if (r.ok) provider = ((await r.json()) as any)?.MediaContainer?.MetadataAgentProvider?.[0]
|
||||
}
|
||||
if (provider) {
|
||||
agentIdentifier = provider.identifier ?? agentIdentifier
|
||||
steps.push({ step: 'provider', ok: true, message: `Agent Fankai enregistré (${agentIdentifier})` })
|
||||
}
|
||||
const groupsRes = await serverFetch(`${serverUrl}/media/providers/metadata/group`, { headers })
|
||||
if (groupsRes.ok) {
|
||||
const gdata = await groupsRes.json() as any
|
||||
const groups: any[] = gdata?.MediaContainer?.MetadataAgentProviderGroup ?? []
|
||||
let group = groups.find((g: any) => g.primaryIdentifier === agentIdentifier)
|
||||
if (!group) {
|
||||
const r = await serverFetch(
|
||||
`${serverUrl}/media/providers/metadata/group?title=Fankai&primaryIdentifier=${encodeURIComponent(agentIdentifier)}`,
|
||||
{ method: 'POST', headers },
|
||||
)
|
||||
if (r.ok) group = ((await r.json()) as any)?.MediaContainer?.MetadataAgentProviderGroup?.[0]
|
||||
}
|
||||
if (group) {
|
||||
groupId = String(group.id ?? '')
|
||||
steps.push({ step: 'group', ok: true, message: `Groupe d'agents prêt (ID : ${groupId})` })
|
||||
agentSetupOk = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const status = providersRes.status
|
||||
steps.push({ step: 'agent', ok: false, message: `Agent Fankai non configuré (HTTP ${status}, Plex antérieur à 1.43 ?) : configuration manuelle nécessaire` })
|
||||
logger.warn('plex', `Configuration de l'agent Fankai : HTTP ${status} depuis ${serverUrl}`)
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
steps.push({ step: 'agent', ok: false, message: `Agent Fankai non configuré : configuration manuelle nécessaire` })
|
||||
logger.warn('plex', `Échec de la configuration de l'agent Fankai : ${msg}`)
|
||||
}
|
||||
|
||||
// ── Étape 2 : créer la bibliothèque ──────────────────────
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
type : 'show',
|
||||
name : libraryName,
|
||||
agent : agentIdentifier,
|
||||
scanner : 'Plex TV Series',
|
||||
language: 'fr-FR',
|
||||
location: libraryPath,
|
||||
})
|
||||
if (groupId) params.set('metadataAgentProviderGroupId', groupId)
|
||||
const libRes = await serverFetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers })
|
||||
if (!libRes.ok) {
|
||||
logger.debug('plex', `Création de bibliothèque HTTP ${libRes.status} : ${await libRes.text().catch(() => '')}`)
|
||||
throw new Error(`le serveur Plex a répondu HTTP ${libRes.status}`)
|
||||
}
|
||||
steps.push({ step: 'library', ok: true, message: `Bibliothèque « ${libraryName} » créée` })
|
||||
logger.info('plex', `Bibliothèque « ${libraryName} » créée sur ${serverUrl}`)
|
||||
res.json({ ok: true, agentSetupOk, steps, manualSetup: !agentSetupOk })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
steps.push({ step: 'library', ok: false, message: `Échec de la création de la bibliothèque : ${msg}` })
|
||||
logger.error('plex', `Échec de la création de la bibliothèque : ${msg}`)
|
||||
res.status(500).json({ ok: false, steps, error: msg })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,199 @@
|
||||
|
||||
import { Router } from 'express'
|
||||
import { findByApiToken } from '../users.js'
|
||||
import { validateJellyfinToken } from '../lib/jellyfin.js'
|
||||
import { readUsers, safeUser } from '../users.js'
|
||||
import { upsertRequest, approveRequest, getRequestsForUser } from '../requests.js'
|
||||
import { autoDownloadRequest } from './requests.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { readAvailable } from '../lib/github-cache.js'
|
||||
import { fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode } from '../lib/serie-helpers.js'
|
||||
import { logger } from '../logger.js'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ── Jeton d'API ───────────────────────────────────────────────
|
||||
|
||||
function requireApiToken(req: Request, res: Response, next: NextFunction): void {
|
||||
const header = req.headers.authorization
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Jeton d\'API requis (Authorization: Bearer <jeton>)' }); return
|
||||
}
|
||||
const user = findByApiToken(header.slice(7))
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'Jeton d\'API invalide' }); return
|
||||
}
|
||||
req.user = user
|
||||
next()
|
||||
}
|
||||
|
||||
// ── POST /api/v1/auth/jellyfin ────────────────────────────────
|
||||
// Échange un jeton Jellyfin contre un jeton FanKarr
|
||||
router.post('/v1/auth/jellyfin', async (req, res) => {
|
||||
const { jellyfinUserId, jellyfinToken } = req.body
|
||||
if (!jellyfinUserId || !jellyfinToken) {
|
||||
res.status(400).json({ error: 'jellyfinUserId et jellyfinToken requis' }); return
|
||||
}
|
||||
|
||||
const info = await validateJellyfinToken(jellyfinUserId, jellyfinToken)
|
||||
if (!info.valid) {
|
||||
res.status(401).json({ error: info.error ?? 'Échec de l\'authentification Jellyfin' }); return
|
||||
}
|
||||
|
||||
const users = readUsers()
|
||||
const fanUser = users.find(u => u.username.toLowerCase() === info.username!.toLowerCase())
|
||||
if (!fanUser) {
|
||||
logger.warn('public-api', `Connexion via Jellyfin : aucun compte FanKarr pour « ${info.username} » : synchronisation Jellyfin nécessaire`)
|
||||
res.status(404).json({
|
||||
error: `Aucun compte FanKarr pour « ${info.username} ». Demandez à l'administrateur de lancer la synchronisation Jellyfin.`,
|
||||
}); return
|
||||
}
|
||||
|
||||
logger.info('public-api', `Connexion via Jellyfin réussie pour « ${fanUser.username} »`)
|
||||
res.json({
|
||||
token : fanUser.apiToken,
|
||||
username: fanUser.username,
|
||||
role : fanUser.role,
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/v1/auth/me ───────────────────────────────────────
|
||||
router.get('/v1/auth/me', requireApiToken, (req, res) => {
|
||||
res.json(safeUser(req.user!))
|
||||
})
|
||||
|
||||
// ── GET /api/v1/series/search?q= ─────────────────────────────
|
||||
router.get('/v1/series/search', requireApiToken, async (req, res) => {
|
||||
const q = String(req.query.q ?? '').trim().toLowerCase()
|
||||
try {
|
||||
const [apiData, availableIds] = await Promise.all([
|
||||
fankaiGet('/series'),
|
||||
readAvailable(),
|
||||
])
|
||||
const availableSet = new Set<number>(availableIds)
|
||||
const all: any[] = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
|
||||
|
||||
// Demandes actives de cet utilisateur, indexées par serieId
|
||||
const activeRequests = getRequestsForUser(req.user!.id)
|
||||
.filter(r => r.status !== 'rejected' && r.status !== 'completed')
|
||||
const requestMap = new Map<number, { id: string; status: string; seasons: number[]; episodes: number[] }>()
|
||||
for (const r of activeRequests) {
|
||||
const me = r.requesters.find(rq => rq.userId === req.user!.id)
|
||||
if (!me) continue
|
||||
requestMap.set(r.serieId, {
|
||||
id : r.id,
|
||||
status : r.status,
|
||||
seasons : me.seasons,
|
||||
episodes: me.episodes ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
const results = all
|
||||
.filter(s => availableSet.has(s.id))
|
||||
.filter(s => !q || s.title?.toLowerCase().includes(q) || s.original_title?.toLowerCase().includes(q))
|
||||
.slice(0, 50)
|
||||
.map(s => ({
|
||||
id : s.id,
|
||||
title : s.title,
|
||||
original_title: s.original_title ?? null,
|
||||
image : s.poster_image ?? null,
|
||||
year : s.year ?? null,
|
||||
rating : s.rating?.value ?? null,
|
||||
description : s.plot ?? null,
|
||||
request : requestMap.get(s.id) ?? null,
|
||||
}))
|
||||
|
||||
res.json(results)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /api/v1/series/:id ────────────────────────────────────
|
||||
// Relais de l'API Fankai
|
||||
router.get('/v1/series/:id', requireApiToken, async (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Identifiant de série invalide' }); return }
|
||||
try {
|
||||
const [serieRaw, seasonsData] = await Promise.all([
|
||||
fankaiGet(`/series/${id}`),
|
||||
fankaiGet(`/series/${id}/seasons`),
|
||||
])
|
||||
const serie = normalizeSerie(serieRaw)
|
||||
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
|
||||
|
||||
const seasonsWithEpisodes = await Promise.all(
|
||||
seasons.map(async (season: any) => {
|
||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? []))
|
||||
.map((ep: any) => ({
|
||||
id : ep.id,
|
||||
episode_number: ep.episode_number,
|
||||
title : ep.title ?? null,
|
||||
image : normalizeEpisode(ep).thumb_image ?? null,
|
||||
}))
|
||||
return {
|
||||
...normalizeSeason(season),
|
||||
season_number: season.season_number,
|
||||
episodes,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
res.json({
|
||||
id : serie.id,
|
||||
title : serie.title,
|
||||
original_title: serie.original_title ?? null,
|
||||
image : serie.poster_image ?? null,
|
||||
seasons : seasonsWithEpisodes,
|
||||
})
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── POST /api/v1/requests ─────────────────────────────────────
|
||||
router.post('/v1/requests', requireApiToken, (req, res) => {
|
||||
const { serieId, serieName, seasons, episodes } = req.body
|
||||
if (!serieId || !serieName) {
|
||||
res.status(400).json({ error: 'serieId et serieName requis' }); return
|
||||
}
|
||||
try {
|
||||
const submittedSeasons = Array.isArray(seasons) ? seasons.map(Number) : []
|
||||
const submittedEpisodes = Array.isArray(episodes) ? episodes.map(Number) : []
|
||||
|
||||
let request = upsertRequest(
|
||||
req.user!.id,
|
||||
Number(serieId),
|
||||
String(serieName),
|
||||
submittedSeasons,
|
||||
submittedEpisodes,
|
||||
)
|
||||
|
||||
const { requestAutoDownloadUsers } = readSettings()
|
||||
const userId = req.user!.id
|
||||
const allowed = requestAutoDownloadUsers === 'all'
|
||||
|| (Array.isArray(requestAutoDownloadUsers) && requestAutoDownloadUsers.includes(userId))
|
||||
|
||||
if (allowed) {
|
||||
const isNew = request.status === 'pending'
|
||||
if (isNew) request = approveRequest(request.id)
|
||||
const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes }
|
||||
autoDownloadRequest(request, dlOverride).catch(err =>
|
||||
logger.warn('public-api', `Échec du téléchargement automatique de « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
}
|
||||
|
||||
res.json(request)
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /api/v1/requests ──────────────────────────────────────
|
||||
router.get('/v1/requests', requireApiToken, (req, res) => {
|
||||
res.json(getRequestsForUser(req.user!.id))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAdmin } from '../auth.js'
|
||||
import {
|
||||
readRequests, upsertRequest, approveRequest,
|
||||
rejectRequest, completeRequest, deleteRequest, withdrawRequest,
|
||||
getRequestsForUser, getPendingCount, mergedSeasons, mergedEpisodes,
|
||||
type SerieRequest,
|
||||
} from '../requests.js'
|
||||
import { dispatchDownload } from '../torrent-clients/index.js'
|
||||
import { readSerieData } from '../lib/github-cache.js'
|
||||
import { resolveRequestTorrents, countRequestedEpisodes } from '../lib/request-scope.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/requests', async (req, res) => {
|
||||
const list = req.user!.role === 'admin'
|
||||
? readRequests()
|
||||
: getRequestsForUser(req.user!.id)
|
||||
res.json(await withScope(list))
|
||||
})
|
||||
|
||||
async function withScope(list: SerieRequest[]) {
|
||||
const serieData = new Map<number, Promise<any | null>>()
|
||||
return Promise.all(list.map(async r => {
|
||||
if (!serieData.has(r.serieId)) serieData.set(r.serieId, readSerieData(r.serieId))
|
||||
const sd = await serieData.get(r.serieId)
|
||||
const seasons = mergedSeasons(r)
|
||||
const episodes = mergedEpisodes(r)
|
||||
return {
|
||||
...r,
|
||||
episodeCount: sd ? countRequestedEpisodes(sd, seasons, episodes) : null,
|
||||
hasTorrents : !!sd && resolveRequestTorrents(sd, seasons, episodes).length > 0,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
router.get('/requests/pending-count', (req, res) => {
|
||||
const count = req.user!.role === 'admin'
|
||||
? getPendingCount()
|
||||
: getRequestsForUser(req.user!.id).filter(r => r.status === 'pending').length
|
||||
res.json({ count })
|
||||
})
|
||||
|
||||
router.post('/requests', (req, res) => {
|
||||
const { serieId, serieName, seasons, episodes, torrentOverride } = req.body
|
||||
if (!serieId || !serieName) {
|
||||
res.status(400).json({ error: 'Série manquante dans la demande' }); return
|
||||
}
|
||||
try {
|
||||
const submittedSeasons = Array.isArray(seasons) ? seasons.map(Number) : []
|
||||
const submittedEpisodes = Array.isArray(episodes) ? episodes.map(Number) : []
|
||||
|
||||
let request = upsertRequest(
|
||||
req.user!.id,
|
||||
Number(serieId),
|
||||
String(serieName),
|
||||
submittedSeasons,
|
||||
submittedEpisodes,
|
||||
)
|
||||
|
||||
const { requestAutoDownloadUsers } = readSettings()
|
||||
const userId = req.user!.id
|
||||
const allowed = requestAutoDownloadUsers === 'all'
|
||||
|| (Array.isArray(requestAutoDownloadUsers) && requestAutoDownloadUsers.includes(userId))
|
||||
|
||||
if (allowed) {
|
||||
const isNew = request.status === 'pending'
|
||||
if (isNew) request = approveRequest(request.id)
|
||||
|
||||
if (torrentOverride?.torrent_url || torrentOverride?.magnet) {
|
||||
const url = torrentOverride.torrent_url ?? torrentOverride.magnet
|
||||
dispatchDownload(url, {
|
||||
infohash : torrentOverride.infohash ?? null,
|
||||
magnet : torrentOverride.magnet ?? null,
|
||||
file_index: torrentOverride.file_index ?? null,
|
||||
file_path : torrentOverride.file_path ?? null,
|
||||
}).catch(err =>
|
||||
logger.warn('requests', `Échec du téléchargement automatique du torrent choisi pour « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
} else {
|
||||
const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes }
|
||||
autoDownloadRequest(request, dlOverride).catch(err =>
|
||||
logger.warn('requests', `Échec du téléchargement automatique de « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
res.json(request)
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/requests/:id', requireAdmin, async (req, res) => {
|
||||
const { action, rejectionMessage } = req.body
|
||||
try {
|
||||
let request: SerieRequest
|
||||
const id = String(req.params.id)
|
||||
if (action === 'approve') {
|
||||
request = approveRequest(id)
|
||||
autoDownloadRequest(request).catch(err =>
|
||||
logger.warn('requests', `Échec du téléchargement automatique de « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
} else if (action === 'reject') request = rejectRequest(id, rejectionMessage)
|
||||
else if (action === 'complete') request = completeRequest(id)
|
||||
else { res.status(400).json({ error: 'Action inconnue (approve, reject ou complete)' }); return }
|
||||
res.json(request)
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Envoie les torrents de la demande ; override limite aux saisons et épisodes qui viennent d'être ajoutés. */
|
||||
export async function autoDownloadRequest(req: SerieRequest, override?: { seasons: number[]; episodes: number[] }): Promise<void> {
|
||||
const serieData = await readSerieData(req.serieId)
|
||||
if (!serieData) {
|
||||
logger.warn('requests', `Téléchargement automatique : aucune donnée de torrent pour la série ${req.serieId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const seasons = override !== undefined ? override.seasons : mergedSeasons(req)
|
||||
const episodes = override !== undefined ? override.episodes : mergedEpisodes(req)
|
||||
|
||||
const toDownload = resolveRequestTorrents(serieData, seasons, episodes)
|
||||
|
||||
if (toDownload.length === 0) {
|
||||
logger.warn('requests', `Téléchargement automatique : aucun torrent trouvé pour « ${req.serieName} »`)
|
||||
return
|
||||
}
|
||||
|
||||
let sent = 0
|
||||
for (const t of toDownload) {
|
||||
const url = t.url ?? t.magnet
|
||||
if (!url) continue
|
||||
try {
|
||||
const results = await dispatchDownload(url, { infohash: t.infohash ?? undefined, magnet: t.magnet ?? undefined, file_index: t.file_index ?? null, file_path: t.file_path ?? null })
|
||||
if (results.some(r => r.ok)) sent++
|
||||
} catch (err) {
|
||||
logger.warn('requests', `Téléchargement automatique : erreur à l'envoi d'un torrent : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
logger.info('requests', `Téléchargement automatique de « ${req.serieName} » : ${sent}/${toDownload.length} torrent(s) envoyé(s)`)
|
||||
}
|
||||
|
||||
router.delete('/requests', requireAdmin, (_req, res) => {
|
||||
const all = readRequests()
|
||||
for (const r of all) {
|
||||
try { deleteRequest(r.id) } catch {}
|
||||
}
|
||||
logger.info('requests', `Toutes les demandes supprimées (${all.length})`)
|
||||
res.json({ deleted: all.length })
|
||||
})
|
||||
|
||||
router.delete('/requests/:id/mine', (req, res) => {
|
||||
try {
|
||||
res.json(withdrawRequest(String(req.params.id), req.user!.id))
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/requests/:id', requireAdmin, (req, res) => {
|
||||
try {
|
||||
deleteRequest(String(req.params.id))
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth, requireAdmin } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { loadSynced, setSync, setSyncBulk, isSynced, runRssSync, reconcileSynced } from '../lib/rss-sync.js'
|
||||
import { loadCatalogStatus } from '../lib/serie-helpers.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/rss-sync', requireAuth, (_req, res) => {
|
||||
res.json(Object.values(loadSynced()))
|
||||
})
|
||||
|
||||
// Routes fixes à déclarer avant /:id
|
||||
router.post('/rss-sync/run', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const result = await runRssSync()
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
logger.error('rss-sync', `Échec de la vérification manuelle des séries surveillées : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/rss-sync/orphans', requireAdmin, async (_req, res) => {
|
||||
const { series, complete } = await loadCatalogStatus()
|
||||
res.json({ checked: complete, orphans: complete ? reconcileSynced(series) : [] })
|
||||
})
|
||||
|
||||
router.post('/rss-sync/bulk', requireAdmin, (req, res) => {
|
||||
const enabled = req.body?.enabled === true
|
||||
const series = (Array.isArray(req.body?.series) ? req.body.series : [])
|
||||
.map((s: any) => ({ id: Number(s?.id), name: String(s?.name ?? '') }))
|
||||
.filter((s: { id: number; name: string }) => s.id && s.name)
|
||||
if (series.length === 0) { res.status(400).json({ error: 'Aucune série sélectionnée' }); return }
|
||||
const { changed, map } = setSyncBulk(series, enabled)
|
||||
logger.info('rss-sync', `Surveillance ${enabled ? 'activée' : 'désactivée'} pour plusieurs séries : ${changed} série(s) modifiée(s) sur ${series.length}`)
|
||||
res.json({ ok: true, changed, total: Object.keys(map).length })
|
||||
})
|
||||
|
||||
router.get('/rss-sync/:id', requireAuth, (req, res) => {
|
||||
const serieId = Number(req.params.id)
|
||||
res.json({ synced: isSynced(serieId) })
|
||||
})
|
||||
|
||||
router.post('/rss-sync/:id', requireAuth, (req, res) => {
|
||||
const serieId = Number(req.params.id)
|
||||
const serieName = String(req.body.serieName ?? '')
|
||||
if (!serieId || !serieName) { res.status(400).json({ error: 'Série manquante' }); return }
|
||||
const map = setSync(serieId, serieName, true)
|
||||
logger.info('rss-sync', `Surveillance activée pour « ${serieName} » (série ${serieId})`)
|
||||
res.json({ synced: true, total: Object.keys(map).length })
|
||||
})
|
||||
|
||||
router.delete('/rss-sync/:id', requireAuth, (req, res) => {
|
||||
const serieId = Number(req.params.id)
|
||||
const map = setSync(serieId, '', false)
|
||||
logger.info('rss-sync', `Surveillance désactivée pour la série ${serieId}`)
|
||||
res.json({ synced: false, total: Object.keys(map).length })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { dispatchList } from '../torrent-clients/index.js'
|
||||
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
|
||||
import { loadSynced } from '../lib/rss-sync.js'
|
||||
import {
|
||||
fankaiGet, fetchSerieFromApi, normalizeSerie, normalizeSeason, normalizeEpisode,
|
||||
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState, countOrganizedEpisodes,
|
||||
deduplicateEpisodes, serieHasEpisodes,
|
||||
} from '../lib/serie-helpers.js'
|
||||
import { recentImportsBySerie } from '../lib/notifs.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/library/recent', requireAuth, (req, res) => {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 12, 1), 50)
|
||||
res.json(recentImportsBySerie().slice(0, limit))
|
||||
})
|
||||
|
||||
router.get('/series', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()])
|
||||
const availableSet = new Set<number>(availableIds)
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const activeTorrents = new Set<string>()
|
||||
const clientHashes = new Set<string>()
|
||||
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (!t.hash) continue; clientHashes.add(t.hash.toLowerCase()); if (t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
|
||||
const organizedEpisodeIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
|
||||
const synced = loadSynced()
|
||||
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
|
||||
const serieDataMap = new Map<number, any>()
|
||||
await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
|
||||
const lastImports = new Map(recentImportsBySerie().map(r => [r.serieId, r.at]))
|
||||
res.json({ series: seriesRaw.map((serie: any) => {
|
||||
const serieData = serieDataMap.get(serie.id) ?? null
|
||||
const torrents = serieData ? extractTorrentsFromSerieData(serieData) : []
|
||||
const inClient = torrents.some(t => t.infohash && clientHashes.has(t.infohash.toLowerCase()))
|
||||
const hasFiles = (serieData?.seasons ?? []).some((s: any) => (s.episodes ?? []).some((ep: any) => organizedEpisodeIds.has(String(ep.id))))
|
||||
const counts = serieData ? countOrganizedEpisodes(serieData, organized) : { total: 0, organized: 0 }
|
||||
return {
|
||||
...serie, torrent_count: torrents.length, has_torrents: torrents.length > 0,
|
||||
download_state: computeSerieDownloadState(serieData, organized, activeTorrents, counts),
|
||||
episode_count: counts.total, organized_count: counts.organized, last_imported_at: lastImports.get(serie.id) ?? null,
|
||||
in_client: inClient, has_files: hasFiles, rss_synced: !!synced[serie.id],
|
||||
}
|
||||
})})
|
||||
} catch (err) {
|
||||
logger.error('api', `Échec du chargement des séries : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const [{ seasons, ...serieRaw }, serieData] = await Promise.all([fetchSerieFromApi(id), readSerieData(id)])
|
||||
const serie = { ...normalizeSerie(serieRaw), wiki: serieData?.wiki ?? null }
|
||||
const seasonsWithEpisodes = seasons.map((season: any) => ({ ...normalizeSeason(season), episodes: season.episodes.map(normalizeEpisode) }))
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
const episodeTorrentMap : Record<number, any[]> = {}
|
||||
const seasonTorrentMapBySn : Record<number, any[]> = {}
|
||||
const integraleTorrents : any[] = []
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
|
||||
// Importé = une entrée pour l'épisode sous n'importe quel hash : les torrents du scraper peuvent disparaître
|
||||
const trackedIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
|
||||
for (const season of seasonsWithEpisodes) {
|
||||
for (const ep of season.episodes) {
|
||||
if (trackedIds.has(String(ep.id))) organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
if (serieData) {
|
||||
// Index hash : pack (intégrale ou saison)
|
||||
const torrentByHash = new Map<string, { torrent_url: string; magnet: string; type: string; raw: string; fankai: boolean; torrent_name: string | null }>()
|
||||
for (const t of (serieData.torrents ?? []))
|
||||
if (t.infohash) torrentByHash.set(t.infohash.toLowerCase(), { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title ?? '', fankai: t.fankai ?? true, torrent_name: t.torrent_name ?? null })
|
||||
for (const sd_s of (serieData.seasons ?? []))
|
||||
for (const t of (sd_s.torrents ?? []))
|
||||
if (t.infohash) torrentByHash.set(t.infohash.toLowerCase(), { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title ?? '', fankai: t.fankai ?? true, torrent_name: t.torrent_name ?? null })
|
||||
|
||||
for (const t of (serieData.torrents ?? [])) {
|
||||
integraleTorrents.push({ ...t, raw: t.title })
|
||||
const resolved = buildResolvedEpisodes(serieData, t.infohash)
|
||||
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of resolved) {
|
||||
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
||||
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const t of (season.torrents ?? [])) {
|
||||
if (!seasonTorrentMapBySn[season.season_number]) seasonTorrentMapBySn[season.season_number] = []
|
||||
seasonTorrentMapBySn[season.season_number].push({ torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, type: 'pack_saison', raw: t.title, torrent_name: t.torrent_name ?? null, manual: t.manual ?? false })
|
||||
const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
|
||||
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of resolved) {
|
||||
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
|
||||
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of (ep.torrents ?? [])) {
|
||||
if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = []
|
||||
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
episodeTorrentMap[ep.id].push({ torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, type: 'episode', raw: t.title, torrent_name: t.torrent_name ?? null, manual: t.manual ?? false, fankai: t.fankai ?? true, file_index: pathEntry?.file_index ?? null, file_path: pathEntry?.path ?? null, formatted_name: pathEntry?.formatted_name ?? ep.formatted_name ?? null })
|
||||
availableEpisodeIds.add(ep.id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
const isOrg = orgFiles[String(ep.id)] !== undefined
|
||||
if (!isOrg) {
|
||||
const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
const filename = match ? match.path.split('/').pop() : null
|
||||
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
|
||||
} else {
|
||||
organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
if (!episodeTorrentMap[ep.id] || episodeTorrentMap[ep.id].length === 0) {
|
||||
const seenPackHashes = new Set<string>()
|
||||
for (const pathEntry of (ep.paths ?? [])) {
|
||||
if (typeof pathEntry !== 'object' || !pathEntry.infohash) continue
|
||||
const hashKey = pathEntry.infohash.toLowerCase()
|
||||
if (seenPackHashes.has(hashKey)) continue
|
||||
const pack = torrentByHash.get(hashKey)
|
||||
if (!pack) continue
|
||||
seenPackHashes.add(hashKey)
|
||||
if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = []
|
||||
episodeTorrentMap[ep.id].push({ torrent_url: pack.torrent_url, magnet: pack.magnet, infohash: hashKey, type: pack.type, raw: pack.raw, torrent_name: pack.torrent_name ?? null, manual: false, fankai: pack.fankai, file_index: pathEntry.file_index ?? null, file_path: pathEntry.path ?? null, formatted_name: pathEntry.formatted_name ?? ep.formatted_name ?? null })
|
||||
availableEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
|
||||
const rawEps = season.episodes.map((ep: any) => {
|
||||
const epTorrents = episodeTorrentMap[ep.id] ?? []
|
||||
const epTorrent = epTorrents[0] ?? null
|
||||
let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null
|
||||
if (fankai === null && serieData) {
|
||||
outer: for (const sd_season of serieData.seasons ?? []) {
|
||||
for (const sdEp of sd_season.episodes ?? []) {
|
||||
if (sdEp.id !== ep.id) continue
|
||||
for (const p of sdEp.paths ?? []) {
|
||||
const ih = typeof p === 'object' ? p.infohash?.toLowerCase() : null
|
||||
if (!ih) continue
|
||||
for (const t of serieData.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } }
|
||||
for (const s of serieData.seasons ?? []) { for (const t of s.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolvedFankai = fankai ?? true
|
||||
return {
|
||||
...ep,
|
||||
available: availableEpisodeIds.has(ep.id),
|
||||
torrent: epTorrent ? { ...epTorrent, fankai: resolvedFankai } : null,
|
||||
torrents: epTorrents.map((t: any) => ({ ...t, fankai: resolvedFankai ?? t.fankai ?? true })),
|
||||
fankai,
|
||||
organized: organizedEpisodeIds.has(ep.id),
|
||||
}
|
||||
})
|
||||
const eps = deduplicateEpisodes(rawEps)
|
||||
const total = eps.filter((e: any) => e.available).length
|
||||
const orgCount = eps.filter((e: any) => e.organized).length
|
||||
const epTotal = eps.length
|
||||
const orgState = orgCount === 0
|
||||
? 'none'
|
||||
: orgCount >= epTotal
|
||||
? 'complete'
|
||||
: total > 0 && orgCount >= total
|
||||
? 'complete'
|
||||
: 'partial'
|
||||
const seasonTorrents = seasonTorrentMapBySn[season.season_number] ?? []
|
||||
return {
|
||||
...season,
|
||||
torrent: seasonTorrents[0] ?? null,
|
||||
torrents: seasonTorrents,
|
||||
organized_state: orgState,
|
||||
organized_count: orgCount,
|
||||
episodes: eps,
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ serie, seasons: enrichedSeasons, scraper_synced: serieHasEpisodes(serieData), torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) })
|
||||
} catch (err) {
|
||||
logger.error('api', `Échec du chargement de la série ${id} : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { readSettings, writeSettings } from '../settings.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { readOnboarding, advanceOnboarding, isOnboardingStep } from '../onboarding.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/settings/onboarding', requireAuth, (_req, res) => {
|
||||
res.json(readOnboarding())
|
||||
})
|
||||
|
||||
router.post('/settings/onboarding', requireAuth, (req, res) => {
|
||||
const { step, complete } = req.body ?? {}
|
||||
if (step !== undefined && !isOnboardingStep(step)) {
|
||||
res.status(400).json({ error: 'Étape inconnue' }); return
|
||||
}
|
||||
res.json(advanceOnboarding(step, complete === true))
|
||||
})
|
||||
|
||||
router.get('/settings', requireAuth, (_req, res) => {
|
||||
res.json(readSettings())
|
||||
})
|
||||
|
||||
router.post('/settings', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove, autoUnimportMissing, requestAutoDownloadUsers, englishDirectory } = req.body
|
||||
const patch: Record<string, any> = {}
|
||||
if (mediaPath !== undefined) patch.mediaPath = mediaPath
|
||||
if (completePath !== undefined) patch.completePath = completePath
|
||||
if (organizeMode !== undefined) patch.organizeMode = organizeMode
|
||||
if (category !== undefined) patch.category = category
|
||||
if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport
|
||||
if (autoImport !== undefined) patch.autoImport = autoImport
|
||||
if (devMode !== undefined) patch.devMode = devMode
|
||||
if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove
|
||||
if (autoUnimportMissing !== undefined) patch.autoUnimportMissing = autoUnimportMissing
|
||||
if (requestAutoDownloadUsers !== undefined) patch.requestAutoDownloadUsers = requestAutoDownloadUsers
|
||||
if (englishDirectory !== undefined) patch.englishDirectory = englishDirectory
|
||||
const updated = writeSettings(patch)
|
||||
logger.info('api', 'Paramètres mis à jour')
|
||||
res.json(updated)
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { systemInfo, checkPaths } from '../system.js'
|
||||
import { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from '../organize.js'
|
||||
import { readAvailable, cacheClear } from '../lib/github-cache.js'
|
||||
import { loadCatalog } from '../lib/serie-helpers.js'
|
||||
import { cacheSize, cacheFetchedAt } from '../lib/github-cache.js'
|
||||
import { recentOrganized } from '../lib/notifs.js'
|
||||
import { workerRunning, lastScan } from '../organize.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const UNEXPECTED_ERROR = 'Erreur inattendue, consultez les journaux'
|
||||
|
||||
function readDirError(dirPath: string, err: unknown): string {
|
||||
const code = (err as NodeJS.ErrnoException)?.code
|
||||
if (code === 'ENOENT') return `Dossier introuvable : ${dirPath}`
|
||||
if (code === 'EACCES' || code === 'EPERM') return `Accès refusé au dossier : ${dirPath}`
|
||||
if (code === 'ENOTDIR') return `Ce chemin n'est pas un dossier : ${dirPath}`
|
||||
return `Dossier inaccessible : ${dirPath}`
|
||||
}
|
||||
|
||||
// ── Journaux ───────────────────────────────────────────────────
|
||||
router.get('/logs', requireAuth, (req, res) => {
|
||||
const limit = Number(req.query.limit) || 100
|
||||
const level = (req.query.level as string) || 'all'
|
||||
const source = (req.query.source as string) || undefined
|
||||
res.json({ entries: readLogs({ limit, level: level as any, source }), size: logsFileSize() })
|
||||
})
|
||||
|
||||
router.post('/logs/clear', requireAuth, (_req, res) => {
|
||||
clearLogs()
|
||||
logger.info('api', 'Journaux effacés')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── Système ────────────────────────────────────────────────────
|
||||
router.get('/system/info', systemInfo)
|
||||
|
||||
router.get('/system', requireAuth, (_req, res) => {
|
||||
res.json({ isDocker: fs.existsSync('/.dockerenv') })
|
||||
})
|
||||
|
||||
router.post('/system/check-paths', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath } = req.body ?? {}
|
||||
if (typeof mediaPath !== 'string' || (completePath !== undefined && typeof completePath !== 'string')) {
|
||||
res.status(400).json({ error: 'Chemin de la médiathèque requis' }); return
|
||||
}
|
||||
res.json(checkPaths(mediaPath, completePath))
|
||||
})
|
||||
|
||||
router.get('/version', (_req, res) => {
|
||||
try {
|
||||
const versionPath = path.join(BASE_DIR, 'version.txt')
|
||||
const version = fs.existsSync(versionPath) ? fs.readFileSync(versionPath, 'utf-8').trim() : 'dev'
|
||||
res.json({ version })
|
||||
} catch {
|
||||
res.json({ version: 'dev' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Parcours des dossiers ──────────────────────────────────────
|
||||
router.get('/browse', requireAuth, (req, res) => {
|
||||
const isWindows = process.platform === 'win32'
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
|
||||
// ── Racine virtuelle Windows : liste les lecteurs disponibles ──
|
||||
if (isWindows && dirPath === '/') {
|
||||
const drives: string[] = []
|
||||
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
||||
const d = `${letter}:\\`
|
||||
try { fs.readdirSync(d); drives.push(d) } catch {}
|
||||
}
|
||||
res.json({ path: '/', parent: null, dirs: drives, drivesRoot: true })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b))
|
||||
|
||||
// Sous Windows, remonter à '/' depuis la racine d'un lecteur (ex. C:\)
|
||||
let parent: string | null
|
||||
if (dirPath === '/') {
|
||||
parent = null
|
||||
} else if (isWindows && /^[A-Za-z]:\\?$/.test(dirPath)) {
|
||||
parent = '/'
|
||||
} else {
|
||||
const up = path.dirname(dirPath)
|
||||
parent = up === dirPath ? null : up // dirname de C:\ renvoie C:\ : pas de parent
|
||||
}
|
||||
|
||||
res.json({ path: dirPath, parent, dirs })
|
||||
} catch (err) {
|
||||
logger.warn('api', `Lecture du dossier « ${dirPath} » impossible : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(400).json({ error: readDirError(dirPath, err) })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/browse-files', requireAuth, (req, res) => {
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
|
||||
try {
|
||||
// Lecture seule : le dossier est créé au moment de l'import
|
||||
if (!fs.existsSync(dirPath)) { res.json({ path: dirPath, files: [], exists: false }); return }
|
||||
const files: { name: string; path: string; size: number }[] = []
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
|
||||
catch { return }
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(full)
|
||||
} else if (entry.isFile() && VIDEO_EXTS.has(path.extname(entry.name).toLowerCase())) {
|
||||
try {
|
||||
const stat = fs.statSync(full)
|
||||
files.push({ name: entry.name, path: full, size: stat.size })
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(dirPath)
|
||||
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||
res.json({ path: dirPath, files, exists: true })
|
||||
} catch (err) {
|
||||
logger.warn('api', `Lecture du dossier « ${dirPath} » impossible : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(400).json({ error: readDirError(dirPath, err) })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Catalogue ──────────────────────────────────────────────────
|
||||
router.get('/torrents/status', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const available = await readAvailable()
|
||||
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0, syncedAt: cacheFetchedAt('available.json') })
|
||||
} catch { res.json({ exists: false, count: 0, empty: true }) }
|
||||
})
|
||||
|
||||
router.post('/update', requireAuth, async (req, res) => {
|
||||
const force = req.body?.force === true
|
||||
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
||||
try {
|
||||
cacheClear()
|
||||
logger.info('api', force
|
||||
? 'Resynchronisation complète du catalogue (caches ignorés), noms de fichiers recalculés'
|
||||
: 'Cache GitHub vidé : rechargement du catalogue')
|
||||
|
||||
const availableIds = await readAvailable(force)
|
||||
if (!Array.isArray(availableIds)) throw new Error('Catalogue GitHub invalide (available.json)')
|
||||
logger.info('api', `Catalogue rechargé : ${availableIds.length} séries disponibles`)
|
||||
|
||||
if (force) {
|
||||
const seriesData = await loadCatalog(true)
|
||||
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
||||
const { removed } = dedupeOrganizedEpisodes(organizedPath)
|
||||
const { renamed, errors } = await syncFilenameChanges(seriesData, organizedPath)
|
||||
logger.info('api', `Resynchronisation terminée : ${updated} ID(s) migré(s), ${removed} doublon(s) retiré(s), ${renamed} fichier(s) renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
|
||||
res.json({ ok: true, count: availableIds.length, migrated: updated, deduped: removed, renamed, errors })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({ ok: true, count: availableIds.length })
|
||||
;(async () => {
|
||||
try {
|
||||
const seriesData = await loadCatalog()
|
||||
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
|
||||
if (renamed > 0)
|
||||
logger.info('api', `Renommage après synchronisation : ${renamed} fichier(s) renommé(s)`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Échec du renommage après synchronisation : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
})()
|
||||
} catch (err) {
|
||||
logger.error('api', `Échec de la synchronisation du catalogue : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/scan', requireAuth, (_req, res) => {
|
||||
res.json({ lastScan })
|
||||
})
|
||||
|
||||
router.post('/scan', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { mediaPath } = readSettings()
|
||||
logger.info('api', 'Analyse manuelle de la médiathèque lancée')
|
||||
const seriesData = await loadCatalog()
|
||||
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
logger.error('api', `Échec de l'analyse de la médiathèque : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Diagnostic ─────────────────────────────────────────────────
|
||||
router.get('/debug/stats', requireAuth, (req, res) => {
|
||||
const mem = process.memoryUsage()
|
||||
const uptimeS = Math.floor(process.uptime())
|
||||
const h = Math.floor(uptimeS / 3600)
|
||||
const m = Math.floor((uptimeS % 3600) / 60)
|
||||
const s = uptimeS % 60
|
||||
let organizedCount = 0
|
||||
try {
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath)) {
|
||||
const org = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
organizedCount = Object.values(org).reduce((acc: number, episodes: any) => acc + Object.keys(episodes).length, 0)
|
||||
}
|
||||
} catch {}
|
||||
res.json({
|
||||
memory : { heapUsed: Math.round(mem.heapUsed / 1024 / 1024), heapTotal: Math.round(mem.heapTotal / 1024 / 1024), rss: Math.round(mem.rss / 1024 / 1024) },
|
||||
cache : { entries: cacheSize(), ttlHours: 1 },
|
||||
uptime : `${h}h ${m}m ${s}s`,
|
||||
uptimeSeconds: uptimeS,
|
||||
worker : { running: workerRunning },
|
||||
organized: { trackedFiles: organizedCount },
|
||||
requests : { notifs: recentOrganized.length },
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import {
|
||||
getDriver, getAvailableClients,
|
||||
listClients, addClient, removeClient, getClient,
|
||||
sanitizeClient, updateClient,
|
||||
} from '../torrent-clients/index.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/torrent-clients/available', requireAuth, (_req, res) => {
|
||||
res.json(getAvailableClients())
|
||||
})
|
||||
|
||||
router.get('/torrent-clients', requireAuth, (_req, res) => {
|
||||
res.json(listClients().map((c: any) => sanitizeClient(c)))
|
||||
})
|
||||
|
||||
router.post('/torrent-clients', requireAuth, (req, res) => {
|
||||
const { name, type, config } = req.body
|
||||
if (!name || !type || !config) { res.status(400).json({ error: 'Nom, type et réglages du client requis' }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
|
||||
res.json(sanitizeClient(addClient(name, type, config)))
|
||||
})
|
||||
|
||||
router.delete('/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
const ok = removeClient(String(req.params.uuid))
|
||||
if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.put('/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
const { name, type, config } = req.body
|
||||
if (!name || !type || !config) { res.status(400).json({ error: 'Nom, type et réglages du client requis' }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
|
||||
const oldClient = getClient(String(req.params.uuid))
|
||||
if (oldClient) {
|
||||
const driver = getDriver(type)
|
||||
if (driver) {
|
||||
for (const field of driver.definition.fields) {
|
||||
if (field.type === 'password' && config[field.key] === '••••••••') {
|
||||
config[field.key] = oldClient.config[field.key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const updated = updateClient(String(req.params.uuid), name, type, config)
|
||||
if (!updated) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
res.json(sanitizeClient(updated))
|
||||
})
|
||||
|
||||
router.post('/torrent-clients/test-config', requireAuth, async (req, res) => {
|
||||
const { type, config, uuid } = req.body
|
||||
if (!type || !config) { res.status(400).json({ error: 'Type et réglages du client requis' }); return }
|
||||
const driver = getDriver(type)
|
||||
if (!driver) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
|
||||
if (uuid) {
|
||||
const oldClient = getClient(uuid)
|
||||
if (oldClient) {
|
||||
for (const field of driver.definition.fields) {
|
||||
if (field.type === 'password' && config[field.key] === '••••••••') {
|
||||
config[field.key] = oldClient.config[field.key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json(await driver.test(config))
|
||||
})
|
||||
|
||||
router.post('/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
|
||||
const client = getClient(String(req.params.uuid))
|
||||
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) { res.status(400).json({ error: 'Type de client non pris en charge' }); return }
|
||||
res.json(await driver.test(client.config))
|
||||
})
|
||||
|
||||
router.get('/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) => {
|
||||
const client = getClient(String(req.params.uuid))
|
||||
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) { res.status(400).json({ error: 'Type de client non pris en charge' }); return }
|
||||
res.json(await driver.healthcheck(client.config))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Router } from 'express'
|
||||
import {
|
||||
readUsers, createUser, updateUser, deleteUser,
|
||||
regenerateApiToken, safeUser,
|
||||
} from '../users.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/users', (_req, res) => {
|
||||
res.json(readUsers().map(safeUser))
|
||||
})
|
||||
|
||||
router.post('/users', (req, res) => {
|
||||
const { username, password, role } = req.body
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
res.status(400).json({ error: 'Le mot de passe doit contenir au moins 6 caractères' }); return
|
||||
}
|
||||
try {
|
||||
const user = createUser(username, password, role === 'admin' ? 'admin' : 'user')
|
||||
res.json(safeUser(user))
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/users/:id', (req, res) => {
|
||||
const { username, password, role } = req.body
|
||||
try {
|
||||
const user = updateUser(req.params.id, { username, password, role })
|
||||
res.json(safeUser(user))
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/users/:id', (req, res) => {
|
||||
if (req.user?.id === req.params.id) {
|
||||
res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte' }); return
|
||||
}
|
||||
try {
|
||||
deleteUser(req.params.id)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/users/:id/regenerate-token', (req, res) => {
|
||||
try {
|
||||
const token = regenerateApiToken(req.params.id)
|
||||
res.json({ apiToken: token })
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,41 +1,32 @@
|
||||
/**
|
||||
* secret.ts
|
||||
* =========
|
||||
* Génère et persiste le JWT secret dans data/secret.key
|
||||
* Si JWT_SECRET est défini en variable d'environnement, il est utilisé tel quel.
|
||||
* Sinon, on lit data/secret.key — s'il n'existe pas, on en génère un et on le sauvegarde.
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
const SECRET_PATH = path.join(process.cwd(), 'data', 'secret.key')
|
||||
const SECRET_PATH = path.join(DATA_DIR, 'secret.key')
|
||||
|
||||
function generateSecret(): string {
|
||||
return crypto.randomBytes(48).toString('hex')
|
||||
}
|
||||
|
||||
function loadOrCreateSecret(): string {
|
||||
// 1. Variable d'environnement — priorité absolue
|
||||
if (process.env.JWT_SECRET && process.env.JWT_SECRET !== 'fankarr-secret-change-me') {
|
||||
return process.env.JWT_SECRET
|
||||
}
|
||||
|
||||
// 2. Fichier existant
|
||||
if (fs.existsSync(SECRET_PATH)) {
|
||||
const secret = fs.readFileSync(SECRET_PATH, 'utf-8').trim()
|
||||
if (secret.length > 0) return secret
|
||||
}
|
||||
|
||||
// 3. Génération + sauvegarde
|
||||
const secret = generateSecret()
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(SECRET_PATH), { recursive: true })
|
||||
fs.writeFileSync(SECRET_PATH, secret, { encoding: 'utf-8', mode: 0o600 })
|
||||
console.log('[auth] JWT secret généré et sauvegardé dans data/secret.key')
|
||||
console.log(`[auth] Secret JWT généré dans ${SECRET_PATH}`)
|
||||
} catch (err) {
|
||||
console.error('[auth] Impossible de sauvegarder le secret:', err)
|
||||
console.error('[auth] Impossible d\'enregistrer le secret JWT :', err)
|
||||
}
|
||||
|
||||
return secret
|
||||
|
||||
@@ -1,27 +1,43 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
const BASE_DIR = _isBunBinary
|
||||
? path.dirname((process as any).execPath)
|
||||
: process.cwd()
|
||||
const DATA_PATH = path.join(BASE_DIR, 'data', 'settings.json')
|
||||
const DATA_PATH = path.join(DATA_DIR, 'settings.json')
|
||||
|
||||
export interface Settings {
|
||||
mediaPath : string
|
||||
completePath : string
|
||||
organizeMode : 'hardlink' | 'move'
|
||||
category : string
|
||||
nfoSupport : boolean // téléchargement NFO/images depuis GitLab Fankai Pack
|
||||
mediaPath : string
|
||||
completePath : string
|
||||
organizeMode : 'hardlink' | 'copy' | 'move'
|
||||
category : string
|
||||
nfoSupport : boolean
|
||||
autoImport : boolean
|
||||
devMode : boolean
|
||||
deleteTorrentOnMove : boolean
|
||||
requestAutoDownloadUsers: 'all' | string[] // Utilisateurs dont les demandes sont approuvées et téléchargées automatiquement ; [] = aucun
|
||||
jellyfinUrl : string // URL du serveur Jellyfin (ex. http://jellyfin:8096)
|
||||
jellyfinAdminToken : string // Clé API Jellyfin pour la synchronisation
|
||||
englishDirectory : boolean // Dossiers de saison en anglais (« Season 01 » au lieu de « Saison 1 »)
|
||||
autoUnimportMissing : boolean
|
||||
onboardingStep : string | null // étape la plus avancée atteinte dans l'assistant
|
||||
onboardingCompletedAt: string | null
|
||||
}
|
||||
|
||||
const defaults: Settings = {
|
||||
mediaPath : '',
|
||||
completePath : '',
|
||||
organizeMode : 'hardlink',
|
||||
category : 'fankai',
|
||||
nfoSupport : false,
|
||||
mediaPath : '',
|
||||
completePath : '',
|
||||
organizeMode : 'hardlink',
|
||||
category : 'fankai',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
devMode : false,
|
||||
deleteTorrentOnMove: false,
|
||||
requestAutoDownloadUsers: [],
|
||||
jellyfinUrl : '',
|
||||
jellyfinAdminToken : '',
|
||||
englishDirectory : false,
|
||||
autoUnimportMissing: false,
|
||||
onboardingStep : null,
|
||||
onboardingCompletedAt: null,
|
||||
}
|
||||
|
||||
export function readSettings(): Settings {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { randomBytes } from 'crypto'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
// GET /api/system/info
|
||||
export function systemInfo(_req: Request, res: Response): void {
|
||||
const isTermux = fs.existsSync('/data/data/com.termux')
|
||||
const platform = os.platform()
|
||||
|
||||
res.json({
|
||||
platform,
|
||||
isTermux,
|
||||
defaultPath: isTermux ? '/sdcard/' : '/',
|
||||
})
|
||||
}
|
||||
|
||||
// ── Vérification des dossiers (assistant de configuration) ────
|
||||
|
||||
export interface PathCheck {
|
||||
input : string
|
||||
resolved : string
|
||||
exists : boolean
|
||||
isDirectory: boolean
|
||||
writable : boolean
|
||||
error? : string
|
||||
}
|
||||
|
||||
export interface HardlinkCheck {
|
||||
tested : boolean
|
||||
ok : boolean
|
||||
code? : string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type PathRelation = 'distinct' | 'same' | 'nested'
|
||||
|
||||
function tempName(prefix: string): string {
|
||||
return `.fankarr-${prefix}-${process.pid}-${randomBytes(4).toString('hex')}`
|
||||
}
|
||||
|
||||
function removeQuietly(file: string): void {
|
||||
try { fs.rmSync(file, { force: true, maxRetries: 3, retryDelay: 100 }) } catch {}
|
||||
}
|
||||
|
||||
export function checkDirectory(input: string, { allowRoot = true } = {}): PathCheck {
|
||||
const raw = (input ?? '').trim()
|
||||
const result: PathCheck = { input: raw, resolved: '', exists: false, isDirectory: false, writable: false }
|
||||
|
||||
if (!raw) return { ...result, error: 'Chemin vide' }
|
||||
if (/^[A-Za-z]:$/.test(raw)) return { ...result, error: 'Chemin incomplet : ajoutez « \\ » après la lettre du lecteur (ex. C:\\)' }
|
||||
if (!path.isAbsolute(raw)) return { ...result, error: 'Le chemin doit être absolu' }
|
||||
|
||||
result.resolved = path.resolve(raw)
|
||||
if (!allowRoot && path.parse(result.resolved).root === result.resolved) {
|
||||
return { ...result, error: 'La racine du disque ne peut pas servir de médiathèque' }
|
||||
}
|
||||
|
||||
let stat: fs.Stats
|
||||
try { stat = fs.statSync(result.resolved) }
|
||||
catch { return { ...result, error: 'Dossier introuvable' } }
|
||||
|
||||
result.exists = true
|
||||
if (!stat.isDirectory()) return { ...result, error: "Ce chemin n'est pas un dossier" }
|
||||
result.isDirectory = true
|
||||
|
||||
// Test d'écriture réel
|
||||
const probe = path.join(result.resolved, tempName('write'))
|
||||
try {
|
||||
fs.writeFileSync(probe, '')
|
||||
result.writable = true
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
result.error = code === 'EACCES' || code === 'EPERM'
|
||||
? `Écriture impossible : permissions insuffisantes (${code})`
|
||||
: `Écriture impossible${code ? ` (${code})` : ''}`
|
||||
} finally {
|
||||
removeQuietly(probe)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function pathRelation(a: string, b: string): PathRelation {
|
||||
const norm = (p: string) => {
|
||||
const r = path.resolve(p).replace(/[\\/]+$/, '')
|
||||
return process.platform === 'win32' ? r.toLowerCase() : r
|
||||
}
|
||||
const na = norm(a), nb = norm(b)
|
||||
if (na === nb) return 'same'
|
||||
if (na.startsWith(nb + path.sep) || nb.startsWith(na + path.sep)) return 'nested'
|
||||
return 'distinct'
|
||||
}
|
||||
|
||||
function hardlinkMessage(code: string | undefined, err: unknown): string {
|
||||
switch (code) {
|
||||
case 'EXDEV':
|
||||
return 'Volumes différents : hardlink impossible. Passez en mode Copier ou, sous Docker, montez un seul volume parent contenant téléchargements et médiathèque.'
|
||||
case 'EPERM':
|
||||
case 'ENOTSUP':
|
||||
case 'ENOSYS':
|
||||
return 'Ce système de fichiers ne gère pas les hardlinks (FAT/exFAT, partage réseau…).'
|
||||
case 'EACCES':
|
||||
return 'Permissions insuffisantes pour créer un hardlink.'
|
||||
default:
|
||||
return `Hardlink impossible : ${err instanceof Error ? err.message : String(err)}`
|
||||
}
|
||||
}
|
||||
|
||||
export function testHardlink(srcDir: string, destDir: string): HardlinkCheck {
|
||||
const name = tempName('linktest')
|
||||
const src = path.join(srcDir, name)
|
||||
const dest = path.join(destDir, `${name}.link`)
|
||||
try {
|
||||
fs.writeFileSync(src, '')
|
||||
fs.linkSync(src, dest)
|
||||
return { tested: true, ok: true, message: 'Hardlink possible entre les deux dossiers' }
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
return { tested: true, ok: false, code, message: hardlinkMessage(code, err) }
|
||||
} finally {
|
||||
removeQuietly(dest)
|
||||
removeQuietly(src)
|
||||
}
|
||||
}
|
||||
|
||||
export function checkPaths(mediaPath: string, completePath?: string) {
|
||||
const media = checkDirectory(mediaPath, { allowRoot: false })
|
||||
const hasComplete = !!completePath?.trim()
|
||||
const complete = hasComplete ? checkDirectory(completePath!) : null
|
||||
|
||||
const valid = (c: PathCheck | null) => !!c && c.writable && !c.error
|
||||
const relation = hasComplete && media.resolved && complete?.resolved
|
||||
? pathRelation(media.resolved, complete.resolved)
|
||||
: null
|
||||
|
||||
let hardlink: HardlinkCheck
|
||||
if (!hasComplete) {
|
||||
hardlink = { tested: false, ok: false, message: 'Renseignez le dossier des téléchargements pour tester le hardlink' }
|
||||
} else if (!valid(media) || !valid(complete)) {
|
||||
hardlink = { tested: false, ok: false, message: 'Test impossible tant que les dossiers ne sont pas valides' }
|
||||
} else if (relation === 'same') {
|
||||
hardlink = { tested: false, ok: false, message: 'Les deux dossiers sont identiques' }
|
||||
} else {
|
||||
hardlink = testHardlink(complete!.resolved, media.resolved)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: valid(media) && (!hasComplete || valid(complete)) && relation !== 'same',
|
||||
mediaPath : media,
|
||||
completePath: complete,
|
||||
relation,
|
||||
hardlink,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/** Deluge via l'API JSON-RPC de l'interface web. Progression en pourcentage (0–100). */
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// ─── Sessions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SessionEntry { cookie: string; expires: number }
|
||||
const _sessions = new Map<string, SessionEntry>()
|
||||
|
||||
function sessionKey(config: ClientConfig): string {
|
||||
return `${config.url}::${config.password}`
|
||||
}
|
||||
|
||||
// ─── HTTP ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function delugeUrl(config: ClientConfig): string {
|
||||
return String(config.url ?? '').replace(/\/+$/, '') + '/json'
|
||||
}
|
||||
|
||||
function parseCookie(raw: string): string {
|
||||
// "_session_id=abc123; Path=/; HttpOnly" devient "_session_id=abc123"
|
||||
return raw.split(';')[0].trim()
|
||||
}
|
||||
|
||||
async function delugeRPC(
|
||||
config : ClientConfig,
|
||||
url : string,
|
||||
method : string,
|
||||
params : unknown[],
|
||||
cookie : string,
|
||||
): Promise<{ result: any; newCookie: string | null }> {
|
||||
const res = await clientFetch(config, url, {
|
||||
method : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept' : 'application/json',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
},
|
||||
body: JSON.stringify({ method, params, id: 1 }),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(`Deluge a répondu HTTP ${res.status} : vérifiez l'URL de l'interface web`)
|
||||
|
||||
const json = await res.json()
|
||||
if (json.error) {
|
||||
throw new Error(`Erreur Deluge : ${json.error.message ?? `code ${json.error.code ?? 'inconnu'}`}`)
|
||||
}
|
||||
|
||||
const setCookie = res.headers.get('set-cookie')
|
||||
return {
|
||||
result : json.result,
|
||||
newCookie: setCookie ? parseCookie(setCookie) : null,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Connexion ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function delugeLogin(config: ClientConfig): Promise<string> {
|
||||
const url = delugeUrl(config)
|
||||
const res = await clientFetch(config, url, {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body : JSON.stringify({ method: 'auth.login', params: [String(config.password ?? '')], id: 1 }),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(`Deluge a répondu HTTP ${res.status} : vérifiez l'URL de l'interface web`)
|
||||
|
||||
const json = await res.json()
|
||||
if (json.error) throw new Error(`Connexion à Deluge refusée : ${json.error.message ?? `code ${json.error.code ?? 'inconnu'}`}`)
|
||||
if (!json.result) throw new Error('Connexion à Deluge refusée : mot de passe incorrect ?')
|
||||
|
||||
const setCookie = res.headers.get('set-cookie')
|
||||
if (!setCookie) throw new Error('Échec de la connexion à Deluge : aucun cookie de session reçu')
|
||||
|
||||
return parseCookie(setCookie)
|
||||
}
|
||||
|
||||
// ─── Appel avec gestion de session ────────────────────────────────────────────
|
||||
|
||||
async function delugeCall(
|
||||
config : ClientConfig,
|
||||
method : string,
|
||||
params : unknown[] = [],
|
||||
): Promise<any> {
|
||||
const url = delugeUrl(config)
|
||||
const key = sessionKey(config)
|
||||
|
||||
let entry = _sessions.get(key)
|
||||
if (!entry || Date.now() >= entry.expires) {
|
||||
const cookie = await delugeLogin(config)
|
||||
entry = { cookie, expires: Date.now() + 30 * 60_000 }
|
||||
_sessions.set(key, entry)
|
||||
}
|
||||
|
||||
try {
|
||||
const { result, newCookie } = await delugeRPC(config, url, method, params, entry.cookie)
|
||||
if (newCookie) {
|
||||
entry.cookie = newCookie
|
||||
entry.expires = Date.now() + 30 * 60_000
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (msg.includes('Not authorized') || msg.includes('auth') || msg.includes('401')) {
|
||||
_sessions.delete(key)
|
||||
const cookie = await delugeLogin(config)
|
||||
const fresh = { cookie, expires: Date.now() + 30 * 60_000 }
|
||||
_sessions.set(key, fresh)
|
||||
const { result } = await delugeRPC(config, url, method, params, cookie)
|
||||
return result
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ─── États ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function mapState(state: string): TorrentInfo['state'] {
|
||||
switch (state) {
|
||||
case 'Downloading':
|
||||
case 'Queued' :
|
||||
case 'Allocating' :
|
||||
case 'Moving' : return 'downloading'
|
||||
case 'Seeding' : return 'seeding'
|
||||
case 'Paused' : return 'paused'
|
||||
case 'Checking' : return 'checking'
|
||||
case 'Error' : return 'error'
|
||||
default : return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Aplatissement de l'arborescence de fichiers Deluge ────────────────────────
|
||||
// web.get_torrent_files retourne :
|
||||
// { contents: { "nom": { type: "file"|"dir", index, progress, priority, contents? } } }
|
||||
|
||||
function flattenDelugeFiles(
|
||||
contents: Record<string, any>,
|
||||
prefix = '',
|
||||
): TorrentFileProgress[] {
|
||||
const result: TorrentFileProgress[] = []
|
||||
for (const [name, node] of Object.entries(contents ?? {})) {
|
||||
const fullPath = prefix ? `${prefix}/${name}` : name
|
||||
if (node.type === 'dir') {
|
||||
result.push(...flattenDelugeFiles(node.contents ?? {}, fullPath))
|
||||
} else {
|
||||
result.push({
|
||||
index : typeof node.index === 'number' ? node.index : 0,
|
||||
name : fullPath,
|
||||
progress: typeof node.progress === 'number' ? node.progress : 0,
|
||||
priority: typeof node.priority === 'number' ? node.priority : 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Sélection de fichier (en arrière-plan) ──────────────────────────────────
|
||||
|
||||
async function applyFilePriority(
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
try {
|
||||
const status = await delugeCall(config, 'core.get_torrent_status', [hash, ['num_files', 'has_metadata']])
|
||||
const numFiles = typeof status?.num_files === 'number' ? status.num_files : 0
|
||||
if (numFiles === 0 || !status?.has_metadata) continue
|
||||
|
||||
const priorities = Array.from({ length: numFiles }, (_, i) => i === fileIndex ? 1 : 0)
|
||||
await delugeCall(config, 'core.set_torrent_options', [[hash], { file_priorities: priorities }])
|
||||
logger.info('deluge', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
logger.warn('deluge', `Délai dépassé : priorité de fichier non appliquée pour ${hash.slice(0, 8)}…`)
|
||||
}
|
||||
|
||||
// ─── Client ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const delugeDriver: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'deluge',
|
||||
label : 'Deluge',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8112', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'category', label: 'Catégorie (plugin Label)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
_sessions.delete(sessionKey(config))
|
||||
const cookie = await delugeLogin(config)
|
||||
_sessions.set(sessionKey(config), { cookie, expires: Date.now() + 30 * 60_000 })
|
||||
logger.info('deluge', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
logger.warn('deluge', `Échec du test de connexion sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const version = await delugeCall(config, 'daemon.get_version')
|
||||
return { online: true, version: String(version ?? 'inconnue') }
|
||||
} catch {
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, category?) {
|
||||
const keys = [
|
||||
'name', 'state', 'progress', 'total_size', 'total_done',
|
||||
'total_uploaded', 'ratio', 'download_payload_rate', 'upload_payload_rate',
|
||||
'eta', 'save_path', 'label',
|
||||
]
|
||||
// Tous les torrents, filtrés par label ici pour ne pas dépendre du plugin Label
|
||||
const raw: Record<string, any> = await delugeCall(config, 'core.get_torrents_status', [{}, keys]) ?? {}
|
||||
|
||||
return Object.entries(raw)
|
||||
.filter(([_, t]) => !category || (t.label ?? '') === category)
|
||||
.map(([hash, t]): TorrentInfo => ({
|
||||
hash : hash.toLowerCase(),
|
||||
name : String(t.name ?? ''),
|
||||
state : mapState(String(t.state ?? '')),
|
||||
progress : Math.round(t.progress ?? 0), // déjà en %
|
||||
size : typeof t.total_size === 'number' ? t.total_size : 0,
|
||||
downloaded: typeof t.total_done === 'number' ? t.total_done : 0,
|
||||
uploaded : typeof t.total_uploaded === 'number' ? t.total_uploaded : 0,
|
||||
ratio : Math.round((t.ratio ?? 0) * 100) / 100,
|
||||
speed : typeof t.download_payload_rate === 'number' ? t.download_payload_rate : 0,
|
||||
upspeed : typeof t.upload_payload_rate === 'number' ? t.upload_payload_rate : 0,
|
||||
eta : typeof t.eta === 'number' && t.eta >= 0 ? t.eta : -1,
|
||||
save_path : String(t.save_path ?? ''),
|
||||
category : String(t.label ?? ''),
|
||||
}))
|
||||
},
|
||||
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const addOpts: Record<string, unknown> = {}
|
||||
if (config.savePath) addOpts['download_location'] = String(config.savePath)
|
||||
|
||||
let hash: string | null = null
|
||||
|
||||
if (url.startsWith('magnet:')) {
|
||||
hash = await delugeCall(config, 'core.add_torrent_magnet', [url, addOpts])
|
||||
logger.info('deluge', `Magnet ajouté (hash ${hash?.slice(0, 8) ?? '?'}…)`)
|
||||
} else {
|
||||
const torrentRes = await fetch(url)
|
||||
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent (HTTP ${torrentRes.status})`)
|
||||
const buf = await torrentRes.arrayBuffer()
|
||||
const b64 = Buffer.from(buf).toString('base64')
|
||||
const filename = url.split('/').pop()?.replace(/\?.*$/, '') ?? 'torrent.torrent'
|
||||
hash = await delugeCall(config, 'core.add_torrent_file', [filename, b64, addOpts])
|
||||
logger.info('deluge', `Fichier .torrent envoyé (hash ${hash?.slice(0, 8) ?? '?'}…)`)
|
||||
}
|
||||
|
||||
// Plugin Label facultatif : erreurs ignorées
|
||||
if (config.category && hash) {
|
||||
delugeCall(config, 'label.set_torrent', [hash, String(config.category)]).catch(() => {})
|
||||
}
|
||||
|
||||
if (options?.file_index != null && hash) {
|
||||
applyFilePriority(config, hash, options.file_index).catch(() => {})
|
||||
}
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
await delugeCall(config, 'core.remove_torrent', [hash, deleteFiles])
|
||||
logger.info('deluge', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
},
|
||||
|
||||
async getFiles(config, hash) {
|
||||
const data = await delugeCall(config, 'web.get_torrent_files', [hash])
|
||||
if (!data?.contents) return []
|
||||
return flattenDelugeFiles(data.contents)
|
||||
},
|
||||
}
|
||||
|
||||
export default delugeDriver
|
||||
@@ -1,70 +1,123 @@
|
||||
/**
|
||||
* Torrent Client Registry
|
||||
* =======================
|
||||
* Interface commune + registry de tous les clients disponibles.
|
||||
* Chaque client expose son schema de configuration (FieldDef[])
|
||||
* pour que le frontend génère le formulaire dynamiquement.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Agent, fetch as undiciFetch } from 'undici'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Config d'un client torrent (les champs `boolean` sont stockés en booléens). */
|
||||
export type ClientConfig = Record<string, string | number | boolean>
|
||||
|
||||
export interface FieldDef {
|
||||
key : string
|
||||
label : string
|
||||
type : 'text' | 'password' | 'number' | 'url'
|
||||
type : 'text' | 'password' | 'number' | 'url' | 'boolean'
|
||||
placeholder?: string
|
||||
required : boolean
|
||||
default? : string | number
|
||||
default? : string | number | boolean
|
||||
}
|
||||
|
||||
/** Définition statique d'un type de client (ce qu'il expose au front) */
|
||||
export interface TorrentClientDefinition {
|
||||
id : string // "qbittorrent", "transmission", etc.
|
||||
label : string // "qBittorrent"
|
||||
fields : FieldDef[] // schema du formulaire
|
||||
id : string
|
||||
label : string
|
||||
fields : FieldDef[]
|
||||
/** Clients sans catégories : dispatchList ne garde que les torrents ajoutés par FanKarr. */
|
||||
filterByManagedHashes?: boolean
|
||||
}
|
||||
|
||||
/** Instance sauvegardée d'un client configuré par l'utilisateur */
|
||||
export interface SavedClient {
|
||||
uuid : string // identifiant unique de cette instance
|
||||
name : string // nom custom donné par l'utilisateur
|
||||
type : string // id du TorrentClientDefinition
|
||||
config: Record<string, string | number>
|
||||
uuid : string
|
||||
name : string
|
||||
type : string
|
||||
config: ClientConfig
|
||||
}
|
||||
|
||||
export interface TorrentFileProgress {
|
||||
index : number
|
||||
name ?: string // chemin relatif dans le torrent (ex. "Pack S1/Episode1.mkv"), selon le client
|
||||
progress: number // 0–1
|
||||
priority: number // 0 = non téléchargé, 1 = normale, 6 = haute, 7 = maximale
|
||||
}
|
||||
|
||||
/** État normalisé d'un torrent (commun à tous les clients) */
|
||||
export interface TorrentInfo {
|
||||
hash : string
|
||||
name : string
|
||||
state : 'downloading' | 'seeding' | 'paused' | 'checking' | 'error' | 'unknown'
|
||||
progress : number // 0-100
|
||||
size : number // bytes
|
||||
downloaded: number // bytes
|
||||
speed : number // bytes/s
|
||||
eta : number // secondes, -1 si inconnu
|
||||
save_path : string // dossier de complétion
|
||||
progress : number
|
||||
size : number
|
||||
downloaded: number
|
||||
uploaded : number
|
||||
ratio : number
|
||||
speed : number
|
||||
upspeed : number
|
||||
eta : number
|
||||
save_path : string
|
||||
category : string
|
||||
files ?: TorrentFileProgress[]
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
file_index?: number | null
|
||||
file_path ?: string | null
|
||||
infohash ?: string | null // hash issu du catalogue (plus fiable que le magnet)
|
||||
magnet ?: string | null
|
||||
}
|
||||
|
||||
/** Interface que chaque client doit implémenter */
|
||||
export interface TorrentClientDriver {
|
||||
definition : TorrentClientDefinition
|
||||
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
|
||||
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }>
|
||||
add : (config: Record<string, string | number>, url: string) => Promise<void>
|
||||
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
|
||||
test : (config: ClientConfig) => Promise<{ ok: boolean; message: string }>
|
||||
healthcheck : (config: ClientConfig) => Promise<{ online: boolean; version?: string }>
|
||||
add : (config: ClientConfig, url: string, options?: DownloadOptions) => Promise<void>
|
||||
list : (config: ClientConfig, category?: string) => Promise<TorrentInfo[]>
|
||||
remove : (config: ClientConfig, hash: string, deleteFiles?: boolean) => Promise<void>
|
||||
getFiles ?: (config: ClientConfig, hash: string) => Promise<TorrentFileProgress[]>
|
||||
}
|
||||
|
||||
// ─── Registry ─────────────────────────────────────────────────────────────────
|
||||
// ─── Certificats TLS ──────────────────────────────────────────────────────────
|
||||
// ignoreCertificateErrors : Agent undici sous Node, option tls du fetch sous Bun
|
||||
|
||||
let _insecureAgent: Agent | null = null
|
||||
|
||||
function insecureAgent(): Agent {
|
||||
if (!_insecureAgent) _insecureAgent = new Agent({ connect: { rejectUnauthorized: false } })
|
||||
return _insecureAgent
|
||||
}
|
||||
|
||||
export function ignoreCertificateErrors(config: ClientConfig): boolean {
|
||||
const v = config.ignoreCertificateErrors
|
||||
return v === true || v === 'true' || v === 1
|
||||
}
|
||||
|
||||
// Option tls propre à Bun, absente des types standards
|
||||
interface BunRequestInit extends RequestInit {
|
||||
tls?: { rejectUnauthorized?: boolean }
|
||||
}
|
||||
|
||||
const isBun = 'bun' in process.versions
|
||||
|
||||
export function clientFetch(
|
||||
config : ClientConfig,
|
||||
url : string,
|
||||
init : RequestInit = {},
|
||||
): Promise<Response> {
|
||||
if (!ignoreCertificateErrors(config)) return fetch(url, init)
|
||||
if (isBun) {
|
||||
const bunInit: BunRequestInit = { ...init, tls: { rejectUnauthorized: false } }
|
||||
return fetch(url, bunInit)
|
||||
}
|
||||
return undiciFetch(url, { ...init, dispatcher: insecureAgent() } as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>
|
||||
}
|
||||
|
||||
// ─── Registre ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const drivers = new Map<string, TorrentClientDriver>()
|
||||
|
||||
export function registerDriver(driver: TorrentClientDriver) {
|
||||
drivers.set(driver.definition.id, driver)
|
||||
logger.debug('torrent-clients', `Type de client enregistré : ${driver.definition.label}`)
|
||||
}
|
||||
|
||||
export function getDriver(type: string): TorrentClientDriver | undefined {
|
||||
@@ -75,9 +128,59 @@ export function getAvailableClients(): TorrentClientDefinition[] {
|
||||
return [...drivers.values()].map(d => d.definition)
|
||||
}
|
||||
|
||||
// ─── Persistence (clients enregistrés) ────────────────────────────────────────
|
||||
// ─── Stockage ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const CLIENTS_PATH = path.join(process.cwd(), 'data', 'torrent_clients.json')
|
||||
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
|
||||
|
||||
// ─── Torrents suivis ──────────────────────────────────────────────────────────
|
||||
// Hashes ajoutés par FanKarr, par client : filtre les clients sans catégories (Synology)
|
||||
|
||||
const MANAGED_PATH = path.join(DATA_DIR, 'managed_tasks.json')
|
||||
|
||||
function loadManagedMap(): Record<string, string[]> {
|
||||
try {
|
||||
if (!fs.existsSync(MANAGED_PATH)) return {}
|
||||
return JSON.parse(fs.readFileSync(MANAGED_PATH, 'utf-8'))
|
||||
} catch { return {} }
|
||||
}
|
||||
|
||||
function saveManagedMap(data: Record<string, string[]>) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(MANAGED_PATH), { recursive: true })
|
||||
fs.writeFileSync(MANAGED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getManagedSet(clientUuid: string): Set<string> | null {
|
||||
const list = loadManagedMap()[clientUuid]
|
||||
if (!list || list.length === 0) return null
|
||||
return new Set(list)
|
||||
}
|
||||
|
||||
export function trackManagedHash(clientUuid: string, infohash: string) {
|
||||
const data = loadManagedMap()
|
||||
if (!data[clientUuid]) data[clientUuid] = []
|
||||
const h = infohash.toLowerCase()
|
||||
if (!data[clientUuid].includes(h)) {
|
||||
data[clientUuid].push(h)
|
||||
saveManagedMap(data)
|
||||
}
|
||||
}
|
||||
|
||||
export function untrackManagedHash(clientUuid: string, infohash: string) {
|
||||
const data = loadManagedMap()
|
||||
if (!data[clientUuid]) return
|
||||
data[clientUuid] = data[clientUuid].filter(h => h !== infohash.toLowerCase())
|
||||
if (data[clientUuid].length === 0) delete data[clientUuid]
|
||||
saveManagedMap(data)
|
||||
}
|
||||
|
||||
function clearManagedHashes(clientUuid: string) {
|
||||
const data = loadManagedMap()
|
||||
if (!data[clientUuid]) return
|
||||
delete data[clientUuid]
|
||||
saveManagedMap(data)
|
||||
}
|
||||
|
||||
function loadClients(): SavedClient[] {
|
||||
try {
|
||||
@@ -97,19 +200,23 @@ export function listClients(): SavedClient[] {
|
||||
return loadClients()
|
||||
}
|
||||
|
||||
export function addClient(name: string, type: string, config: Record<string, string | number>): SavedClient {
|
||||
export function addClient(name: string, type: string, config: ClientConfig): SavedClient {
|
||||
const clients = loadClients()
|
||||
const client: SavedClient = { uuid: randomUUID(), name, type, config }
|
||||
clients.push(client)
|
||||
saveClients(clients)
|
||||
logger.info('torrent-clients', `Client ajouté : « ${name} » (${type})`)
|
||||
return client
|
||||
}
|
||||
|
||||
export function removeClient(uuid: string): boolean {
|
||||
const clients = loadClients()
|
||||
const clients = loadClients()
|
||||
const target = clients.find(c => c.uuid === uuid)
|
||||
const filtered = clients.filter(c => c.uuid !== uuid)
|
||||
if (filtered.length === clients.length) return false
|
||||
saveClients(filtered)
|
||||
clearManagedHashes(uuid)
|
||||
logger.info('torrent-clients', `Client supprimé : « ${target?.name} » (${target?.type})`)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -117,7 +224,16 @@ export function getClient(uuid: string): SavedClient | undefined {
|
||||
return loadClients().find(c => c.uuid === uuid)
|
||||
}
|
||||
|
||||
/** Masque les champs password pour l'envoi au front */
|
||||
export function updateClient(uuid: string, name: string, type: string, config: ClientConfig): SavedClient | null {
|
||||
const clients = loadClients()
|
||||
const index = clients.findIndex(c => c.uuid === uuid)
|
||||
if (index === -1) return null
|
||||
clients[index] = { ...clients[index], name, type, config }
|
||||
saveClients(clients)
|
||||
logger.info('torrent-clients', `Client modifié : « ${name} » (${type})`)
|
||||
return clients[index]
|
||||
}
|
||||
|
||||
export function sanitizeClient(client: SavedClient, definition?: TorrentClientDefinition): SavedClient {
|
||||
if (!definition) definition = getDriver(client.type)?.definition
|
||||
if (!definition) return client
|
||||
@@ -130,54 +246,213 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe
|
||||
return { ...client, config }
|
||||
}
|
||||
|
||||
// ─── Dispatch download vers tous les clients ──────────────────────────────────
|
||||
// ─── Pause après échecs de connexion ──────────────────────────────────────────
|
||||
// Client refusant l'authentification mis en pause pour éviter un bannissement d'IP
|
||||
|
||||
export async function dispatchDownload(url: string): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
|
||||
interface BackoffEntry {
|
||||
failCount : number
|
||||
nextRetry : number // timestamp ms
|
||||
}
|
||||
|
||||
const _backoff = new Map<string, BackoffEntry>()
|
||||
|
||||
const BACKOFF_DELAYS = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
|
||||
|
||||
function isInCooldown(uuid: string): boolean {
|
||||
const entry = _backoff.get(uuid)
|
||||
if (!entry) return false
|
||||
return Date.now() < entry.nextRetry
|
||||
}
|
||||
|
||||
function cooldownRemaining(uuid: string): string {
|
||||
const entry = _backoff.get(uuid)
|
||||
if (!entry) return ''
|
||||
const remaining = Math.max(0, entry.nextRetry - Date.now())
|
||||
const m = Math.ceil(remaining / 60_000)
|
||||
return m > 0 ? `${m} min` : ''
|
||||
}
|
||||
|
||||
function registerFailure(uuid: string, clientName: string) {
|
||||
const entry = _backoff.get(uuid) ?? { failCount: 0, nextRetry: 0 }
|
||||
entry.failCount = Math.min(entry.failCount + 1, BACKOFF_DELAYS.length - 1) + (entry.failCount === 0 ? 0 : 0)
|
||||
const delay = BACKOFF_DELAYS[Math.min(entry.failCount - 1, BACKOFF_DELAYS.length - 1)]
|
||||
entry.nextRetry = Date.now() + delay
|
||||
_backoff.set(uuid, entry)
|
||||
const min = Math.round(delay / 60_000)
|
||||
logger.warn('torrent-clients', `Client « ${clientName} » en pause ${min} min après ${entry.failCount} échec(s) de connexion`)
|
||||
}
|
||||
|
||||
function registerSuccess(uuid: string) {
|
||||
_backoff.delete(uuid)
|
||||
}
|
||||
|
||||
// Exposé pour l'API d'état
|
||||
export function getClientCooldowns(): Record<string, { failCount: number; nextRetry: number }> {
|
||||
const result: Record<string, { failCount: number; nextRetry: number }> = {}
|
||||
for (const [uuid, entry] of _backoff.entries()) {
|
||||
if (Date.now() < entry.nextRetry) result[uuid] = entry
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Correspondance des chemins ───────────────────────────────────────────────
|
||||
|
||||
// Remplace le préfixe remotePath par localPath (ex. /downloads/x devient /mnt/nas/downloads/x)
|
||||
function remapSavePath(savePath: string, remotePath: string, localPath: string): string {
|
||||
const remote = remotePath.replace(/\/+$/, '')
|
||||
const local = localPath.replace(/\/+$/, '')
|
||||
if (!remote || !local) return savePath
|
||||
if (!savePath.startsWith(remote)) return savePath
|
||||
return local + savePath.slice(remote.length)
|
||||
}
|
||||
|
||||
// ─── Envoi d'un téléchargement ────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchDownload(url: string, options?: DownloadOptions): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
|
||||
const clients = loadClients()
|
||||
|
||||
if (clients.length === 0) {
|
||||
logger.warn('torrent-clients', 'Aucun client torrent configuré : téléchargement impossible')
|
||||
return []
|
||||
}
|
||||
|
||||
const results = []
|
||||
|
||||
for (const client of clients) {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) {
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Driver "${client.type}" introuvable` })
|
||||
logger.error('torrent-clients', `Type de client « ${client.type} » non pris en charge (client « ${client.name} »)`)
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Type de client « ${client.type} » non pris en charge` })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await driver.add(client.config, url)
|
||||
await driver.add(client.config, url, options)
|
||||
if (options?.infohash) {
|
||||
trackManagedHash(client.uuid, options.infohash)
|
||||
logger.debug('torrent-clients', `Torrent ${options.infohash.slice(0, 8)}… suivi pour « ${client.name} »`)
|
||||
}
|
||||
logger.info('torrent-clients', `Téléchargement envoyé à « ${client.name} »`)
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: true })
|
||||
} catch (err) {
|
||||
results.push({
|
||||
uuid : client.uuid,
|
||||
name : client.name,
|
||||
ok : false,
|
||||
error: err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
})
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
logger.error('torrent-clients', `Échec de l'envoi à « ${client.name} » : ${msg}`)
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: false, error: msg })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ─── Dispatch list vers tous les clients ─────────────────────────────────────
|
||||
// ─── Suppression ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchList(category?: string): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> {
|
||||
export async function dispatchRemove(hash: string, deleteFiles = false): Promise<{ ok: boolean; error?: string }> {
|
||||
const clients = loadClients()
|
||||
const results: (TorrentInfo & { client_uuid: string; client_name: string })[] = []
|
||||
|
||||
let found = false
|
||||
for (const client of clients) {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) continue
|
||||
try {
|
||||
// La catégorie du client est prioritaire sur la catégorie globale
|
||||
await driver.remove(client.config, hash, deleteFiles)
|
||||
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de « ${client.name} »${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
untrackManagedHash(client.uuid, hash)
|
||||
found = true
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
logger.warn('torrent-clients', `Impossible de supprimer le torrent de « ${client.name} » : ${msg}`)
|
||||
}
|
||||
}
|
||||
return found ? { ok: true } : { ok: false, error: 'Torrent introuvable dans les clients torrent' }
|
||||
}
|
||||
|
||||
// Titre normalisé vers infohash, pour retrouver les torrents Synology
|
||||
function buildTitleIndex(infohashMap: Record<string, string>): Map<string, string> {
|
||||
const index = new Map<string, string>()
|
||||
for (const [hash, title] of Object.entries(infohashMap)) {
|
||||
index.set(title.toLowerCase().trim(), hash.toLowerCase())
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
export async function dispatchList(
|
||||
category? : string,
|
||||
infohashMap? : Record<string, string>,
|
||||
): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> {
|
||||
const clients = loadClients()
|
||||
const results: (TorrentInfo & { client_uuid: string; client_name: string })[] = []
|
||||
const titleIndex = infohashMap ? buildTitleIndex(infohashMap) : null
|
||||
|
||||
for (const client of clients) {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) continue
|
||||
|
||||
if (isInCooldown(client.uuid)) {
|
||||
const remaining = cooldownRemaining(client.uuid)
|
||||
logger.debug('torrent-clients', `Client « ${client.name} » en pause, nouvel essai dans ${remaining}`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const clientCategory = (client.config.category as string) || category
|
||||
const torrents = await driver.list(client.config, clientCategory)
|
||||
|
||||
registerSuccess(client.uuid)
|
||||
|
||||
const remotePath = String(client.config.remotePath ?? '')
|
||||
const localPath = String(client.config.localPath ?? '')
|
||||
const hasRemap = remotePath && localPath
|
||||
|
||||
for (const t of torrents) {
|
||||
results.push({ ...t, client_uuid: client.uuid, client_name: client.name })
|
||||
const save_path = hasRemap
|
||||
? remapSavePath(t.save_path, remotePath, localPath)
|
||||
: t.save_path
|
||||
|
||||
// Synology (dbid_xxx) : retrouver l'infohash par le titre
|
||||
let hash = t.hash
|
||||
if (titleIndex && t.hash.startsWith('dbid_')) {
|
||||
const resolved = titleIndex.get(t.name.toLowerCase().trim())
|
||||
if (resolved) {
|
||||
logger.debug('torrent-clients', `Hash Synology résolu : « ${t.name} » (${resolved.slice(0, 8)}…)`)
|
||||
hash = resolved
|
||||
}
|
||||
}
|
||||
|
||||
if (driver.definition.filterByManagedHashes) {
|
||||
const managed = getManagedSet(client.uuid)
|
||||
if (managed && !managed.has(hash)) continue
|
||||
}
|
||||
|
||||
results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[torrent-clients] list() échoué pour ${client.name}:`, err)
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
logger.error('torrent-clients', `Impossible de récupérer la liste des torrents de « ${client.name} » : ${msg}`)
|
||||
const lower = msg.toLowerCase()
|
||||
const isAuthError = ['login', 'auth', 'ban', 'forbidden', '401', '403', 'connexion refusée', 'refusé la connexion',
|
||||
'connexion à deluge refusée', 'clé api', 'échec de la connexion'].some(k => lower.includes(k))
|
||||
if (isAuthError) registerFailure(client.uuid, client.name)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ─── Progression par fichier ──────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchGetFiles(
|
||||
hash : string,
|
||||
clientUuid?: string,
|
||||
): Promise<TorrentFileProgress[]> {
|
||||
const clients = clientUuid
|
||||
? loadClients().filter(c => c.uuid === clientUuid)
|
||||
: loadClients()
|
||||
|
||||
for (const client of clients) {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver?.getFiles) continue
|
||||
try {
|
||||
const files = await driver.getFiles(client.config, hash)
|
||||
if (files.length > 0) return files
|
||||
} catch {}
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -1,37 +1,128 @@
|
||||
/**
|
||||
* qBittorrent Driver
|
||||
*/
|
||||
/** qBittorrent 4.x et 5.x (clé API à partir de 5.2.0). */
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// Réponse « Ok. », ou JSON derrière certains reverse proxies
|
||||
function isQbAddSuccess(text: string): boolean {
|
||||
if (text.trim() === 'Ok.') return true
|
||||
try {
|
||||
const json = JSON.parse(text)
|
||||
if (typeof json?.success_count === 'number') return json.success_count > 0
|
||||
} catch {}
|
||||
return false
|
||||
}
|
||||
|
||||
// Mapping état qBittorrent → état normalisé
|
||||
function mapState(state: string): TorrentInfo['state'] {
|
||||
if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading'
|
||||
if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding'
|
||||
if (['pausedDL', 'pausedUP'].includes(state)) return 'paused'
|
||||
if (['pausedDL', 'pausedUP', 'stoppedDL', 'stoppedUP'].includes(state)) return 'paused'
|
||||
if (['checkingDL', 'checkingUP', 'checkingResumeData'].includes(state)) return 'checking'
|
||||
if (state === 'error' || state === 'missingFiles') return 'error'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
async function qbLogin(config: Record<string, string | number>): Promise<string> {
|
||||
async function qbAuth(config: ClientConfig): Promise<Record<string, string>> {
|
||||
if (config.apiKey) {
|
||||
return { Authorization: `Bearer ${config.apiKey}` }
|
||||
}
|
||||
const cookie = await qbLogin(config)
|
||||
return { Cookie: cookie }
|
||||
}
|
||||
|
||||
async function qbLogin(config: ClientConfig): Promise<string> {
|
||||
const form = new URLSearchParams()
|
||||
form.append('username', String(config.username ?? ''))
|
||||
form.append('password', String(config.password ?? ''))
|
||||
|
||||
const res = await fetch(`${config.url}/api/v2/auth/login`, {
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/auth/login`, {
|
||||
method : 'POST',
|
||||
body : form,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
|
||||
if (!res.ok) throw new Error(`Échec de la connexion : ${text.trim() || `HTTP ${res.status}`}`)
|
||||
if (text.trim() === 'Fails.') throw new Error('Identifiants incorrects (vérifiez le nom d\'utilisateur et le mot de passe)')
|
||||
|
||||
const cookie = res.headers.get('set-cookie') ?? ''
|
||||
const sid = cookie.match(/SID=([^;]+)/)?.[1]
|
||||
if (!sid) throw new Error('Cookie SID introuvable')
|
||||
return sid
|
||||
const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0].trim()
|
||||
if (!cookie) throw new Error('Cookie de session introuvable')
|
||||
return cookie
|
||||
}
|
||||
|
||||
async function qbTorrentExists(config: ClientConfig, authHeaders: Record<string, string>, hash: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?hashes=${hash}`, {
|
||||
headers: authHeaders,
|
||||
})
|
||||
if (!res.ok) return false
|
||||
const list = await res.json()
|
||||
return Array.isArray(list) && list.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function qbApplyFilePriority(
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
|
||||
try {
|
||||
const authH = await qbAuth(config)
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) continue
|
||||
const files: any[] = await res.json()
|
||||
if (!Array.isArray(files) || files.length === 0) continue
|
||||
|
||||
if (fileIndex >= files.length) {
|
||||
throw new Error(`Fichier n° ${fileIndex} hors limites : le torrent contient ${files.length} fichiers`)
|
||||
}
|
||||
|
||||
// Premier épisode choisi : tout désactiver sauf la cible ; sinon activer seulement la cible
|
||||
const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0)
|
||||
|
||||
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
|
||||
if (isInitialState) {
|
||||
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
|
||||
if (unwanted.length > 0) {
|
||||
const body = new URLSearchParams({ hash, id: unwanted.join('|'), priority: '0' })
|
||||
const r = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body, headers })
|
||||
if (!r.ok) { logger.warn('qbittorrent', `Priorités non appliquées (HTTP ${r.status}), nouvel essai`); continue }
|
||||
}
|
||||
}
|
||||
|
||||
const r2 = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, {
|
||||
method: 'POST',
|
||||
body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }),
|
||||
headers,
|
||||
})
|
||||
if (!r2.ok) { logger.warn('qbittorrent', `Priorité du fichier cible non appliquée (HTTP ${r2.status}), nouvel essai`); continue }
|
||||
|
||||
const verif = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
|
||||
if (verif.ok) {
|
||||
const verifiedFiles: any[] = await verif.json()
|
||||
const target = verifiedFiles[fileIndex]
|
||||
if (!target || (target.priority ?? 0) === 0) {
|
||||
logger.warn('qbittorrent', `Fichier n° ${fileIndex} toujours à priorité 0 après application, nouvel essai`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('qbittorrent', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('hors limites')) throw err
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
|
||||
}
|
||||
|
||||
const QB: TorrentClientDriver = {
|
||||
@@ -39,72 +130,222 @@ const QB: TorrentClientDriver = {
|
||||
id : 'qbittorrent',
|
||||
label : 'qBittorrent',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'url', label: 'URL de la WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
||||
{ key: 'apiKey', label: 'Clé API (qBittorrent 5.2 ou plus)', type: 'password', placeholder: 'qbt_xxxx…', required: false },
|
||||
{ key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'admin', required: false },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
await qbLogin(config)
|
||||
const authH = await qbAuth(config)
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/app/version`, { headers: authH })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
return { ok: false, message: err instanceof Error ? err.message : 'Erreur inconnue' }
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
logger.warn('qbittorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const sid = await qbLogin(config)
|
||||
const res = await fetch(`${config.url}/api/v2/app/version`, {
|
||||
headers: { Cookie: `SID=${sid}` }
|
||||
const authH = await qbAuth(config)
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/app/version`, {
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) return { online: false }
|
||||
return { online: true, version: (await res.text()).trim() }
|
||||
} catch {
|
||||
const version = (await res.text()).trim()
|
||||
if (version.startsWith('<')) throw new Error('Réponse HTML reçue : clé API invalide ou non prise en charge par cette version de qBittorrent')
|
||||
logger.debug('qbittorrent', `Client en ligne (version ${version})`)
|
||||
return { online: true, version }
|
||||
} catch (err) {
|
||||
logger.debug('qbittorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, category) {
|
||||
const sid = await qbLogin(config)
|
||||
const authH = await qbAuth(config)
|
||||
const params = new URLSearchParams()
|
||||
if (category) params.set('category', category)
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
|
||||
headers: { Cookie: `SID=${sid}` }
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?${params}`, {
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) throw new Error(`qB list échoué: ${res.status}`)
|
||||
if (!res.ok) throw new Error(`Liste des torrents inaccessible (HTTP ${res.status})`)
|
||||
const data: any[] = await res.json()
|
||||
return data.map(t => ({
|
||||
hash : t.hash,
|
||||
name : t.name,
|
||||
state : mapState(t.state),
|
||||
progress : Math.round(t.progress * 100),
|
||||
size : t.size,
|
||||
downloaded: t.downloaded,
|
||||
speed : t.dlspeed,
|
||||
eta : t.eta ?? -1,
|
||||
save_path : t.save_path,
|
||||
category : t.category ?? '',
|
||||
} satisfies TorrentInfo))
|
||||
|
||||
const fileMap = new Map<string, any[]>()
|
||||
await Promise.all(data.map(async (t: any) => {
|
||||
try {
|
||||
const r = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
|
||||
headers: authH,
|
||||
})
|
||||
if (r.ok) fileMap.set(t.hash, await r.json())
|
||||
} catch {}
|
||||
}))
|
||||
|
||||
return data.map(t => {
|
||||
const fileInfo = fileMap.get(t.hash) ?? []
|
||||
return {
|
||||
hash : t.hash,
|
||||
name : t.name,
|
||||
state : mapState(t.state),
|
||||
progress : Math.round(t.progress * 100),
|
||||
size : t.size,
|
||||
downloaded: t.downloaded,
|
||||
uploaded : t.uploaded ?? 0,
|
||||
ratio : Math.round((t.ratio ?? 0) * 100) / 100,
|
||||
speed : t.dlspeed,
|
||||
upspeed : t.upspeed ?? 0,
|
||||
eta : t.eta ?? -1,
|
||||
save_path : t.save_path,
|
||||
category : t.category ?? '',
|
||||
files : fileInfo.length > 0
|
||||
? fileInfo.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
name : String(f.name ?? ''),
|
||||
progress: f.progress ?? 0, // 0–1
|
||||
priority: f.priority ?? 1,
|
||||
}))
|
||||
: undefined,
|
||||
} satisfies TorrentInfo
|
||||
})
|
||||
},
|
||||
|
||||
async add(config, url) {
|
||||
const sid = await qbLogin(config)
|
||||
const form = new FormData()
|
||||
form.append('urls', url)
|
||||
if (config.category) form.append('category', String(config.category))
|
||||
if (config.savePath) form.append('savepath', String(config.savePath))
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
method : 'POST',
|
||||
body : form,
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
async getFiles(config, hash) {
|
||||
const authH = await qbAuth(config)
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data: any[] = await res.json()
|
||||
return data.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
name : String(f.name ?? ''),
|
||||
progress: f.progress ?? 0,
|
||||
priority: f.priority ?? 1,
|
||||
}))
|
||||
},
|
||||
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const authH = await qbAuth(config)
|
||||
|
||||
const hash = (options?.infohash?.toLowerCase() ?? null)
|
||||
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|
||||
|| options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|
||||
|| null
|
||||
|
||||
if (options?.file_index != null) {
|
||||
if (hash) {
|
||||
const exists = await qbTorrentExists(config, authH, hash)
|
||||
if (exists) {
|
||||
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
|
||||
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('qbittorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Hashes présents avant l'ajout, pour repérer le nouveau torrent si le hash est inconnu
|
||||
let knownHashes: Set<string> = new Set()
|
||||
if (!hash) {
|
||||
try {
|
||||
const lr = await clientFetch(config, `${config.url}/api/v2/torrents/info`, { headers: authH })
|
||||
if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase()))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// .torrent récupéré par FanKarr : le client n'a pas forcément accès à Internet
|
||||
const isMagnet = url.startsWith('magnet:')
|
||||
let res: Response
|
||||
|
||||
if (isMagnet) {
|
||||
const params = new URLSearchParams()
|
||||
params.set('urls', url)
|
||||
if (config.category) params.set('category', String(config.category))
|
||||
if (config.savePath) params.set('savepath', String(config.savePath))
|
||||
res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, {
|
||||
method: 'POST', body: params,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
} else {
|
||||
logger.debug('qbittorrent', `Téléchargement du fichier .torrent par FanKarr : ${url.slice(0, 120)}`)
|
||||
const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } })
|
||||
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent (HTTP ${torrentRes.status}) : ${url}`)
|
||||
const torrentBytes = await torrentRes.arrayBuffer()
|
||||
const form = new FormData()
|
||||
if (config.category) form.append('category', String(config.category))
|
||||
if (config.savePath) form.append('savepath', String(config.savePath))
|
||||
form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent')
|
||||
res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH })
|
||||
}
|
||||
const text = await res.text()
|
||||
if (!isQbAddSuccess(text)) throw new Error(`qBittorrent a refusé le torrent : ${text}`)
|
||||
|
||||
if (hash) {
|
||||
logger.info('qbittorrent', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
|
||||
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('qbittorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
} else {
|
||||
logger.info('qbittorrent', `Torrent ajouté sans hash connu : recherche dans la liste (fichier n° ${options.file_index})`)
|
||||
const fileIndex = options.file_index
|
||||
;(async () => {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
const ah = await qbAuth(config)
|
||||
const lr = await clientFetch(config, `${config.url}/api/v2/torrents/info`, { headers: ah })
|
||||
if (!lr.ok) continue
|
||||
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
|
||||
if (!newT) continue
|
||||
const resolvedHash = String(newT.hash).toLowerCase()
|
||||
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}…, sélection du fichier n° ${fileIndex}`)
|
||||
await qbApplyFilePriority(config, resolvedHash, fileIndex)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
logger.warn('qbittorrent', `Hash introuvable : impossible de sélectionner le fichier n° ${fileIndex}`)
|
||||
})().catch(() => {})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('urls', url)
|
||||
if (config.category) params.set('category', String(config.category))
|
||||
if (config.savePath) params.set('savepath', String(config.savePath))
|
||||
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, {
|
||||
method: 'POST', body: params,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
const text = await res.text()
|
||||
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||
if (!isQbAddSuccess(text)) throw new Error(`qBittorrent a refusé le torrent : ${text}`)
|
||||
logger.info('qbittorrent', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
const authH = await qbAuth(config)
|
||||
const params = new URLSearchParams({ hashes: hash, deleteFiles: deleteFiles ? 'true' : 'false' })
|
||||
const res = await clientFetch(config, `${config.url}/api/v2/torrents/delete`, {
|
||||
method : 'POST',
|
||||
body : params,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
if (!res.ok) throw new Error(`Échec de la suppression (HTTP ${res.status})`)
|
||||
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default QB
|
||||
export default QB
|
||||
@@ -0,0 +1,233 @@
|
||||
/** Real-Debrid : torrents téléchargés côté RD, fichiers lus depuis le dossier local monté (rclone, WebDAV…). */
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
|
||||
|
||||
function rdHeaders(config: ClientConfig) {
|
||||
return {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
}
|
||||
|
||||
async function rdFetch(
|
||||
config : ClientConfig,
|
||||
method : 'GET' | 'POST' | 'DELETE',
|
||||
path : string,
|
||||
body ?: URLSearchParams,
|
||||
): Promise<any> {
|
||||
const res = await fetch(`${RD_BASE}${path}`, {
|
||||
method,
|
||||
headers: rdHeaders(config),
|
||||
body : body,
|
||||
})
|
||||
if (res.status === 204) return null
|
||||
if (res.status === 401 || res.status === 403) throw new Error('Clé API Real-Debrid invalide ou compte non premium')
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
throw new Error(`Real-Debrid a répondu HTTP ${res.status}${data?.error ? ` : ${data.error}` : ''}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function mapStatus(status: string): TorrentInfo['state'] {
|
||||
switch (status) {
|
||||
case 'downloaded': return 'seeding'
|
||||
case 'downloading':
|
||||
case 'queued':
|
||||
case 'compressing':
|
||||
case 'uploading':
|
||||
case 'magnet_conversion': return 'downloading'
|
||||
case 'waiting_files_selection': return 'paused'
|
||||
case 'magnet_error':
|
||||
case 'error':
|
||||
case 'virus':
|
||||
case 'dead': return 'error'
|
||||
default: return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const rdDriver: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'real-debrid',
|
||||
label : 'Real-Debrid',
|
||||
fields: [
|
||||
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
|
||||
{ key: 'localPath', label: 'Dossier où Real-Debrid est monté (rclone, WebDAV…)', type: 'text', placeholder: '/mnt/real-debrid', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
const user = await rdFetch(config, 'GET', '/user')
|
||||
logger.info('real-debrid', `Connexion réussie (utilisateur ${user.username})`)
|
||||
return { ok: true, message: `Connecté en tant que ${user.username}` }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
logger.warn('real-debrid', `Échec du test de connexion : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const user = await rdFetch(config, 'GET', '/user')
|
||||
return { online: true, version: `Compte ${user.username}` }
|
||||
} catch {
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const isMagnet = url.startsWith('magnet:')
|
||||
const hash = (options?.infohash?.toLowerCase() ?? null)
|
||||
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|
||||
|| null
|
||||
|
||||
if (hash) {
|
||||
try {
|
||||
const list: any[] = await rdFetch(config, 'GET', '/torrents')
|
||||
const existing = list.find((t: any) => t.hash?.toLowerCase() === hash)
|
||||
if (existing) {
|
||||
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur Real-Debrid`)
|
||||
if (options?.file_index != null && existing.status === 'waiting_files_selection') {
|
||||
const files: any[] = await rdFetch(config, 'GET', `/torrents/info/${existing.id}`)
|
||||
.then((info: any) => info.files ?? [])
|
||||
const fileId = files[options.file_index]?.id
|
||||
if (fileId) {
|
||||
await rdFetch(config, 'POST', `/torrents/selectFiles/${existing.id}`,
|
||||
new URLSearchParams({ files: String(fileId) }))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let torrentId: string
|
||||
|
||||
if (isMagnet) {
|
||||
const data = await rdFetch(config, 'POST', '/torrents/addMagnet',
|
||||
new URLSearchParams({ magnet: url }))
|
||||
torrentId = data.id
|
||||
logger.info('real-debrid', `Magnet ajouté (ID ${torrentId})`)
|
||||
} else {
|
||||
const torrentRes = await fetch(url)
|
||||
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent (HTTP ${torrentRes.status})`)
|
||||
const torrentBuf = await torrentRes.arrayBuffer()
|
||||
const formData = new FormData()
|
||||
formData.append('torrent', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
|
||||
const uploadRes = await fetch(`${RD_BASE}/torrents/addTorrent`, {
|
||||
method : 'PUT',
|
||||
headers: { Authorization: `Bearer ${config.apiKey}` },
|
||||
body : formData,
|
||||
})
|
||||
if (!uploadRes.ok) throw new Error(`Échec de l'envoi du fichier .torrent (HTTP ${uploadRes.status})`)
|
||||
const data = await uploadRes.json()
|
||||
torrentId = data.id
|
||||
logger.info('real-debrid', `Fichier .torrent envoyé (ID ${torrentId})`)
|
||||
}
|
||||
|
||||
if (options?.file_index != null) {
|
||||
let files: any[] = []
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
|
||||
if (info.status === 'waiting_files_selection' && Array.isArray(info.files) && info.files.length > 0) {
|
||||
files = info.files
|
||||
break
|
||||
}
|
||||
if (info.status === 'downloading' || info.status === 'downloaded') break
|
||||
} catch {}
|
||||
}
|
||||
if (files.length > 0 && options.file_index < files.length) {
|
||||
const fileId = files[options.file_index]?.id
|
||||
if (fileId) {
|
||||
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
|
||||
new URLSearchParams({ files: String(fileId) }))
|
||||
logger.info('real-debrid', `Fichier n° ${options.file_index} sélectionné pour le torrent Real-Debrid ${torrentId}`)
|
||||
}
|
||||
} else if (files.length > 0) {
|
||||
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
|
||||
new URLSearchParams({ files: 'all' }))
|
||||
}
|
||||
} else {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
|
||||
if (info.status === 'waiting_files_selection') {
|
||||
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
|
||||
new URLSearchParams({ files: 'all' }))
|
||||
break
|
||||
}
|
||||
if (info.status !== 'magnet_conversion') break
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, _category?) {
|
||||
const torrents: any[] = await rdFetch(config, 'GET', '/torrents')
|
||||
const localBase = String(config.localPath ?? '').replace(/\/+$/, '')
|
||||
|
||||
return torrents.map((t: any): TorrentInfo => {
|
||||
const progress = typeof t.progress === 'number' ? t.progress / 100 : 0
|
||||
// save_path virtuel : dossier monté et nom du torrent
|
||||
const save_path = localBase ? `${localBase}/${t.filename ?? t.id}` : ''
|
||||
|
||||
const files: TorrentFileProgress[] | undefined =
|
||||
Array.isArray(t.files) && t.files.length > 0
|
||||
? t.files.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
name : String(f.path ?? f.name ?? ''),
|
||||
progress: f.selected ? progress : 0,
|
||||
}))
|
||||
: undefined
|
||||
|
||||
return {
|
||||
hash : String(t.hash ?? t.id ?? '').toLowerCase(),
|
||||
name : String(t.filename ?? t.id ?? ''),
|
||||
state : mapStatus(t.status ?? ''),
|
||||
progress : Math.round(progress * 100),
|
||||
size : typeof t.bytes === 'number' ? t.bytes : 0,
|
||||
downloaded: typeof t.bytes === 'number' ? Math.round(t.bytes * progress) : 0,
|
||||
uploaded : 0,
|
||||
ratio : 0,
|
||||
speed : typeof t.speed === 'number' ? t.speed : 0,
|
||||
upspeed : 0,
|
||||
eta : -1,
|
||||
save_path,
|
||||
category : '',
|
||||
files,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async remove(config, hash, _deleteFiles?) {
|
||||
const list: any[] = await rdFetch(config, 'GET', '/torrents')
|
||||
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
|
||||
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur Real-Debrid`)
|
||||
await rdFetch(config, 'DELETE', `/torrents/delete/${target.id}`)
|
||||
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… supprimé de Real-Debrid`)
|
||||
},
|
||||
|
||||
async getFiles(config, hash) {
|
||||
const list: any[] = await rdFetch(config, 'GET', '/torrents')
|
||||
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
|
||||
if (!target) return []
|
||||
const info = await rdFetch(config, 'GET', `/torrents/info/${target.id}`)
|
||||
const overallProgress = typeof info.progress === 'number' ? info.progress / 100 : 0
|
||||
if (!Array.isArray(info.files)) return []
|
||||
return info.files.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
name : String(f.path ?? f.name ?? ''),
|
||||
progress: f.selected ? overallProgress : 0,
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
export default rdDriver
|
||||
@@ -0,0 +1,325 @@
|
||||
/** rTorrent via XML-RPC (ruTorrent ou rTorrent seul, sur /RPC2 ou /XMLRPC). */
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// ─── XML-RPC ──────────────────────────────────────────────────
|
||||
|
||||
function xmlValue(val: any): string {
|
||||
if (typeof val === 'string') return `<value><string>${val}</string></value>`
|
||||
if (typeof val === 'number') return `<value><i8>${val}</i8></value>`
|
||||
if (typeof val === 'boolean') return `<value><boolean>${val ? 1 : 0}</boolean></value>`
|
||||
if (Array.isArray(val)) return `<value><array><data>${val.map(xmlValue).join('')}</data></array></value>`
|
||||
return `<value><string>${String(val)}</string></value>`
|
||||
}
|
||||
|
||||
function xmlCall(method: string, params: any[] = []): string {
|
||||
return `<?xml version="1.0"?>
|
||||
<methodCall>
|
||||
<methodName>${method}</methodName>
|
||||
<params>${params.map(p => `<param>${xmlValue(p)}</param>`).join('')}</params>
|
||||
</methodCall>`
|
||||
}
|
||||
|
||||
function parseXmlValue(node: Element): any {
|
||||
const child = node.firstElementChild
|
||||
if (!child) return node.textContent ?? ''
|
||||
switch (child.tagName) {
|
||||
case 'string' : return child.textContent ?? ''
|
||||
case 'int' :
|
||||
case 'i4' :
|
||||
case 'i8' : return parseInt(child.textContent ?? '0', 10)
|
||||
case 'double' : return parseFloat(child.textContent ?? '0')
|
||||
case 'boolean' : return child.textContent === '1'
|
||||
case 'array' : {
|
||||
const data = child.querySelector('data')
|
||||
if (!data) return []
|
||||
return Array.from(data.children).map(parseXmlValue)
|
||||
}
|
||||
case 'struct' : {
|
||||
const result: Record<string, any> = {}
|
||||
for (const member of Array.from(child.querySelectorAll(':scope > member'))) {
|
||||
const name = member.querySelector(':scope > name')?.textContent ?? ''
|
||||
const value = member.querySelector(':scope > value')
|
||||
if (name && value) result[name] = parseXmlValue(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
default: return child.textContent ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
async function rpcCall(
|
||||
config : ClientConfig,
|
||||
method : string,
|
||||
params : any[] = [],
|
||||
): Promise<any> {
|
||||
const body = xmlCall(method, params)
|
||||
const headers : Record<string, string> = { 'Content-Type': 'text/xml' }
|
||||
|
||||
if (config.username && config.password) {
|
||||
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
|
||||
}
|
||||
|
||||
const rpcPath = String(config.rpcPath || '/RPC2')
|
||||
const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}`
|
||||
|
||||
const res = await clientFetch(config, url, { method: 'POST', headers, body })
|
||||
if (!res.ok) throw new Error(`rTorrent a répondu HTTP ${res.status} : vérifiez l'URL et le chemin RPC`)
|
||||
|
||||
const text = await res.text()
|
||||
|
||||
// Pas de DOMParser sous Node : analyse XML minimale
|
||||
const faultMatch = text.match(/<name>faultString<\/name>\s*<value><string>([^<]*)<\/string>/)
|
||||
if (faultMatch) throw new Error(`Erreur rTorrent : ${faultMatch[1]}`)
|
||||
|
||||
const valueMatch = text.match(/<methodResponse>\s*<params>\s*<param>\s*<value>([\s\S]*?)<\/value>\s*<\/param>/)
|
||||
if (!valueMatch) return null
|
||||
|
||||
// Types courants uniquement
|
||||
const inner = valueMatch[1].trim()
|
||||
|
||||
if (inner.startsWith('<array>')) {
|
||||
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
|
||||
return items.map(m => parseInnerValue(m[1].trim()))
|
||||
}
|
||||
|
||||
return parseInnerValue(inner)
|
||||
}
|
||||
|
||||
function parseInnerValue(inner: string): any {
|
||||
if (inner.startsWith('<array>')) {
|
||||
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
|
||||
return items.map(m => parseInnerValue(m[1].trim()))
|
||||
}
|
||||
const strMatch = inner.match(/^<string>([\s\S]*?)<\/string>$/)
|
||||
if (strMatch) return strMatch[1]
|
||||
const intMatch = inner.match(/^<(?:i4|i8|int)>([\s\S]*?)<\/(?:i4|i8|int)>$/)
|
||||
if (intMatch) return parseInt(intMatch[1], 10)
|
||||
// Valeur brute sans tag
|
||||
if (!inner.includes('<')) return inner
|
||||
return inner
|
||||
}
|
||||
|
||||
async function d_multicall(
|
||||
config : ClientConfig,
|
||||
view : string,
|
||||
methods: string[],
|
||||
): Promise<any[][]> {
|
||||
const params = [view, ...methods.map(m => `${m}=`)]
|
||||
const result = await rpcCall(config, 'd.multicall2', ['', ...params])
|
||||
return Array.isArray(result) ? result : []
|
||||
}
|
||||
|
||||
async function rtTorrentExists(config: ClientConfig, hash: string): Promise<boolean> {
|
||||
try {
|
||||
const rows = await d_multicall(config, 'main', ['d.hash'])
|
||||
return rows.some(r => String(r[0]).toLowerCase() === hash.toLowerCase())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Attend les métadonnées puis applique les priorités, avant le début du téléchargement. */
|
||||
async function rtApplyFilePriority(
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
try {
|
||||
const files: any[] = await rpcCall(config, 'f.multicall', [hash, '', 'f.size_bytes=', 'f.priority='])
|
||||
const fileCount = Array.isArray(files) ? files.length : 0
|
||||
if (fileCount === 0) continue
|
||||
|
||||
// Premier épisode choisi : tout désactiver sauf la cible ; sinon activer seulement la cible
|
||||
const isInitialState = files.every((f: any) => Number(f[1]) > 0)
|
||||
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
if (i === fileIndex) {
|
||||
await rpcCall(config, 'f.priority.set', [hash, i, 1])
|
||||
} else if (isInitialState) {
|
||||
await rpcCall(config, 'f.priority.set', [hash, i, 0])
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('rtorrent', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
|
||||
throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
|
||||
}
|
||||
|
||||
// ─── États ────────────────────────────────────────────────────
|
||||
function mapState(isOpen: number, isActive: number, isChecking: number, isComplete: number): TorrentInfo['state'] {
|
||||
if (isChecking) return 'checking'
|
||||
if (!isOpen) return 'paused'
|
||||
if (!isActive) return 'paused'
|
||||
if (isComplete) return 'seeding'
|
||||
return 'downloading'
|
||||
}
|
||||
|
||||
const RT: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'rtorrent',
|
||||
label : 'rTorrent',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
||||
{ key: 'rpcPath', label: 'Chemin RPC', type: 'text', placeholder: '/RPC2', required: false, default: '/RPC2' },
|
||||
{ key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'admin', required: false },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
|
||||
{ key: 'category', label: 'Catégorie (label)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
const version = await rpcCall(config, 'system.client_version')
|
||||
logger.info('rtorrent', `Test de connexion réussi sur ${config.url} (version ${version})`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
logger.warn('rtorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const version = await rpcCall(config, 'system.client_version')
|
||||
logger.debug('rtorrent', `Client en ligne (version ${version})`)
|
||||
return { online: true, version: String(version) }
|
||||
} catch (err) {
|
||||
logger.debug('rtorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, category) {
|
||||
const methods = [
|
||||
'd.hash',
|
||||
'd.name',
|
||||
'd.is_open',
|
||||
'd.is_active',
|
||||
'd.is_hash_checking',
|
||||
'd.complete',
|
||||
'd.size_bytes',
|
||||
'd.bytes_done',
|
||||
'd.up.total',
|
||||
'd.ratio', // ratio × 1000
|
||||
'd.down.rate',
|
||||
'd.up.rate',
|
||||
'd.left_bytes',
|
||||
'd.directory',
|
||||
'd.custom1',
|
||||
]
|
||||
|
||||
const rows = await d_multicall(config, 'main', methods)
|
||||
|
||||
const fileMap = new Map<string, any[]>()
|
||||
await Promise.all(rows.map(async (r: any) => {
|
||||
const hash = String(r[0]).toUpperCase()
|
||||
try {
|
||||
const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path=', 'f.priority='])
|
||||
if (Array.isArray(files) && files.length > 0) fileMap.set(hash.toLowerCase(), files)
|
||||
} catch {}
|
||||
}))
|
||||
|
||||
return rows
|
||||
.map(r => {
|
||||
const [hash, name, isOpen, isActive, isChecking, isComplete,
|
||||
size, downloaded, uploadedTotal, ratioRaw,
|
||||
dlSpeed, ulSpeed, leftBytes, directory, label] = r
|
||||
|
||||
const progress = size > 0 ? Math.min(100, Math.round(((size - leftBytes) / size) * 100)) : 0
|
||||
const eta = Number(dlSpeed) > 0 && Number(leftBytes) > 0 ? Math.round(Number(leftBytes) / Number(dlSpeed)) : -1
|
||||
const hashLower = String(hash).toLowerCase()
|
||||
const fileInfo = fileMap.get(hashLower) ?? []
|
||||
|
||||
return {
|
||||
hash : hashLower,
|
||||
name : String(name),
|
||||
state : mapState(Number(isOpen), Number(isActive), Number(isChecking), Number(isComplete)),
|
||||
progress,
|
||||
size : Number(size),
|
||||
downloaded: Number(downloaded),
|
||||
uploaded : Number(uploadedTotal ?? 0),
|
||||
ratio : Math.round((Number(ratioRaw ?? 0) / 1000) * 100) / 100,
|
||||
speed : Number(dlSpeed),
|
||||
upspeed : Number(ulSpeed ?? 0),
|
||||
eta,
|
||||
save_path : String(directory),
|
||||
category : String(label ?? ''),
|
||||
files : fileInfo.length > 0
|
||||
? fileInfo.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
name : String(f[2] ?? ''),
|
||||
progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0,
|
||||
priority: Number(f[3] ?? 1) === 0 ? 0 : 1,
|
||||
}))
|
||||
: undefined,
|
||||
} satisfies TorrentInfo
|
||||
})
|
||||
.filter(t => {
|
||||
if (!category) return true
|
||||
return t.category === category
|
||||
})
|
||||
},
|
||||
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
const hash = hashMatch?.[1]?.toUpperCase() ?? null
|
||||
|
||||
if (options?.file_index != null && hash) {
|
||||
const exists = await rtTorrentExists(config, hash)
|
||||
|
||||
if (exists) {
|
||||
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
|
||||
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('rtorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Nouveau torrent : priorités appliquées dès que load.start a récupéré les métadonnées
|
||||
const args: any[] = ['', url]
|
||||
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
|
||||
if (config.category) args.push(`d.custom1.set=${config.category}`)
|
||||
await rpcCall(config, 'load.start', args)
|
||||
|
||||
logger.info('rtorrent', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
|
||||
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('rtorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const args: any[] = ['', url]
|
||||
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
|
||||
if (config.category) args.push(`d.custom1.set=${config.category}`)
|
||||
await rpcCall(config, 'load.start', args)
|
||||
|
||||
logger.info('rtorrent', `Torrent ajouté${config.savePath ? ` (dossier : ${config.savePath})` : ''}`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
const h = hash.toUpperCase()
|
||||
if (deleteFiles) {
|
||||
await rpcCall(config, 'd.delete_tied', [h])
|
||||
await rpcCall(config, 'd.erase', [h])
|
||||
} else {
|
||||
await rpcCall(config, 'd.erase', [h])
|
||||
}
|
||||
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default RT
|
||||
@@ -0,0 +1,377 @@
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
|
||||
// ─── Cache URI et infohash ─────────────────────────────────────────────────────
|
||||
// DSM anciens : pas de hash BT dans l'API, on mémorise l'URI et son infohash à l'ajout
|
||||
|
||||
const URI_HASH_PATH = path.join(DATA_DIR, 'synology_uri_hash.json')
|
||||
|
||||
// ─── Cache hash BT et ID Synology (en mémoire) ────────────────────────────────
|
||||
// Hash BT vers dbid_X, pour que remove() et getFiles() retrouvent la tâche
|
||||
const _hashToSynoId = new Map<string, string>()
|
||||
|
||||
function loadUriHashMap(): Record<string, string> {
|
||||
try {
|
||||
if (fs.existsSync(URI_HASH_PATH)) return JSON.parse(fs.readFileSync(URI_HASH_PATH, 'utf-8'))
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
|
||||
const _uriToHash: Map<string, string> = (() => {
|
||||
const m = new Map<string, string>()
|
||||
for (const [k, v] of Object.entries(loadUriHashMap())) m.set(k, v)
|
||||
return m
|
||||
})()
|
||||
|
||||
function storeUriHash(uri: string, hash: string) {
|
||||
const key = uri.toLowerCase()
|
||||
if (_uriToHash.get(key) === hash) return
|
||||
_uriToHash.set(key, hash)
|
||||
try {
|
||||
const map = loadUriHashMap()
|
||||
map[key] = hash
|
||||
fs.mkdirSync(path.dirname(URI_HASH_PATH), { recursive: true })
|
||||
fs.writeFileSync(URI_HASH_PATH, JSON.stringify(map, null, 2), 'utf-8')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function resolveHash(detailHash: string, detailUri: string, taskId: string): string {
|
||||
// 1. Hash direct depuis l'API Synology (DSM récent)
|
||||
if (detailHash.length > 0) return detailHash.toLowerCase()
|
||||
|
||||
// 2. Hash extrait du magnet de detail.uri
|
||||
if (detailUri.startsWith('magnet:')) {
|
||||
const m = detailUri.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||
if (m) return m[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 3. Cache URI mémorisé à l'ajout
|
||||
if (detailUri) {
|
||||
const cached = _uriToHash.get(detailUri.toLowerCase())
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
// 4. À défaut : ID Synology natif (dbid_X)
|
||||
logger.debug('synology-ds', `Hash BT non résolu, ID Synology utilisé : ${taskId}`)
|
||||
return String(taskId ?? '').toLowerCase()
|
||||
}
|
||||
|
||||
// ─── États ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function mapState(status: string): TorrentInfo['state'] {
|
||||
if (status === 'downloading') return 'downloading'
|
||||
if (status === 'seeding' || status === 'finished') return 'seeding'
|
||||
if (status === 'finishing' || status === 'extracting') return 'seeding'
|
||||
if (status === 'paused') return 'paused'
|
||||
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
|
||||
if (status === 'hash_checking') return 'checking'
|
||||
if (status === 'error') return 'error'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
// ─── Authentification ──────────────────────────────────────────────────────────
|
||||
|
||||
const AUTH_ERRORS: Record<number, string> = {
|
||||
400: 'nom d\'utilisateur ou mot de passe incorrect',
|
||||
401: 'compte désactivé',
|
||||
402: 'accès refusé',
|
||||
403: 'code de validation en deux étapes requis',
|
||||
404: 'code de validation en deux étapes refusé',
|
||||
}
|
||||
|
||||
const TASK_ERRORS: Record<number, string> = {
|
||||
105: 'accès refusé',
|
||||
106: 'session expirée',
|
||||
400: 'envoi du fichier refusé',
|
||||
401: 'nombre maximal de tâches atteint',
|
||||
402: 'accès au dossier de destination refusé',
|
||||
403: 'dossier de destination introuvable',
|
||||
406: 'aucun dossier de destination par défaut',
|
||||
408: 'fichier introuvable',
|
||||
}
|
||||
|
||||
function dsErrorText(error: any, table: Record<number, string>): string {
|
||||
const code = error?.code
|
||||
return table[code] ? `${table[code]} (code ${code})` : `code ${code ?? 'inconnu'}`
|
||||
}
|
||||
|
||||
async function dsLogin(config: ClientConfig): Promise<string> {
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.API.Auth',
|
||||
version: '3',
|
||||
method : 'login',
|
||||
account: String(config.username ?? ''),
|
||||
passwd : String(config.password ?? ''),
|
||||
session: 'DownloadStation',
|
||||
format : 'sid',
|
||||
})
|
||||
const res = await clientFetch(config, `${config.url}/webapi/auth.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`Synology a répondu HTTP ${res.status} : vérifiez l'URL de DSM`)
|
||||
const data = await res.json()
|
||||
if (!data.success) throw new Error(`Connexion refusée par Synology : ${dsErrorText(data.error, AUTH_ERRORS)}`)
|
||||
return data.data.sid
|
||||
}
|
||||
|
||||
async function dsRequest(
|
||||
config : ClientConfig,
|
||||
api : string,
|
||||
method : string,
|
||||
version: string,
|
||||
extra : Record<string, string> = {},
|
||||
sid : string,
|
||||
): Promise<any> {
|
||||
const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra })
|
||||
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`Download Station a répondu HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
if (!data.success) throw new Error(`Erreur Download Station : ${dsErrorText(data.error, TASK_ERRORS)}`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
// ─── Client ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DS: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'synology-ds',
|
||||
label : 'Synology Download Station',
|
||||
filterByManagedHashes: true,
|
||||
fields : [
|
||||
{ key: 'url', label: 'URL de DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
|
||||
{ key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'admin', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/volume1/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
await dsLogin(config)
|
||||
logger.info('synology-ds', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
logger.warn('synology-ds', `Échec du test de connexion sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const sid = await dsLogin(config)
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.DownloadStation.Info',
|
||||
version: '2',
|
||||
method : 'getinfo',
|
||||
_sid : sid,
|
||||
})
|
||||
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/info.cgi?${params}`)
|
||||
if (!res.ok) return { online: false }
|
||||
const data = await res.json()
|
||||
if (!data.success) return { online: false }
|
||||
const version = data.data?.version_string ?? 'inconnue'
|
||||
return { online: true, version }
|
||||
} catch {
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, category) {
|
||||
const sid = await dsLogin(config)
|
||||
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
|
||||
{ additional: 'transfer,detail,file' }, sid)
|
||||
|
||||
const tasks: any[] = data?.tasks ?? []
|
||||
|
||||
// Pas de catégories chez Synology : filtre sur le dossier de téléchargement s'il est renseigné
|
||||
const normalizedSavePath = String(config.savePath ?? '').replace(/\/+$/, '').toLowerCase()
|
||||
const filtered = normalizedSavePath
|
||||
? tasks.filter(t => {
|
||||
const dest = String(t.additional?.detail?.destination ?? '').replace(/\/+$/, '').toLowerCase()
|
||||
return dest === normalizedSavePath || dest.startsWith(normalizedSavePath + '/')
|
||||
})
|
||||
: tasks
|
||||
|
||||
return filtered.map(t => {
|
||||
const transfer = t.additional?.transfer ?? {}
|
||||
const detail = t.additional?.detail ?? {}
|
||||
const fileList: any[] = t.additional?.file ?? []
|
||||
const size = t.size ?? 0
|
||||
const dl = transfer.size_downloaded ?? 0
|
||||
|
||||
const hash = resolveHash(
|
||||
String(detail.hash ?? '').trim(),
|
||||
String(detail.uri ?? '').trim(),
|
||||
String(t.id ?? ''),
|
||||
)
|
||||
|
||||
const synoIdStr = String(t.id ?? '').toLowerCase()
|
||||
if (!hash.startsWith('dbid_') && synoIdStr) {
|
||||
_hashToSynoId.set(hash, synoIdStr)
|
||||
}
|
||||
|
||||
const files = fileList.length > 0
|
||||
? fileList.map((f: any, i: number) => {
|
||||
const fSize = typeof f.size === 'number' ? f.size : 0
|
||||
const fDl = typeof f.size_downloaded === 'number' ? f.size_downloaded : 0
|
||||
return {
|
||||
index : i,
|
||||
name : String(f.filename ?? ''),
|
||||
progress: fSize > 0 ? fDl / fSize : 0,
|
||||
priority: String(f.priority ?? 'normal') === 'skip' ? 0 : 1,
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
|
||||
return {
|
||||
hash,
|
||||
name : t.title,
|
||||
state : mapState(t.status),
|
||||
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
|
||||
size,
|
||||
downloaded: dl,
|
||||
uploaded : transfer.size_uploaded ?? 0,
|
||||
ratio : size > 0 ? Math.round(((transfer.size_uploaded ?? 0) / size) * 100) / 100 : 0,
|
||||
speed : transfer.speed_download ?? 0,
|
||||
upspeed : transfer.speed_upload ?? 0,
|
||||
eta : -1,
|
||||
save_path : detail.destination ?? '',
|
||||
category : category ?? '',
|
||||
files,
|
||||
} satisfies TorrentInfo
|
||||
})
|
||||
},
|
||||
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
if (options?.file_index != null)
|
||||
logger.warn('synology-ds', 'Download Station ne permet pas de choisir un fichier : torrent complet téléchargé')
|
||||
|
||||
if (options?.infohash) {
|
||||
storeUriHash(url, options.infohash)
|
||||
logger.debug('synology-ds', `Hash mémorisé pour l'URI : ${options.infohash.slice(0, 8)}… (${url.slice(-50)})`)
|
||||
}
|
||||
|
||||
const sid = await dsLogin(config)
|
||||
|
||||
// IDs existants avant l'ajout, pour repérer la nouvelle tâche
|
||||
const beforeIds = new Set<string>()
|
||||
try {
|
||||
const before = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
|
||||
for (const t of before?.tasks ?? []) beforeIds.add(String(t.id))
|
||||
} catch {}
|
||||
|
||||
const doAdd = async (withDestination: boolean) => {
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.DownloadStation.Task',
|
||||
version: '1',
|
||||
method : 'create',
|
||||
_sid : sid,
|
||||
uri : url,
|
||||
})
|
||||
if (withDestination && config.savePath) params.append('destination', String(config.savePath))
|
||||
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`Download Station a répondu HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
let data = await doAdd(true)
|
||||
|
||||
// Code 403 : dossier introuvable sur le NAS, nouvel essai sans destination
|
||||
if (!data.success && data.error?.code === 403 && config.savePath) {
|
||||
logger.warn('synology-ds', `Dossier « ${config.savePath} » introuvable sur le NAS (code 403) : ajout dans le dossier par défaut`)
|
||||
data = await doAdd(false)
|
||||
}
|
||||
|
||||
if (!data.success) throw new Error(`Download Station a refusé le torrent : ${dsErrorText(data.error, TASK_ERRORS)}`)
|
||||
|
||||
if (options?.infohash) {
|
||||
try {
|
||||
const after = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
|
||||
const newTask = (after?.tasks ?? []).find((t: any) => !beforeIds.has(String(t.id)))
|
||||
if (newTask) {
|
||||
const synoId = String(newTask.id).toLowerCase()
|
||||
_hashToSynoId.set(options.infohash.toLowerCase(), synoId)
|
||||
logger.debug('synology-ds', `ID Synology ${synoId} associé à ${options.infohash.slice(0, 8)}…`)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
logger.info('synology-ds', `Torrent ajouté${config.savePath ? ` (dossier : ${config.savePath})` : ''}`)
|
||||
},
|
||||
|
||||
async getFiles(config, hash) {
|
||||
const sid = await dsLogin(config)
|
||||
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
|
||||
{ additional: 'detail,file' }, sid)
|
||||
const tasks: any[] = data?.tasks ?? []
|
||||
|
||||
const h = hash.toLowerCase()
|
||||
const synoIdFromHash = _hashToSynoId.get(h) ?? ''
|
||||
const task = tasks.find((t: any) => {
|
||||
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
|
||||
const detailUri = String(t.additional?.detail?.uri ?? '').toLowerCase()
|
||||
const synoId = String(t.id ?? '').toLowerCase()
|
||||
const cached = _uriToHash.get(detailUri) ?? ''
|
||||
return detailHash === h
|
||||
|| synoId === h
|
||||
|| cached === h
|
||||
|| (synoIdFromHash && synoId === synoIdFromHash)
|
||||
})
|
||||
|
||||
if (!task) return []
|
||||
const fileList: any[] = task.additional?.file ?? []
|
||||
return fileList.map((f: any, i: number) => {
|
||||
const fSize = typeof f.size === 'number' ? f.size : 0
|
||||
const fDl = typeof f.size_downloaded === 'number' ? f.size_downloaded : 0
|
||||
return {
|
||||
index : i,
|
||||
name : String(f.filename ?? ''),
|
||||
progress: fSize > 0 ? fDl / fSize : 0,
|
||||
priority: String(f.priority ?? 'normal') === 'skip' ? 0 : 1,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async remove(config, hash, _deleteFiles?) {
|
||||
const sid = await dsLogin(config)
|
||||
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', { additional: 'detail' }, sid)
|
||||
const tasks: any[] = data?.tasks ?? []
|
||||
|
||||
const h = hash.toLowerCase()
|
||||
const synoIdFromHash = _hashToSynoId.get(h) ?? ''
|
||||
const found = tasks.find((t: any) => {
|
||||
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
|
||||
const detailUri = String(t.additional?.detail?.uri ?? '').toLowerCase()
|
||||
const synoId = String(t.id ?? '').toLowerCase()
|
||||
const cachedHash = _uriToHash.get(detailUri) ?? ''
|
||||
return detailHash === h
|
||||
|| synoId === h
|
||||
|| cachedHash === h
|
||||
|| (synoIdFromHash && synoId === synoIdFromHash)
|
||||
})
|
||||
|
||||
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.DownloadStation.Task',
|
||||
version : '1',
|
||||
method : 'delete',
|
||||
id : found.id,
|
||||
force_complete: 'false',
|
||||
_sid : sid,
|
||||
})
|
||||
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`Échec de la suppression (HTTP ${res.status})`)
|
||||
logger.info('synology-ds', `Torrent ${found.id} supprimé`)
|
||||
},
|
||||
}
|
||||
|
||||
export default DS
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
function mapState(status: number): TorrentInfo['state'] {
|
||||
// 0 arrêté, 1-2 vérification (attente, en cours), 3-4 téléchargement (attente, en cours), 5-6 partage (attente, en cours)
|
||||
if (status === 4 || status === 3) return 'downloading'
|
||||
if (status === 6 || status === 5) return 'seeding'
|
||||
if (status === 0) return 'paused'
|
||||
if (status === 1 || status === 2) return 'checking'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
async function trRequest(
|
||||
config: ClientConfig,
|
||||
method: string,
|
||||
args: Record<string, unknown> = {},
|
||||
sessionId = ''
|
||||
): Promise<{ result: string; arguments?: any; sessionId?: string }> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type' : 'application/json',
|
||||
'X-Transmission-Session-Id': sessionId,
|
||||
}
|
||||
|
||||
if (config.username && config.password) {
|
||||
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
|
||||
}
|
||||
|
||||
const res = await clientFetch(config, `${config.url}/transmission/rpc`, {
|
||||
method : 'POST',
|
||||
headers,
|
||||
body : JSON.stringify({ method, arguments: args }),
|
||||
})
|
||||
|
||||
// Première requête : 409 avec l'identifiant de session
|
||||
if (res.status === 409) {
|
||||
const newSessionId = res.headers.get('X-Transmission-Session-Id') ?? ''
|
||||
if (!newSessionId) throw new Error('Identifiant de session absent de la réponse de Transmission (HTTP 409)')
|
||||
return trRequest(config, method, args, newSessionId)
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(`Transmission a répondu HTTP ${res.status}`)
|
||||
|
||||
const data = await res.json()
|
||||
if (data.result !== 'success') throw new Error(`Erreur Transmission : ${data.result}`)
|
||||
|
||||
return { ...data, sessionId }
|
||||
}
|
||||
|
||||
async function trGetIdByHash(config: ClientConfig, hash: string): Promise<number | null> {
|
||||
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
|
||||
const torrents = data.arguments?.torrents ?? []
|
||||
return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null
|
||||
}
|
||||
|
||||
async function trApplyFilePriority(
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
const id = await trGetIdByHash(config, hash)
|
||||
if (id == null) continue
|
||||
|
||||
const data = await trRequest(config, 'torrent-get', { ids: [id], fields: ['files'] })
|
||||
const files = data.arguments?.torrents?.[0]?.files
|
||||
if (!Array.isArray(files) || files.length === 0) continue
|
||||
|
||||
const unwanted = files.map((_: any, i: number) => i).filter((i: number) => i !== fileIndex)
|
||||
await trRequest(config, 'torrent-set', {
|
||||
ids : [id],
|
||||
'files-wanted' : [fileIndex],
|
||||
'files-unwanted' : unwanted,
|
||||
})
|
||||
logger.info('transmission', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
|
||||
}
|
||||
|
||||
const TR: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'transmission',
|
||||
label : 'Transmission',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true },
|
||||
{ key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'transmission', required: false },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
await trRequest(config, 'session-get')
|
||||
logger.info('transmission', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
logger.warn('transmission', `Échec du test de connexion sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const data = await trRequest(config, 'session-get')
|
||||
const version = data.arguments?.version ?? 'inconnue'
|
||||
logger.debug('transmission', `Client en ligne (version ${version})`)
|
||||
return { online: true, version }
|
||||
} catch (err) {
|
||||
logger.debug('transmission', `Client injoignable : ${err instanceof Error ? err.message : err}`)
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, category) {
|
||||
const fields = [
|
||||
'hashString', 'name', 'status', 'percentDone', 'totalSize',
|
||||
'downloadedEver', 'uploadedEver', 'uploadRatio',
|
||||
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
|
||||
'files', // bytesCompleted et length par fichier (fileStats n'a pas length)
|
||||
]
|
||||
const data = await trRequest(config, 'torrent-get', { fields })
|
||||
const torrents: any[] = data.arguments?.torrents ?? []
|
||||
|
||||
return torrents
|
||||
.filter(t => {
|
||||
if (!category) return true
|
||||
return t.labels?.includes(category)
|
||||
})
|
||||
.map(t => ({
|
||||
hash : t.hashString,
|
||||
name : t.name,
|
||||
state : mapState(t.status),
|
||||
progress : Math.round(t.percentDone * 100),
|
||||
size : t.totalSize,
|
||||
downloaded: t.downloadedEver,
|
||||
uploaded : t.uploadedEver ?? 0,
|
||||
ratio : Math.round((t.uploadRatio ?? 0) * 100) / 100,
|
||||
speed : t.rateDownload,
|
||||
upspeed : t.rateUpload ?? 0,
|
||||
eta : t.eta ?? -1,
|
||||
save_path : t.downloadDir,
|
||||
category : t.labels?.[0] ?? '',
|
||||
files : Array.isArray(t.files) && t.files.length > 0
|
||||
? t.files.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
name : String(f.name ?? ''),
|
||||
progress: f.length > 0 ? f.bytesCompleted / f.length : 0,
|
||||
}))
|
||||
: undefined,
|
||||
} satisfies TorrentInfo))
|
||||
},
|
||||
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
const hash = hashMatch?.[1]?.toLowerCase() ?? null
|
||||
|
||||
const args: Record<string, unknown> = { filename: url }
|
||||
if (config.savePath) args['download-dir'] = String(config.savePath)
|
||||
if (config.category) args['labels'] = [String(config.category)]
|
||||
|
||||
const res = await trRequest(config, 'torrent-add', args)
|
||||
|
||||
if (options?.file_index != null && hash) {
|
||||
const isDuplicate = !!res.arguments?.['torrent-duplicate']
|
||||
if (isDuplicate) {
|
||||
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
|
||||
} else {
|
||||
logger.info('transmission', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
|
||||
}
|
||||
trApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('transmission', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('transmission', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
// Torrents identifiés par ID numérique : recherche dans la liste
|
||||
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
|
||||
const torrents: any[] = data.arguments?.torrents ?? []
|
||||
const found = torrents.find(t => t.hashString?.toLowerCase() === hash.toLowerCase())
|
||||
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
|
||||
await trRequest(config, 'torrent-remove', { ids: [found.id], 'delete-local-data': deleteFiles })
|
||||
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default TR
|
||||
@@ -0,0 +1,248 @@
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// Drapeaux d'état : 1 démarré, 2 vérification, 4 démarrage après vérification, 8 vérifié, 16 erreur, 32 pause, 64 file d'attente, 128 chargé
|
||||
function mapState(status: number): TorrentInfo['state'] {
|
||||
if (status & 16) return 'error'
|
||||
if (status & 32) return 'paused'
|
||||
if (status & 2) return 'checking'
|
||||
// Démarré et vérifié : en cours (le seeding est déduit de la progression)
|
||||
if (status & 1 && status & 8) return 'downloading'
|
||||
if (status & 64) return 'downloading'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
interface UTSession { token: string; cookie: string }
|
||||
|
||||
async function utTorrentExists(config: ClientConfig, hash: string): Promise<boolean> {
|
||||
try {
|
||||
const session = await utGetSession(config)
|
||||
const data = await utRequest(config, { action: 'getfiles', hash: hash.toUpperCase() }, session)
|
||||
const files = data?.files?.[1]
|
||||
return Array.isArray(files) && files.length > 0
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
/** Attend la liste des fichiers puis les passe à 0, sauf la cible à 2 (normale). */
|
||||
async function utApplyFilePriority(
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
const HASH = hash.toUpperCase()
|
||||
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
const session = await utGetSession(config)
|
||||
const data = await utRequest(config, { action: 'getfiles', hash: HASH }, session)
|
||||
const files = data?.files?.[1] as any[][] | undefined
|
||||
if (!Array.isArray(files) || files.length === 0) continue
|
||||
|
||||
const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' })
|
||||
for (let i = 0; i < files.length; i++) skipQs.append('f', String(i))
|
||||
await clientFetch(config, `${config.url}/gui/?${skipQs}`, {
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
|
||||
const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) })
|
||||
await clientFetch(config, `${config.url}/gui/?${selectQs}`, {
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
|
||||
logger.info('utorrent', `Fichier n° ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(`Délai dépassé : fichiers du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
|
||||
}
|
||||
|
||||
async function utGetSession(config: ClientConfig): Promise<UTSession> {
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
const headers : Record<string, string> = {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
}
|
||||
|
||||
const res = await clientFetch(config, `${config.url}/gui/token.html`, { headers })
|
||||
if (res.status === 401) throw new Error('uTorrent a refusé la connexion : vérifiez le nom d\'utilisateur et le mot de passe')
|
||||
if (!res.ok) throw new Error(`uTorrent a répondu HTTP ${res.status} : vérifiez l'URL de la WebUI`)
|
||||
|
||||
const text = await res.text()
|
||||
const match = text.match(/<div[^>]+id=['"]token['"][^>]*>([^<]+)</)
|
||||
if (!match) throw new Error('Réponse inattendue de uTorrent : vérifiez l\'URL de la WebUI')
|
||||
const token = match[1].trim()
|
||||
const cookie = res.headers.get('set-cookie') ?? ''
|
||||
|
||||
return { token, cookie }
|
||||
}
|
||||
|
||||
async function utRequest(
|
||||
config : ClientConfig,
|
||||
params : Record<string, string>,
|
||||
session: UTSession,
|
||||
): Promise<any> {
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
const qs = new URLSearchParams({ token: session.token, ...params })
|
||||
|
||||
const res = await clientFetch(config, `${config.url}/gui/?${qs}`, {
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Cookie' : session.cookie,
|
||||
},
|
||||
})
|
||||
if (!res.ok) throw new Error(`uTorrent a répondu HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function mapTorrent(t: any[]): TorrentInfo {
|
||||
// Tableau par torrent : [hash, status, name, size, progress, downloaded, uploaded, ratio, ul_speed, dl_speed, eta, label, …]
|
||||
const [hash, status, name, size, progress, downloaded, uploaded, ratio, ulSpeed, dlSpeed, eta, label] = t
|
||||
return {
|
||||
hash : hash.toLowerCase(),
|
||||
name,
|
||||
state : progress >= 1000 ? 'seeding' : mapState(status),
|
||||
progress : Math.min(100, Math.round(progress / 10)),
|
||||
size,
|
||||
downloaded,
|
||||
uploaded : uploaded ?? 0,
|
||||
ratio : Math.round(((ratio ?? 0) / 1000) * 100) / 100, // ratio × 1000
|
||||
speed : dlSpeed ?? 0,
|
||||
upspeed : ulSpeed ?? 0,
|
||||
eta : eta ?? -1,
|
||||
save_path : '',
|
||||
category : label ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
const UT: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'utorrent',
|
||||
label : 'uTorrent',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL de la WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
||||
{ key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'admin', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
await utGetSession(config)
|
||||
logger.info('utorrent', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
logger.warn('utorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const session = await utGetSession(config)
|
||||
const data = await utRequest(config, { action: 'getsettings' }, session)
|
||||
const version = data?.['build']?.toString() ?? 'inconnue'
|
||||
logger.debug('utorrent', `Client en ligne (build ${version})`)
|
||||
return { online: true, version }
|
||||
} catch (err) {
|
||||
logger.debug('utorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, category) {
|
||||
const session = await utGetSession(config)
|
||||
const data = await utRequest(config, { list: '1' }, session)
|
||||
const torrents: any[][] = data?.torrents ?? []
|
||||
|
||||
return torrents
|
||||
.filter(t => {
|
||||
if (!category) return true
|
||||
return t[11] === category // index 11 = label
|
||||
})
|
||||
.map(t => {
|
||||
const info = mapTorrent(t)
|
||||
|
||||
const props = data?.props?.find((p: any) => p[0]?.toLowerCase() === info.hash)
|
||||
if (props) info.save_path = props[1] ?? ''
|
||||
|
||||
return info
|
||||
})
|
||||
},
|
||||
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
const hash = hashMatch?.[1]?.toLowerCase() ?? null
|
||||
|
||||
if (options?.file_index != null && hash) {
|
||||
const exists = await utTorrentExists(config, hash)
|
||||
if (exists) {
|
||||
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
|
||||
utApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('utorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const session = await utGetSession(config)
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
|
||||
const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url })
|
||||
if (config.savePath) qs.set('path', String(config.savePath))
|
||||
|
||||
const res = await clientFetch(config, `${config.url}/gui/?${qs}`, {
|
||||
method : 'GET',
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
if (!res.ok) throw new Error(`Échec de l'ajout (HTTP ${res.status})`)
|
||||
|
||||
if (options?.file_index != null && hash) {
|
||||
logger.info('utorrent', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
|
||||
utApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('utorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
} else {
|
||||
logger.info('utorrent', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
|
||||
}
|
||||
|
||||
if (config.category) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const s2 = await utGetSession(config)
|
||||
const data = await utRequest(config, { list: '1' }, s2)
|
||||
const torrents: any[][] = data?.torrents ?? []
|
||||
const target = torrents.find(t => !t[11])
|
||||
if (target) {
|
||||
await utRequest(config, {
|
||||
action: 'setprops',
|
||||
hash : target[0],
|
||||
s : 'label',
|
||||
v : String(config.category),
|
||||
}, s2)
|
||||
}
|
||||
} catch {}
|
||||
}, 3000)
|
||||
}
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
const s = await utGetSession(config)
|
||||
await utRequest(config, {
|
||||
action: deleteFiles ? 'removedata' : 'remove',
|
||||
hash : hash.toUpperCase(),
|
||||
}, s)
|
||||
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default UT
|
||||
@@ -0,0 +1,177 @@
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
|
||||
export type UserRole = 'admin' | 'user'
|
||||
|
||||
export interface User {
|
||||
id : string
|
||||
username : string
|
||||
passwordHash: string
|
||||
role : UserRole
|
||||
apiToken : string
|
||||
createdAt : string
|
||||
tourSeenAt? : string | null
|
||||
lastLoginAt?: string | null
|
||||
}
|
||||
|
||||
const USERS_PATH = path.join(DATA_DIR, 'users.json')
|
||||
const LEGACY_PATH = path.join(DATA_DIR, 'auth.json')
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
// ── Migration depuis auth.json ────────────────────────────────
|
||||
|
||||
export function migrateIfNeeded(): void {
|
||||
if (fs.existsSync(USERS_PATH)) return
|
||||
if (!fs.existsSync(LEGACY_PATH)) return
|
||||
|
||||
try {
|
||||
const legacy = JSON.parse(fs.readFileSync(LEGACY_PATH, 'utf-8')) as {
|
||||
username: string
|
||||
passwordHash: string
|
||||
}
|
||||
const admin: User = {
|
||||
id : crypto.randomUUID(),
|
||||
username : legacy.username,
|
||||
passwordHash: legacy.passwordHash,
|
||||
role : 'admin',
|
||||
apiToken : crypto.randomBytes(32).toString('hex'),
|
||||
createdAt : new Date().toISOString(),
|
||||
}
|
||||
writeUsers([admin])
|
||||
logger.info('users', `Migration de auth.json vers users.json réussie pour « ${legacy.username} »`)
|
||||
} catch (err) {
|
||||
logger.error('users', `Échec de la migration de auth.json : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fichier des utilisateurs ──────────────────────────────────
|
||||
|
||||
export function readUsers(): User[] {
|
||||
try {
|
||||
if (!fs.existsSync(USERS_PATH)) return []
|
||||
return JSON.parse(fs.readFileSync(USERS_PATH, 'utf-8')) as User[]
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export function writeUsers(users: User[]): void {
|
||||
fs.mkdirSync(path.dirname(USERS_PATH), { recursive: true })
|
||||
fs.writeFileSync(USERS_PATH, JSON.stringify(users, null, 2))
|
||||
}
|
||||
|
||||
export function hasUsers(): boolean { return readUsers().length > 0 }
|
||||
export function hasAdmin(): boolean { return readUsers().some(u => u.role === 'admin') }
|
||||
|
||||
export function findById(id: string): User | undefined { return readUsers().find(u => u.id === id) }
|
||||
export function findByUsername(username: string): User | undefined { return readUsers().find(u => u.username.toLowerCase() === username.toLowerCase()) }
|
||||
export function findByApiToken(token: string): User | undefined { return readUsers().find(u => u.apiToken === token) }
|
||||
|
||||
export function safeUser(user: User) {
|
||||
const { passwordHash: _, ...safe } = user
|
||||
return safe
|
||||
}
|
||||
|
||||
// ── Gestion des comptes ───────────────────────────────────────
|
||||
|
||||
export function createUser(username: string, password: string, role: UserRole = 'user'): User {
|
||||
const users = readUsers()
|
||||
if (users.some(u => u.username.toLowerCase() === username.toLowerCase())) {
|
||||
throw new Error(`Nom d'utilisateur « ${username} » déjà utilisé`)
|
||||
}
|
||||
const user: User = {
|
||||
id : crypto.randomUUID(),
|
||||
username,
|
||||
passwordHash: bcrypt.hashSync(password, SALT_ROUNDS),
|
||||
role,
|
||||
apiToken : crypto.randomBytes(32).toString('hex'),
|
||||
createdAt : new Date().toISOString(),
|
||||
tourSeenAt : null,
|
||||
}
|
||||
writeUsers([...users, user])
|
||||
logger.info('users', `Utilisateur « ${username} » créé (rôle : ${role})`)
|
||||
return user
|
||||
}
|
||||
|
||||
export function updateUser(
|
||||
id : string,
|
||||
changes: Partial<{ username: string; password: string; role: UserRole }>,
|
||||
): User {
|
||||
const users = readUsers()
|
||||
const idx = users.findIndex(u => u.id === id)
|
||||
if (idx === -1) throw new Error('Utilisateur introuvable')
|
||||
|
||||
const user = users[idx]
|
||||
|
||||
if (changes.username !== undefined && changes.username !== user.username) {
|
||||
if (users.some((u, i) => i !== idx && u.username.toLowerCase() === changes.username!.toLowerCase())) {
|
||||
throw new Error(`Nom d'utilisateur « ${changes.username} » déjà utilisé`)
|
||||
}
|
||||
user.username = changes.username
|
||||
}
|
||||
if (changes.role !== undefined) user.role = changes.role
|
||||
if (changes.password !== undefined) user.passwordHash = bcrypt.hashSync(changes.password, SALT_ROUNDS)
|
||||
|
||||
writeUsers(users)
|
||||
logger.info('users', `Utilisateur « ${user.username} » mis à jour`)
|
||||
return user
|
||||
}
|
||||
|
||||
export function deleteUser(id: string): void {
|
||||
const users = readUsers()
|
||||
const user = users.find(u => u.id === id)
|
||||
if (!user) throw new Error('Utilisateur introuvable')
|
||||
writeUsers(users.filter(u => u.id !== id))
|
||||
logger.info('users', `Utilisateur « ${user.username} » supprimé`)
|
||||
}
|
||||
|
||||
export function changePassword(id: string, currentPassword: string, newPassword: string): void {
|
||||
const users = readUsers()
|
||||
const user = users.find(u => u.id === id)
|
||||
if (!user) throw new Error('Utilisateur introuvable')
|
||||
if (!bcrypt.compareSync(currentPassword, user.passwordHash)) {
|
||||
throw new Error('Mot de passe actuel incorrect')
|
||||
}
|
||||
user.passwordHash = bcrypt.hashSync(newPassword, SALT_ROUNDS)
|
||||
writeUsers(users)
|
||||
logger.info('users', `Mot de passe changé pour « ${user.username} »`)
|
||||
}
|
||||
|
||||
export function markLogin(id: string): string | null {
|
||||
const users = readUsers()
|
||||
const user = users.find(u => u.id === id)
|
||||
if (!user) return null
|
||||
user.lastLoginAt = new Date().toISOString()
|
||||
writeUsers(users)
|
||||
return user.lastLoginAt
|
||||
}
|
||||
|
||||
export function markTourSeen(id: string): string {
|
||||
const users = readUsers()
|
||||
const user = users.find(u => u.id === id)
|
||||
if (!user) throw new Error('Utilisateur introuvable')
|
||||
user.tourSeenAt = user.tourSeenAt ?? new Date().toISOString()
|
||||
writeUsers(users)
|
||||
return user.tourSeenAt
|
||||
}
|
||||
|
||||
export function markAllToursSeen(at: string): number {
|
||||
const users = readUsers()
|
||||
const pending = users.filter(u => !u.tourSeenAt)
|
||||
if (pending.length === 0) return 0
|
||||
for (const u of pending) u.tourSeenAt = at
|
||||
writeUsers(users)
|
||||
return pending.length
|
||||
}
|
||||
|
||||
export function regenerateApiToken(id: string): string {
|
||||
const users = readUsers()
|
||||
const user = users.find(u => u.id === id)
|
||||
if (!user) throw new Error('Utilisateur introuvable')
|
||||
user.apiToken = crypto.randomBytes(32).toString('hex')
|
||||
writeUsers(users)
|
||||
return user.apiToken
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#080c10] text-[#c8d8e8] font-mono">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<keep-alive include="CatalogView">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</RouterView>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
<RouterView />
|
||||
<ToastContainer />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface Torrent {
|
||||
url: string
|
||||
}
|
||||
|
||||
// Parse "Nom@url|Nom@url" → Torrent[]
|
||||
// "Nom@url|Nom@url" en liste de torrents
|
||||
export function parseTorrents(raw: string | null): Torrent[] {
|
||||
if (!raw) return []
|
||||
return raw.split('|').flatMap(item => {
|
||||
@@ -58,7 +58,6 @@ export function parseTorrents(raw: string | null): Torrent[] {
|
||||
})
|
||||
}
|
||||
|
||||
// Préfixe les images relatives
|
||||
export function imageUrl(path: string | null): string | null {
|
||||
if (!path) return null
|
||||
const encoded = encodeURIComponent(`https://api.fankai.fr${path}`)
|
||||
@@ -67,14 +66,14 @@ export function imageUrl(path: string | null): string | null {
|
||||
|
||||
export async function fetchProductions(): Promise<Production[]> {
|
||||
const res = await fetch(`${BASE}/productions`)
|
||||
if (!res.ok) throw new Error('Erreur chargement catalogue')
|
||||
if (!res.ok) throw new Error('Impossible de charger le catalogue.')
|
||||
const data = await res.json()
|
||||
return data.pageProps.data
|
||||
}
|
||||
|
||||
export async function fetchProduction(id: number, name: string): Promise<ProductionDetail> {
|
||||
const res = await fetch(`${BASE}/productions/${id}?name=${encodeURIComponent(name)}`)
|
||||
if (!res.ok) throw new Error('Erreur chargement production')
|
||||
if (!res.ok) throw new Error('Impossible de charger la série.')
|
||||
const data = await res.json()
|
||||
return data.pageProps.data
|
||||
}
|
||||
@@ -1 +1,290 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─── Des polices un peu stylées si vous voulez mon avis ─── */
|
||||
@theme {
|
||||
--font-sans: 'Zen Kaku Gothic New', 'Yu Gothic', 'Hiragino Sans', system-ui, sans-serif;
|
||||
--font-display: 'Shippori Mincho', 'Yu Mincho', Georgia, serif;
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-muted: var(--accent-muted);
|
||||
--color-on-accent: var(--on-accent);
|
||||
|
||||
--color-sidebar: var(--bg-sidebar);
|
||||
--color-main: var(--bg-main);
|
||||
--color-card: var(--bg-card);
|
||||
--color-hover: var(--bg-hover);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-border-light: var(--border-light);
|
||||
|
||||
--color-primary: var(--text-primary);
|
||||
--color-secondary: var(--text-secondary);
|
||||
--color-muted: var(--text-muted);
|
||||
|
||||
--color-ok: var(--ok);
|
||||
--color-err: var(--err);
|
||||
|
||||
--radius-card: 14px;
|
||||
--radius-field: 10px;
|
||||
--radius-poster: 6px;
|
||||
|
||||
--text-body: 13.5px;
|
||||
--text-body--line-height: 1.5;
|
||||
--text-meta: 12.5px;
|
||||
--text-meta--line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ─── Palette ─── */
|
||||
:root {
|
||||
--bg-sidebar: #0e0a08;
|
||||
--bg-main: #13100c;
|
||||
--bg-card: #1d1814;
|
||||
--bg-hover: #27221d;
|
||||
|
||||
--border: #352f2a;
|
||||
--border-light: #27221d;
|
||||
|
||||
--text-primary: #efe9dd;
|
||||
--text-secondary: #b8b0a3;
|
||||
--text-muted: #948b80;
|
||||
|
||||
--gold: #f2c44a;
|
||||
--ok: #89d298;
|
||||
--err: #ea8470;
|
||||
--on-accent: #0e0a08;
|
||||
|
||||
--accent: #f2c44a;
|
||||
--accent-hover: color-mix(in srgb, var(--accent) 80%, white);
|
||||
--accent-muted: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Thèmes ─── */
|
||||
[data-theme="vermillon"] { --accent: #e8734f; }
|
||||
[data-theme="jade"] { --accent: #6fc79a; }
|
||||
|
||||
/* ─── Base globale ─── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-main);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:where(a, button, [role="switch"], summary):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* ─── Composants réutilisables ─── */
|
||||
@layer components {
|
||||
/* Typo */
|
||||
.page-title {
|
||||
@apply font-display text-[28px] leading-tight font-bold text-primary;
|
||||
}
|
||||
.section-title {
|
||||
@apply font-display text-[21px] leading-snug font-bold text-primary;
|
||||
}
|
||||
.card-title {
|
||||
@apply text-[15px] font-bold text-primary;
|
||||
}
|
||||
.tag-label {
|
||||
@apply text-[10.5px] font-bold uppercase tracking-[0.14em] text-muted;
|
||||
}
|
||||
|
||||
/* Boutons */
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-ghost,
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center justify-center gap-2 h-10 px-[18px] rounded-full text-body whitespace-nowrap
|
||||
transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply px-5 bg-accent text-on-accent font-bold hover:bg-accent-hover;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply border border-border text-primary font-semibold hover:bg-hover;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply border border-border-light text-secondary font-medium hover:bg-hover hover:text-primary;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply border border-err/30 text-err font-semibold hover:bg-err/10;
|
||||
}
|
||||
.btn-icon {
|
||||
@apply inline-flex items-center justify-center shrink-0 w-10 h-10 rounded-full border border-border-light text-secondary
|
||||
transition-colors cursor-pointer hover:bg-hover hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-sm {
|
||||
@apply h-8 px-3.5 text-meta;
|
||||
}
|
||||
.btn-icon.btn-sm {
|
||||
@apply w-8 px-0;
|
||||
}
|
||||
|
||||
/* Cartes et champs */
|
||||
.card {
|
||||
@apply bg-card rounded-card px-5 py-4;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
@apply block text-meta text-secondary;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply w-full bg-main border border-border-light text-primary text-body px-3.5 py-2.5 rounded-field
|
||||
placeholder:text-muted outline-none focus:border-accent transition-colors;
|
||||
}
|
||||
|
||||
/* Pastilles d'état */
|
||||
.pill {
|
||||
@apply inline-flex items-center gap-[7px] h-6 px-[11px] rounded-full border border-transparent
|
||||
text-[11.5px] font-bold whitespace-nowrap;
|
||||
}
|
||||
.pill-ok { @apply border-ok/30 text-ok; }
|
||||
.pill-active { @apply bg-accent-muted text-accent; }
|
||||
.pill-wait { @apply border-accent/35 text-accent; }
|
||||
.pill-neutral { @apply border-border text-secondary; }
|
||||
.pill-err { @apply border-err/35 text-err; }
|
||||
.pill-muted { @apply border-border-light text-muted; }
|
||||
|
||||
/* Filtres et onglets à compteur */
|
||||
.chip {
|
||||
@apply inline-flex items-center gap-2 h-[34px] px-3.5 rounded-full border border-border-light text-[13px] font-medium
|
||||
text-secondary whitespace-nowrap transition-colors cursor-pointer hover:text-primary hover:bg-hover;
|
||||
}
|
||||
.chip-count {
|
||||
@apply text-muted;
|
||||
}
|
||||
.chip.is-active {
|
||||
@apply border-primary bg-primary text-on-accent font-bold hover:bg-primary;
|
||||
}
|
||||
.chip.is-active .chip-count {
|
||||
@apply text-on-accent/55 font-medium;
|
||||
}
|
||||
.chip-row {
|
||||
@apply flex items-center gap-1.5 flex-wrap;
|
||||
}
|
||||
.chip-row > * {
|
||||
@apply shrink-0;
|
||||
}
|
||||
/* Sur mobile, les filtres défilent sur une ligne jusqu'aux bords de l'écran */
|
||||
@media (width < 48rem) {
|
||||
.chip-row {
|
||||
@apply flex-nowrap overflow-x-auto -mx-4 px-4 -my-1 py-1;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bascule */
|
||||
.switch {
|
||||
@apply relative inline-flex shrink-0 w-[46px] h-[26px] p-[3px] rounded-full bg-hover transition-colors cursor-pointer
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.switch-knob {
|
||||
@apply block w-5 h-5 rounded-full bg-muted transition-transform duration-200;
|
||||
}
|
||||
.switch[aria-checked="true"] {
|
||||
@apply bg-accent;
|
||||
}
|
||||
.switch[aria-checked="true"] .switch-knob {
|
||||
@apply translate-x-5 bg-on-accent;
|
||||
}
|
||||
|
||||
/* Sélecteur segmenté */
|
||||
.segmented {
|
||||
@apply inline-flex items-center gap-0.5 h-[34px] p-[3px] rounded-full border border-border-light;
|
||||
}
|
||||
.segmented-item {
|
||||
@apply inline-flex items-center h-[26px] px-3.5 rounded-full text-meta text-secondary whitespace-nowrap
|
||||
transition-colors cursor-pointer hover:text-primary;
|
||||
}
|
||||
.segmented-item.is-active {
|
||||
@apply bg-accent text-on-accent font-bold hover:text-on-accent;
|
||||
}
|
||||
.segmented-lg {
|
||||
@apply h-[38px];
|
||||
}
|
||||
.segmented-lg .segmented-item {
|
||||
@apply h-[30px] px-4 text-[13px];
|
||||
}
|
||||
|
||||
/* Barres de progression */
|
||||
.progress {
|
||||
@apply h-[3px] rounded-[2px] bg-hover overflow-hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
@apply h-full rounded-[2px] bg-accent transition-[width] duration-500;
|
||||
}
|
||||
|
||||
/* Menus déroulants et modales */
|
||||
.menu {
|
||||
@apply bg-card border border-border rounded-card p-1.5 shadow-[0_16px_40px_rgb(0_0_0/0.5)] flex flex-col gap-0.5;
|
||||
}
|
||||
.menu-item {
|
||||
@apply flex items-center gap-2.5 w-full min-h-9 px-3 py-2 rounded-lg text-left text-body text-secondary
|
||||
transition-colors cursor-pointer hover:bg-hover hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed;
|
||||
}
|
||||
.modal-backdrop {
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center px-4 bg-sidebar/75;
|
||||
}
|
||||
.modal {
|
||||
@apply w-full bg-card border border-border rounded-card p-6 flex flex-col gap-5 shadow-[0_24px_60px_rgb(0_0_0/0.55)];
|
||||
}
|
||||
|
||||
|
||||
.stamp-new {
|
||||
@apply inline-block px-2 pt-[3px] pb-[2px] border-2 border-accent bg-sidebar/85 text-accent
|
||||
font-display text-[11px] font-bold leading-none tracking-[0.12em] -rotate-6;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-ghost,
|
||||
.btn-danger {
|
||||
@apply h-11;
|
||||
}
|
||||
.btn-icon {
|
||||
@apply w-11 h-11;
|
||||
}
|
||||
.btn-sm {
|
||||
@apply h-10;
|
||||
}
|
||||
.btn-icon.btn-sm {
|
||||
@apply w-10;
|
||||
}
|
||||
.chip {
|
||||
@apply h-10;
|
||||
}
|
||||
.menu-item {
|
||||
@apply min-h-11;
|
||||
}
|
||||
}
|
||||
|
||||
.dot-grid {
|
||||
@apply absolute inset-0 pointer-events-none;
|
||||
background-image: radial-gradient(color-mix(in srgb, var(--accent) 12%, transparent) 1px, transparent 1.5px);
|
||||
background-size: 7px 7px;
|
||||
mask-image: linear-gradient(var(--dot-grid-angle, 90deg), #000 0%, transparent var(--dot-grid-fade, 55%));
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<div class="bg-[#0d1219] flex gap-4 p-3 hover:bg-[#121920] transition-colors">
|
||||
<div class="w-24 flex-shrink-0">
|
||||
<img
|
||||
v-if="ep.cover"
|
||||
:src="imageUrl(ep.cover)"
|
||||
:alt="ep.name"
|
||||
class="w-full aspect-video object-cover"
|
||||
/>
|
||||
<div v-else class="w-full aspect-video bg-[#121920]"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-[10px] text-[#e8513a]">FILM {{ ep.number }}</span>
|
||||
<span class="text-sm font-bold text-white truncate">{{ ep.name }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-[#5a7a94] mb-1">
|
||||
Épisodes {{ ep.episodes }} · {{ ep.duration }}
|
||||
</div>
|
||||
<p class="text-xs text-[#5a7a94] leading-relaxed line-clamp-2">{{ ep.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<a
|
||||
v-if="ep.path_fr"
|
||||
:href="`https://fankai.fr/productions/${productionId}?episode=${ep.number}`"
|
||||
target="_blank"
|
||||
title="Streamable sur fankai.fr"
|
||||
class="text-[10px] text-[#3ae87a] border border-[#3ae87a] px-2 py-1 tracking-widest hover:bg-[#3ae87a]/10 transition-colors"
|
||||
@click.stop
|
||||
>
|
||||
▶ STREAM
|
||||
</a>
|
||||
<div
|
||||
v-else
|
||||
class="text-[10px] text-[#1e2d3d] border border-[#1e2d3d] px-2 py-1 tracking-widest"
|
||||
>
|
||||
BIENTÔT
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { imageUrl, type Episode } from '@/api/fankai'
|
||||
|
||||
defineProps<{
|
||||
ep: Episode
|
||||
productionId: number
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
|
||||
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,91 +1,97 @@
|
||||
<template>
|
||||
<!-- Overlay -->
|
||||
<div class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4" @click.self="$emit('cancel')">
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] w-full max-w-lg flex flex-col" style="max-height: 70vh">
|
||||
<Teleport to="body">
|
||||
<div class="modal-backdrop" @click.self="$emit('cancel')">
|
||||
<div class="bg-card border border-border rounded-card w-full max-w-lg flex flex-col max-h-[70vh] shadow-[0_24px_60px_rgb(0_0_0/0.55)]" role="dialog" aria-modal="true" aria-label="Choisir un dossier">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-[#1e2d3d] shrink-0">
|
||||
<div>
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-0.5">CHOISIR UN DOSSIER</div>
|
||||
<div class="text-xs text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</div>
|
||||
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-hover shrink-0">
|
||||
<div class="min-w-0">
|
||||
<p class="card-title">Choisir un dossier</p>
|
||||
<p class="text-meta text-muted truncate">{{ current }}</p>
|
||||
</div>
|
||||
<button @click="$emit('cancel')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
<button @click="$emit('cancel')" class="text-[#5a7a94] hover:text-white transition-colors cursor-pointer text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Chemin éditable -->
|
||||
<div class="px-5 py-3 border-b border-[#1e2d3d] shrink-0 flex gap-2">
|
||||
<input
|
||||
v-model="inputPath"
|
||||
@keydown.enter="navigateTo(inputPath)"
|
||||
class="flex-1 bg-[#121920] border border-[#1e2d3d] text-xs text-white px-3 py-2 font-mono outline-none focus:border-[#e8513a] transition-colors"
|
||||
placeholder="/"
|
||||
/>
|
||||
<button @click="navigateTo(inputPath)"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-3 py-2 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation retour -->
|
||||
<div v-if="parent !== null" class="px-5 shrink-0">
|
||||
<button @click="navigateTo(parent)"
|
||||
class="flex items-center gap-2 w-full py-2.5 text-xs text-[#5a7a94] hover:text-white transition-colors cursor-pointer border-b border-[#1e2d3d]">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||
</svg>
|
||||
..
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste dossiers -->
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div v-if="loading" class="flex items-center justify-center py-10 text-[#5a7a94] text-xs">
|
||||
<div class="w-4 h-4 border border-white/10 border-t-[#e8513a] rounded-full animate-spin mr-2"/>
|
||||
Chargement...
|
||||
<div class="px-5 py-3 border-b border-hover shrink-0 flex gap-2">
|
||||
<button @click="navigateTo('/')" class="btn-icon h-[42px] w-[42px]" :title="drivesRoot ? 'Lecteurs' : 'Retour à la racine'" :aria-label="drivesRoot ? 'Lecteurs' : 'Retour à la racine'">
|
||||
<House :size="15" />
|
||||
</button>
|
||||
<input
|
||||
v-model="inputPath"
|
||||
@keydown.enter="navigateTo(inputPath)"
|
||||
class="field flex-1"
|
||||
placeholder="/"
|
||||
aria-label="Chemin"
|
||||
/>
|
||||
<button @click="navigateTo(inputPath)" class="btn-secondary h-[42px]">
|
||||
Aller
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="error" class="px-5 py-4 text-xs text-red-400">{{ error }}</div>
|
||||
<div v-else-if="dirs.length === 0" class="px-5 py-4 text-xs text-[#5a7a94]">Aucun sous-dossier</div>
|
||||
<button
|
||||
v-for="dir in dirs"
|
||||
:key="dir"
|
||||
@click="navigateTo(joinPath(current, dir))"
|
||||
class="flex items-center gap-3 w-full px-5 py-2.5 text-xs text-[#c8d8e8] hover:bg-white/5 transition-colors cursor-pointer border-b border-[#1e2d3d]/50 text-left"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
|
||||
</svg>
|
||||
{{ dir }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-t border-[#1e2d3d] shrink-0">
|
||||
<span class="text-[10px] text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</span>
|
||||
<button @click="$emit('select', current)"
|
||||
class="bg-[#e8513a] text-white px-5 py-2 text-xs tracking-widest hover:opacity-85 transition-opacity cursor-pointer">
|
||||
CHOISIR
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="parent !== null" class="px-5 shrink-0">
|
||||
<button
|
||||
@click="navigateTo(parent)"
|
||||
class="flex items-center gap-2 w-full py-2.5 text-meta text-secondary hover:text-primary transition-colors border-b border-hover"
|
||||
>
|
||||
<ArrowLeft :size="14" />
|
||||
Dossier parent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div v-if="loading" class="flex items-center justify-center py-10 text-muted text-meta gap-2">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
</div>
|
||||
<div v-else-if="error" class="px-5 py-4 text-meta text-err">{{ error }}</div>
|
||||
<div v-else-if="dirs.length === 0" class="px-5 py-4 text-meta text-muted">Aucun sous-dossier</div>
|
||||
<button
|
||||
v-for="dir in dirs"
|
||||
:key="dir"
|
||||
@click="navigateTo(joinPath(current, dir))"
|
||||
class="flex items-center gap-3 w-full px-5 min-h-10 py-2 text-body text-primary hover:bg-hover transition-colors border-b border-hover text-left"
|
||||
>
|
||||
<Folder :size="15" :stroke-width="1.75" class="text-muted shrink-0" />
|
||||
{{ dir }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 px-5 py-4 border-t border-hover shrink-0">
|
||||
<span class="text-meta text-muted truncate">{{ current }}</span>
|
||||
<button @click="selectCurrent()" class="btn-primary">
|
||||
Choisir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ArrowLeft, Folder, House, X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{ initialPath?: string }>()
|
||||
const emit = defineEmits<{ select: [path: string]; cancel: [] }>()
|
||||
|
||||
const current = ref(props.initialPath || '/')
|
||||
const inputPath = ref(current.value)
|
||||
const dirs = ref<string[]>([])
|
||||
const parent = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const current = ref(props.initialPath || '/')
|
||||
const inputPath = ref(current.value)
|
||||
const dirs = ref<string[]>([])
|
||||
const parent = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const drivesRoot = ref(false) // true quand la liste affiche les lecteurs Windows
|
||||
|
||||
function selectCurrent() {
|
||||
emit('select', current.value.replace(/\/+$/, '') || '/')
|
||||
}
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
return base === '/' ? `/${name}` : `${base}/${name}`
|
||||
// Lecteur Windows (ex. "C:\") : déjà absolu
|
||||
if (/^[A-Za-z]:\\$/.test(name)) return name
|
||||
if (base === '/') return `/${name}`
|
||||
const cleanBase = base.replace(/[\\/]+$/, '')
|
||||
return `${cleanBase}/${name}`
|
||||
}
|
||||
|
||||
async function navigateTo(p: string) {
|
||||
@@ -95,23 +101,21 @@ async function navigateTo(p: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
error.value = data.error ?? 'Erreur'
|
||||
error.value = (await res.json()).error ?? "Impossible d'ouvrir ce dossier."
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
current.value = data.path
|
||||
inputPath.value = data.path
|
||||
parent.value = data.parent
|
||||
dirs.value = data.dirs
|
||||
const data = await res.json()
|
||||
current.value = data.path
|
||||
inputPath.value = data.path
|
||||
parent.value = data.parent
|
||||
dirs.value = data.dirs
|
||||
drivesRoot.value = data.drivesRoot ?? false
|
||||
} catch {
|
||||
error.value = 'Impossible de lire ce dossier'
|
||||
error.value = 'Impossible de lire ce dossier.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.initialPath, v => {
|
||||
if (v) navigateTo(v)
|
||||
}, { immediate: true })
|
||||
watch(() => props.initialPath, v => { if (v) navigateTo(v) }, { immediate: true })
|
||||
</script>
|
||||
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="modal-backdrop" @click.self="$emit('close')">
|
||||
<div class="bg-card border border-border rounded-card w-full max-w-3xl flex flex-col shadow-[0_24px_60px_rgb(0_0_0/0.55)] max-h-[85vh]" role="dialog" aria-modal="true" aria-labelledby="manual-import-title">
|
||||
|
||||
<div class="flex items-start justify-between gap-4 px-6 py-4 border-b border-hover shrink-0">
|
||||
<div class="min-w-0 flex flex-col gap-1">
|
||||
<h3 id="manual-import-title" class="font-display text-xl font-bold text-primary">Import manuel <span class="font-sans text-body font-normal text-muted">· {{ serieName }}</span></h3>
|
||||
<p class="text-meta text-muted truncate" :title="serieFolder">{{ serieFolder }}</p>
|
||||
</div>
|
||||
<button @click="$emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div v-if="scanning" class="flex flex-col items-center justify-center gap-3 py-16 text-muted">
|
||||
<div class="w-5 h-5 border border-border border-t-accent rounded-full animate-spin" />
|
||||
<p class="text-body">Analyse du dossier…</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!scanning && items.length === 0 && !scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
|
||||
<p class="text-body text-primary">{{ folderMissing ? 'Ce dossier n\'existe pas encore.' : 'Aucun fichier vidéo trouvé dans ce dossier.' }}</p>
|
||||
<p class="text-meta text-muted">
|
||||
{{ folderMissing ? 'Créez' : 'Placez vos fichiers dans' }} <span class="text-secondary break-all">{{ serieFolder }}</span>{{ folderMissing ? ', placez-y vos fichiers' : '' }} puis réessayez.
|
||||
</p>
|
||||
<button @click="scan" class="btn-secondary btn-sm mt-2"><RefreshCw :size="14" /> Relancer l'analyse</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
|
||||
<p class="text-body text-err">{{ scanError }}</p>
|
||||
<button @click="scan" class="btn-secondary btn-sm">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="items.length > 0 && step !== 'done'">
|
||||
|
||||
<div class="flex items-center justify-between px-6 py-3 border-b border-hover shrink-0 gap-3 flex-wrap">
|
||||
<p class="text-meta text-muted">
|
||||
<span class="text-primary font-bold">{{ items.length }}</span> fichier{{ items.length > 1 ? 's' : '' }}
|
||||
<span v-if="alreadyImportedCount > 0"> · <span class="text-ok">{{ plural(alreadyImportedCount, 'déjà importé', 'déjà importés') }}</span></span>
|
||||
· <span class="text-accent">{{ plural(newMatchedCount, 'nouveau', 'nouveaux') }}</span>
|
||||
<span v-if="unmatchedCount > 0"> · {{ plural(unmatchedCount, 'non associé', 'non associés') }}</span>
|
||||
</p>
|
||||
<button @click="scan" class="text-meta text-secondary hover:text-primary transition-colors flex items-center gap-1.5"><RefreshCw :size="13" /> Relancer l'analyse</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1 px-6 py-3 flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(item, i) in items"
|
||||
:key="item.file.path"
|
||||
class="flex items-center gap-3 px-3.5 py-3 rounded-field border transition-colors flex-wrap sm:flex-nowrap"
|
||||
:class="item.alreadyImported
|
||||
? 'border-ok/25 bg-ok/5'
|
||||
: item.episode_id
|
||||
? 'border-accent/30 bg-accent/5'
|
||||
: 'border-border-light bg-main'"
|
||||
>
|
||||
<span
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
|
||||
:class="item.alreadyImported ? 'bg-ok/15 text-ok' : item.episode_id ? 'bg-accent-muted text-accent' : 'bg-hover text-muted'"
|
||||
>
|
||||
<Check v-if="item.alreadyImported || item.episode_id" :size="12" :stroke-width="3" />
|
||||
<Minus v-else :size="12" :stroke-width="3" />
|
||||
</span>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-body text-primary truncate" :title="item.file.name">{{ item.file.name }}</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ formatSize(item.file.size) }}
|
||||
<span v-if="item.alreadyImported" class="text-ok"> · déjà importé</span>
|
||||
<span v-else-if="item.willRename" class="text-accent"> · sera renommé</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<select
|
||||
v-model="item.episode_id"
|
||||
class="field py-2 text-meta w-full sm:w-auto sm:max-w-[280px] shrink-0"
|
||||
@change="onEpisodeChange(i)"
|
||||
:aria-label="`Épisode associé à ${item.file.name}`"
|
||||
>
|
||||
<option :value="null">Aucun épisode</option>
|
||||
<optgroup v-for="season in seasons" :key="season.id" :label="season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}`">
|
||||
<option
|
||||
v-for="ep in season.episodes"
|
||||
:key="ep.id"
|
||||
:value="ep.id"
|
||||
:disabled="isEpisodeAlreadyUsed(ep.id, i)"
|
||||
>
|
||||
E{{ String(ep.episode_number).padStart(2, '0') }} · {{ ep.title || `Épisode ${ep.episode_number}` }}
|
||||
{{ isEpisodeOrganized(ep.id) ? '(déjà importé)' : '' }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 px-6 py-4 border-t border-hover shrink-0 flex-wrap">
|
||||
<p v-if="importError" class="text-meta text-err">{{ importError }}</p>
|
||||
<p v-else class="text-meta text-muted">
|
||||
Les fichiers de ce dossier seront <span class="text-accent">renommés sur place</span>.
|
||||
</p>
|
||||
<div class="flex gap-2.5">
|
||||
<button @click="$emit('close')" class="btn-ghost">Annuler</button>
|
||||
<button
|
||||
@click="doImport"
|
||||
:disabled="newMatchedCount === 0 || importing"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ importing ? 'Import…' : `Importer ${plural(newMatchedCount, 'fichier')}` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center overflow-y-auto">
|
||||
<span
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
:class="importResult.errors.length === 0 ? 'bg-ok/10 text-ok' : 'bg-err/10 text-err'"
|
||||
>
|
||||
<Check v-if="importResult.errors.length === 0" :size="22" :stroke-width="2.5" />
|
||||
<TriangleAlert v-else :size="22" :stroke-width="2" />
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-display text-xl font-bold text-primary">Import terminé</p>
|
||||
<p class="text-body text-secondary">
|
||||
{{ plural(importResult.done, 'fichier importé', 'fichiers importés') }}
|
||||
<span v-if="importResult.errors.length > 0" class="text-err">
|
||||
· {{ plural(importResult.errors.length, 'erreur') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="importResult.errors.length > 0" class="w-full text-left rounded-field border border-err/25 bg-err/5 px-4 py-3">
|
||||
<p class="tag-label text-err mb-1.5">Erreurs</p>
|
||||
<p v-for="e in importResult.errors" :key="e.file" class="text-meta text-secondary mb-1 break-words">
|
||||
<span class="text-primary">{{ e.file }}</span> : {{ e.error }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2.5">
|
||||
<button @click="backToList" class="btn-secondary">Voir les fichiers</button>
|
||||
<button @click="$emit('close')" class="btn-primary">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { Check, Minus, RefreshCw, TriangleAlert, X } from 'lucide-vue-next'
|
||||
import { matchEpisodeFile } from '@/utils/episode-match'
|
||||
import { formatSize, plural } from '@/utils/format'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
serieId : number
|
||||
serieName : string
|
||||
seasons : any[]
|
||||
organized : Record<string, any>
|
||||
initialPath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close : []
|
||||
imported: []
|
||||
}>()
|
||||
|
||||
// ── État ──
|
||||
const step = ref<'list' | 'done'>('list')
|
||||
const scanning = ref(false)
|
||||
const importing = ref(false)
|
||||
const importError = ref('')
|
||||
const scanError = ref('')
|
||||
const folderMissing = ref(false)
|
||||
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
|
||||
|
||||
// Dossier de la série dans la médiathèque
|
||||
const serieTitle = computed(() =>
|
||||
props.serieName.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
)
|
||||
const serieFolder = computed(() => `${props.initialPath}/${serieTitle.value}`)
|
||||
|
||||
interface FileItem {
|
||||
file : { name: string; path: string; size: number }
|
||||
episode_id : number | null
|
||||
originalEpisodeId : number | null // épisode auquel le fichier était déjà associé lors de l'analyse
|
||||
hash : string | null
|
||||
alreadyImported : boolean
|
||||
willRename : boolean
|
||||
}
|
||||
|
||||
const items = ref<FileItem[]>([])
|
||||
|
||||
// ── Calculs ──
|
||||
const alreadyImportedCount = computed(() => items.value.filter(i => i.alreadyImported).length)
|
||||
const newMatchedCount = computed(() => items.value.filter(i => i.episode_id !== null && !i.alreadyImported).length)
|
||||
const unmatchedCount = computed(() => items.value.filter(i => i.episode_id === null).length)
|
||||
|
||||
// ── Utilitaires ──
|
||||
function isEpisodeOrganized(epId: number): boolean {
|
||||
return !!props.organized?.[String(epId)]
|
||||
}
|
||||
|
||||
function isEpisodeAlreadyUsed(epId: number, currentIndex: number): boolean {
|
||||
return items.value.some((item, i) => i !== currentIndex && item.episode_id === epId)
|
||||
}
|
||||
|
||||
function getHashForEpisode(episodeId: number): string | null {
|
||||
for (const season of props.seasons) {
|
||||
for (const ep of season.episodes) {
|
||||
if (ep.id !== episodeId) continue
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p === 'object' && p.infohash) return p.infohash.toLowerCase()
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildOrganizedByPath(): Map<string, number> {
|
||||
const index = new Map<string, number>()
|
||||
for (const [epIdStr, entry] of Object.entries(props.organized)) {
|
||||
if (entry?.dest_path) index.set(entry.dest_path, Number(epIdStr))
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function autoMatch(filename: string): { episode_id: number | null; hash: string | null } {
|
||||
const episodeId = matchEpisodeFile(filename, props.seasons)
|
||||
return { episode_id: episodeId, hash: episodeId === null ? null : getHashForEpisode(episodeId) }
|
||||
}
|
||||
|
||||
function onEpisodeChange(i: number) {
|
||||
const item = items.value[i]
|
||||
if (!item) return
|
||||
const newEpId = item.episode_id // v-model a déjà mis à jour la valeur
|
||||
|
||||
item.hash = getHashForEpisode(Number(newEpId))
|
||||
item.alreadyImported = false
|
||||
item.willRename = newEpId !== null
|
||||
|
||||
// Le fichier choisi remplace ceux déjà importés pour cet épisode
|
||||
if (newEpId !== null) {
|
||||
items.value = items.value.filter(other =>
|
||||
other === item || !(other.episode_id === newEpId && other.alreadyImported)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
scanError.value = ''
|
||||
items.value = []
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/browse-files?path=${encodeURIComponent(serieFolder.value)}`, { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
scanError.value = data.error ?? "Impossible d'analyser le dossier."
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
folderMissing.value = data.exists === false
|
||||
const files: { name: string; path: string; size: number }[] = data.files ?? []
|
||||
|
||||
const organizedByPath = buildOrganizedByPath()
|
||||
|
||||
items.value = files.map(f => {
|
||||
const existingEpId = organizedByPath.get(f.path) ?? null
|
||||
if (existingEpId) {
|
||||
return { file: f, episode_id: existingEpId, originalEpisodeId: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
|
||||
}
|
||||
|
||||
const match = autoMatch(f.name)
|
||||
return { file: f, ...match, originalEpisodeId: null, alreadyImported: false, willRename: match.episode_id !== null }
|
||||
})
|
||||
} catch {
|
||||
scanError.value = 'Impossible de contacter le serveur.'
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
importing.value = true
|
||||
importError.value = ''
|
||||
|
||||
const payload = items.value
|
||||
.filter(i => i.episode_id !== null && !i.alreadyImported)
|
||||
.map(i => ({ file_path: i.file.path, episode_id: i.episode_id, hash: i.hash }))
|
||||
|
||||
if (payload.length === 0) {
|
||||
importError.value = 'Aucun nouveau fichier à importer.'
|
||||
importing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/manual-import', {
|
||||
method : 'POST',
|
||||
headers : { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body : JSON.stringify({ serie_id: props.serieId, items: payload }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
importError.value = data.error ?? "Impossible d'importer les fichiers."
|
||||
toast(data.error ?? "Impossible d'importer les fichiers", 'error')
|
||||
return
|
||||
}
|
||||
|
||||
importResult.value = { done: data.done, errors: data.errors ?? [] }
|
||||
|
||||
if (data.done > 0) {
|
||||
toast(plural(data.done, 'fichier importé', 'fichiers importés'), 'success')
|
||||
}
|
||||
if ((data.errors ?? []).length > 0) {
|
||||
toast(`${plural(data.errors.length, 'fichier')} en erreur`, 'error')
|
||||
}
|
||||
|
||||
// Le parent recharge les épisodes importés
|
||||
emit('imported')
|
||||
|
||||
step.value = 'done'
|
||||
} catch {
|
||||
importError.value = 'Impossible de contacter le serveur.'
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
step.value = 'list'
|
||||
scan() // nouvelle analyse pour marquer les fichiers importés
|
||||
}
|
||||
|
||||
onMounted(scan)
|
||||
</script>
|
||||
@@ -1,22 +1,44 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2">
|
||||
<div class="fixed bottom-4 right-4 left-4 sm:left-auto z-[70] flex flex-col items-end gap-2 pointer-events-none" role="status" aria-live="polite">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
@click="remove(t.id)"
|
||||
class="pointer-events-auto flex items-center gap-3 pl-3.5 pr-4 py-3 rounded-card border bg-card cursor-pointer w-full sm:w-auto sm:min-w-72 max-w-sm shadow-[0_16px_40px_rgb(0_0_0/0.5)]"
|
||||
:class="{
|
||||
'border-[#3ae87a] text-[#3ae87a]': t.type === 'success',
|
||||
'border-[#e8513a] text-[#e8513a]': t.type === 'error',
|
||||
'border-[#3a8fe8] text-[#3a8fe8]': t.type === 'info',
|
||||
'border-ok/30' : t.type === 'success',
|
||||
'border-err/35' : t.type === 'error',
|
||||
'border-accent/35' : t.type === 'info',
|
||||
}"
|
||||
class="bg-[#0d1219] border px-4 py-3 text-xs tracking-wider cursor-pointer flex items-center gap-3 min-w-64"
|
||||
>
|
||||
<span v-if="t.type === 'success'">✓</span>
|
||||
<span v-else-if="t.type === 'error'">✕</span>
|
||||
<span v-else>·</span>
|
||||
{{ t.message }}
|
||||
<span
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
|
||||
:class="{
|
||||
'bg-ok/15 text-ok' : t.type === 'success',
|
||||
'bg-err/15 text-err' : t.type === 'error',
|
||||
'bg-accent-muted text-accent' : t.type === 'info',
|
||||
}"
|
||||
>
|
||||
<Check v-if="t.type === 'success'" :size="13" :stroke-width="2.75" />
|
||||
<X v-else-if="t.type === 'error'" :size="13" :stroke-width="2.75" />
|
||||
<Info v-else :size="13" :stroke-width="2.5" />
|
||||
</span>
|
||||
|
||||
<span class="flex-1 text-meta text-primary leading-snug">{{ t.message }}</span>
|
||||
|
||||
<span
|
||||
v-if="t.count && t.count > 1"
|
||||
class="shrink-0 pill h-5 px-2 text-[10.5px]"
|
||||
:class="{
|
||||
'pill-ok' : t.type === 'success',
|
||||
'pill-err' : t.type === 'error',
|
||||
'pill-active' : t.type === 'info',
|
||||
}"
|
||||
>
|
||||
×{{ t.count }}
|
||||
</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
@@ -24,13 +46,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Info, X } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
const { toasts, remove } = useToast()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active { transition: all 0.2s ease; }
|
||||
.toast-leave-active { transition: all 0.2s ease; }
|
||||
.toast-enter-from { opacity: 0; transform: translateX(20px); }
|
||||
.toast-leave-to { opacity: 0; transform: translateX(20px); }
|
||||
.toast-leave-active { transition: all 0.15s ease; }
|
||||
.toast-enter-from { opacity: 0; transform: translateY(8px) scale(0.97); }
|
||||
.toast-leave-to { opacity: 0; transform: translateY(4px) scale(0.97); }
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-x-4 gap-y-2.5 md:flex-wrap">
|
||||
<div class="chip-row">
|
||||
<button
|
||||
v-for="t in tabs" :key="t.value"
|
||||
@click="emit('update:activeTab', t.value)"
|
||||
class="chip"
|
||||
:class="{
|
||||
'is-active': activeTab === t.value,
|
||||
'border-err/30 text-err hover:text-err': t.tone === 'err' && t.count > 0 && activeTab !== t.value,
|
||||
}"
|
||||
:aria-pressed="activeTab === t.value"
|
||||
>
|
||||
{{ t.label }}<span
|
||||
class="chip-count"
|
||||
:class="{
|
||||
'text-accent! font-bold': t.tone === 'accent' && t.count > 0 && activeTab !== t.value,
|
||||
'text-err! font-bold': t.tone === 'err' && t.count > 0 && activeTab !== t.value,
|
||||
}"
|
||||
>{{ t.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<label class="w-full sm:w-60 h-[34px] pointer-coarse:h-10 rounded-full bg-card border border-border-light flex items-center gap-[9px] pl-3.5 pr-1.5 cursor-text focus-within:border-accent transition-colors">
|
||||
<Search :size="15" :stroke-width="1.75" class="text-muted shrink-0" />
|
||||
<input
|
||||
:value="search"
|
||||
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||
@keydown.esc="emit('update:search', '')"
|
||||
type="text" placeholder="Filtrer" aria-label="Filtrer les torrents"
|
||||
class="flex-1 min-w-0 bg-transparent outline-none text-[13px] text-primary placeholder:text-muted"
|
||||
/>
|
||||
<button v-if="search" @click="emit('update:search', '')" aria-label="Effacer le filtre" class="w-6 h-6 rounded-full flex items-center justify-center text-muted hover:text-primary">
|
||||
<X :size="13" />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-[9px] text-meta text-secondary cursor-pointer select-none">
|
||||
Masquer les importés
|
||||
<button
|
||||
type="button" role="switch" :aria-checked="hideImported"
|
||||
@click="emit('update:hideImported', !hideImported)"
|
||||
class="switch w-9 h-5"
|
||||
><span class="switch-knob w-3.5 h-3.5" :class="{ 'translate-x-4!': hideImported }" /></button>
|
||||
</label>
|
||||
|
||||
<div ref="optionsRef" class="relative">
|
||||
<button
|
||||
@click="optionsOpen = !optionsOpen"
|
||||
class="w-[34px] h-[34px] pointer-coarse:w-10 pointer-coarse:h-10 rounded-full border flex items-center justify-center transition-colors relative"
|
||||
:class="optionsOpen || activeSort !== 'none' || seedingOnly ? 'border-accent/40 text-accent' : 'border-border-light text-secondary hover:text-primary hover:bg-hover'"
|
||||
aria-label="Tri et affichage" aria-haspopup="menu" :aria-expanded="optionsOpen"
|
||||
>
|
||||
<SlidersHorizontal :size="15" />
|
||||
</button>
|
||||
<div v-if="optionsOpen" class="menu absolute top-full right-0 mt-1.5 w-60 z-20" role="menu">
|
||||
<p class="tag-label px-3 pt-1.5 pb-1">Trier</p>
|
||||
<button
|
||||
v-for="s in sortOptions" :key="s.value"
|
||||
role="menuitem"
|
||||
@click="emit('sort', s.value)"
|
||||
class="menu-item justify-between"
|
||||
:class="{ 'text-primary! bg-hover': activeSort === s.value }"
|
||||
>
|
||||
{{ s.label }}
|
||||
<ArrowUp v-if="activeSort === s.value && sortDir === 'asc'" :size="14" class="text-accent" />
|
||||
<ArrowDown v-else-if="activeSort === s.value && sortDir === 'desc'" :size="14" class="text-accent" />
|
||||
</button>
|
||||
|
||||
<div class="h-px bg-hover my-1" />
|
||||
<p class="tag-label px-3 pt-1.5 pb-1">Afficher</p>
|
||||
<label v-for="col in columnOptions" :key="col.key" class="menu-item cursor-pointer">
|
||||
<input type="checkbox" :checked="columns[col.key]" @change="emit('toggle-col', col.key)" class="w-4 h-4 rounded" />
|
||||
{{ col.label }}
|
||||
</label>
|
||||
|
||||
<div class="h-px bg-hover my-1" />
|
||||
<label class="menu-item cursor-pointer justify-between">
|
||||
Masquer les états inconnus
|
||||
<button
|
||||
type="button" role="switch" :aria-checked="seedingOnly"
|
||||
@click="emit('update:seedingOnly', !seedingOnly)"
|
||||
class="switch w-9 h-5"
|
||||
><span class="switch-knob w-3.5 h-3.5" :class="{ 'translate-x-4!': seedingOnly }" /></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ArrowDown, ArrowUp, Search, SlidersHorizontal, X } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
export interface ActivityTab { value: string; label: string; count: number; tone?: 'accent' | 'err' }
|
||||
|
||||
defineProps<{
|
||||
search : string
|
||||
activeSort : string
|
||||
sortDir : 'asc' | 'desc'
|
||||
activeTab : string
|
||||
seedingOnly : boolean
|
||||
hideImported : boolean
|
||||
columns : Record<string, boolean>
|
||||
tabs : ActivityTab[]
|
||||
columnOptions: { key: string; label: string }[]
|
||||
sortOptions : { label: string; value: string }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:search' : [value: string]
|
||||
'update:activeTab' : [value: string]
|
||||
'update:seedingOnly' : [value: boolean]
|
||||
'update:hideImported': [value: boolean]
|
||||
'sort' : [value: string]
|
||||
'toggle-col' : [key: string]
|
||||
}>()
|
||||
|
||||
const optionsOpen = ref(false)
|
||||
const optionsRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(optionsRef, () => { optionsOpen.value = false })
|
||||
</script>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<article class="bg-card rounded-xl px-4 py-3 flex flex-col md:flex-row md:items-center gap-x-4 gap-y-3">
|
||||
|
||||
<div class="flex items-center gap-4 flex-1 min-w-0">
|
||||
<RouterLink v-if="torrent.serieId" :to="`/series/${torrent.serieId}`" class="shrink-0">
|
||||
<img v-if="poster" :src="poster" alt="" class="w-[34px] h-[51px] object-cover rounded-[4px]" />
|
||||
<span v-else class="w-[34px] h-[51px] rounded-[4px] bg-hover text-muted flex items-center justify-center"><Tv :size="16" :stroke-width="1.75" /></span>
|
||||
</RouterLink>
|
||||
<span v-else class="w-[34px] h-[51px] rounded-[4px] bg-hover text-muted flex items-center justify-center shrink-0" title="Série non reconnue">
|
||||
<FileQuestion :size="16" :stroke-width="1.75" />
|
||||
</span>
|
||||
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[7px]">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<div class="relative group/eps min-w-0">
|
||||
<p class="text-sm font-medium truncate cursor-default" :class="isImported ? 'text-secondary' : 'text-primary'" :title="torrent.name">
|
||||
{{ torrent.serieName && !torrent.name.startsWith(torrent.serieName) ? torrent.serieName : torrent.name }}
|
||||
</p>
|
||||
<div v-if="episodesWithProgress.length > 0" class="absolute bottom-full left-0 mb-2 hidden group-hover/eps:block z-20 min-w-56 max-w-80">
|
||||
<div class="menu p-3 gap-0">
|
||||
<p class="text-meta text-primary font-medium truncate mb-1">{{ torrent.name }}</p>
|
||||
<p class="tag-label mb-2">{{ plural(episodesWithProgress.length, 'épisode') }}</p>
|
||||
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<div v-for="ep in episodesWithProgress" :key="ep.episode_id" class="flex items-center justify-between gap-3 text-meta">
|
||||
<span class="text-primary shrink-0 tabular-nums">S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}</span>
|
||||
<span
|
||||
v-if="ep.progress !== null"
|
||||
class="pill h-5 px-2 text-[10.5px]"
|
||||
:class="ep.progress >= 100 ? 'pill-ok' : ep.priority === 0 ? 'pill-muted' : 'pill-active'"
|
||||
>
|
||||
{{ ep.priority === 0 ? 'Exclu' : ep.progress >= 100 ? '✓' : `${ep.progress} %` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill h-5 px-2 text-[11px] shrink-0" :class="[badge.class, { 'max-sm:hidden': torrent.state === 'downloading' }]">{{ badge.label }}</span>
|
||||
<span v-if="clientLine" class="text-[11.5px] text-muted shrink-0 hidden sm:inline">{{ clientLine }}</span>
|
||||
<span v-if="!isDone" class="ml-auto text-[13px] tabular-nums shrink-0" :class="torrent.state === 'error' ? 'text-err' : 'text-primary'">{{ clampedProgress }} %</span>
|
||||
</div>
|
||||
|
||||
<template v-if="!isDone">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :class="{ 'bg-muted': torrent.state === 'paused', 'bg-err': torrent.state === 'error' }" :style="{ width: `${clampedProgress}%` }" />
|
||||
</div>
|
||||
<div class="flex items-center gap-x-4 gap-y-1 flex-wrap text-xs text-muted">
|
||||
<span v-if="torrent.state === 'downloading'">{{ formatSpeed(torrent.speed ?? 0) }}</span>
|
||||
<span v-if="torrent.state === 'downloading' && torrent.eta > 0 && formatDuration(torrent.eta) !== '∞'">reste {{ formatDuration(torrent.eta) }}</span>
|
||||
<span v-if="torrent.state === 'error'" class="text-err">Le client signale une erreur sur ce torrent</span>
|
||||
<span v-if="columns.size">{{ formatSize(torrent.downloaded) }} sur {{ formatSize(torrent.size) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="torrent.errorFiles?.length > 0" class="relative group/err flex items-center gap-[9px] text-meta text-err min-w-0 w-fit max-w-full">
|
||||
<TriangleAlert :size="14" :stroke-width="2" class="shrink-0" />
|
||||
<span class="truncate">{{ errorSummary }}</span>
|
||||
<div class="absolute bottom-full left-0 mb-2 hidden group-hover/err:block z-20 w-96 max-w-[80vw]">
|
||||
<div class="menu p-3 gap-0 border-err/30">
|
||||
<p class="tag-label text-err mb-2">Fichiers en erreur</p>
|
||||
<div v-for="e in torrent.errorFiles" :key="e.file" class="mb-1.5 last:mb-0 text-meta">
|
||||
<p class="text-primary truncate" :title="e.file">{{ e.file }}</p>
|
||||
<p class="text-muted">{{ e.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-x-4 gap-y-1 flex-wrap text-xs text-muted">
|
||||
<span>
|
||||
<template v-if="torrent.organizeProgress?.total > 1">{{ torrent.organizeProgress.total }} fichiers · </template>{{ formatSize(torrent.size) }}
|
||||
</span>
|
||||
<span v-if="torrent.state === 'unknown'" class="text-accent">Le client n'indique pas l'état de ce torrent</span>
|
||||
<span v-if="isImported && torrent.importedAt">importé {{ formatRelative(torrent.importedAt) }}</span>
|
||||
<span v-else-if="!isImported && autoImportIn">import automatique {{ autoImportIn }}</span>
|
||||
<span v-if="columns.uploaded && torrent.uploaded">Envoyé {{ formatSize(torrent.uploaded) }}</span>
|
||||
<span v-if="columns.upspeed && torrent.upspeed > 0">Envoi {{ formatSpeed(torrent.upspeed) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:w-[260px] shrink-0 flex items-center justify-end gap-2.5 flex-wrap">
|
||||
<template v-if="!isDone">
|
||||
<span class="text-meta text-muted max-md:mr-auto">{{ torrent.serieName ? (autoImport ? 'Import automatique à la fin' : 'À importer une fois terminé') : 'Série non reconnue' }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="isImported" class="flex items-center gap-1.5 text-meta text-ok max-md:mr-auto">
|
||||
<Check :size="14" :stroke-width="2.5" /> Importé {{ torrent.organizeProgress?.done }}/{{ torrent.organizeProgress?.total }}
|
||||
</span>
|
||||
<span v-else class="text-meta text-secondary max-md:mr-auto">
|
||||
{{ torrent.organizeState === 'partial' ? `Importé ${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total}` : 'Non importé' }}
|
||||
</span>
|
||||
<button
|
||||
v-if="!isImported"
|
||||
@click="emit('import', torrent)"
|
||||
:disabled="importing"
|
||||
class="btn-sm min-w-[88px] max-md:min-w-[128px]"
|
||||
:class="torrent.errorFiles?.length > 0 || torrent.organizeState === 'partial' ? 'btn-secondary' : 'btn-primary'"
|
||||
>
|
||||
<Loader v-if="importing" :size="13" class="animate-spin" />
|
||||
{{ importing ? 'Import…' : torrent.errorFiles?.length > 0 ? 'Réessayer' : 'Importer' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="emit('toggle-confirm', showConfirmDelete ? null : torrent.hash)"
|
||||
:disabled="deleting"
|
||||
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-err hover:border-err/30 transition-colors"
|
||||
:title="isDone ? 'Supprimer le torrent' : 'Arrêter le téléchargement'"
|
||||
:aria-label="isDone ? 'Supprimer le torrent' : 'Arrêter le téléchargement'"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
<div v-if="showConfirmDelete" class="menu absolute bottom-full right-0 mb-2 w-60 z-20 p-3 gap-3">
|
||||
<p class="text-body text-primary font-medium">
|
||||
{{ isDone ? 'Supprimer ce torrent du client ?' : 'Arrêter ce téléchargement ?' }}
|
||||
</p>
|
||||
<label class="flex items-center gap-2 text-meta text-secondary cursor-pointer">
|
||||
<input type="checkbox" v-model="withFiles" class="w-4 h-4 rounded" />
|
||||
Supprimer aussi les fichiers du disque
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button @click="emit('delete', torrent, withFiles)" :disabled="deleting" class="btn-danger btn-sm flex-1">
|
||||
{{ deleting ? 'Suppression…' : isDone ? 'Supprimer' : 'Arrêter' }}
|
||||
</button>
|
||||
<button @click="emit('toggle-confirm', null)" class="btn-ghost btn-sm flex-1">{{ isDone ? 'Garder' : 'Continuer' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Check, FileQuestion, Loader, Trash2, TriangleAlert, Tv } from 'lucide-vue-next'
|
||||
import { formatDuration, formatRelative, formatSize, formatSpeed, plural } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
torrent : any
|
||||
columns : Record<string, boolean>
|
||||
importing : boolean
|
||||
deleting : boolean
|
||||
showConfirmDelete: boolean
|
||||
poster? : string | null
|
||||
autoImport? : boolean
|
||||
autoImportIn? : string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
import : [torrent: any]
|
||||
delete : [torrent: any, withFiles: boolean]
|
||||
'toggle-confirm': [hash: string | null]
|
||||
}>()
|
||||
|
||||
const withFiles = ref(false)
|
||||
|
||||
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.torrent.progress ?? 0)))
|
||||
const isDone = computed(() => props.torrent.state === 'seeding' || props.torrent.state === 'unknown')
|
||||
const isImported = computed(() => isDone.value && props.torrent.organizeState === 'done')
|
||||
|
||||
const clientLine = computed(() => [
|
||||
props.columns.client ? props.torrent.client_name : null,
|
||||
isDone.value && props.columns.ratio ? `Ratio ${(props.torrent.ratio ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : null,
|
||||
].filter(Boolean).join(' · '))
|
||||
|
||||
|
||||
const errorSummary = computed(() => {
|
||||
const errs: { file: string; error: string }[] = props.torrent.errorFiles ?? []
|
||||
if (!errs.length) return ''
|
||||
const name = errs[0]!.file.split(/[\\/]/).pop() ?? errs[0]!.file
|
||||
const ep = name.match(/S\d+E(\d+)/i)
|
||||
return `${ep ? `E${ep[1]}` : name} · ${errs[0]!.error}${errs.length > 1 ? ` (+${errs.length - 1})` : ''}`
|
||||
})
|
||||
|
||||
const episodesWithProgress = computed(() => {
|
||||
const eps: any[] = props.torrent.episodes ?? []
|
||||
if (!eps.length) return []
|
||||
return eps.map((ep: any) => {
|
||||
const file = (props.torrent.files ?? []).find((f: any) => {
|
||||
const basename = String(f.name ?? '').replace(/\\/g, '/').split('/').pop() ?? ''
|
||||
return basename.toLowerCase() === ep.filename?.toLowerCase()
|
||||
})
|
||||
return {
|
||||
...ep,
|
||||
progress: file != null ? Math.round((file.progress ?? 0) * 100) : null,
|
||||
priority: file?.priority ?? 1,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const badge = computed(() => ({
|
||||
downloading: { label: 'Téléchargement', class: 'pill-active' },
|
||||
seeding : { label: 'Terminé', class: 'pill-ok' },
|
||||
paused : { label: 'En pause', class: 'pill-neutral' },
|
||||
checking : { label: 'Vérification', class: 'pill-wait' },
|
||||
error : { label: 'Erreur', class: 'pill-err' },
|
||||
unknown : { label: 'État inconnu', class: 'pill-wait' },
|
||||
} as Record<string, { label: string; class: string }>)[props.torrent.state] ?? { label: 'État inconnu', class: 'pill-muted' })
|
||||
</script>
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<header class="flex items-center gap-1 pl-4 pr-2 h-14 shrink-0 bg-main">
|
||||
<RouterLink to="/dashboard" class="flex-1 min-w-0 flex items-center gap-2.5">
|
||||
<FankarrLogo class="w-6 h-[25px] shrink-0" />
|
||||
<span class="font-display text-xl font-bold tracking-[0.01em] text-primary">FanKarr</span>
|
||||
</RouterLink>
|
||||
|
||||
<button
|
||||
@click="librarySearch.open()"
|
||||
aria-label="Rechercher une série"
|
||||
class="w-11 h-11 rounded-full flex items-center justify-center text-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
<Search :size="19" :stroke-width="1.75" />
|
||||
</button>
|
||||
|
||||
<div ref="menuRef" class="relative">
|
||||
<button
|
||||
data-tour="help"
|
||||
@click="open = !open"
|
||||
aria-label="Menu du compte"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
class="relative w-11 h-11 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<span class="w-[34px] h-[34px] rounded-full bg-card border border-border text-accent font-display text-base font-bold flex items-center justify-center">
|
||||
{{ initial }}
|
||||
</span>
|
||||
<span v-if="auth.isAdmin && updateAvailable" class="absolute top-[7px] right-[7px] w-2 h-2 rounded-full bg-accent ring-2 ring-main" />
|
||||
</button>
|
||||
|
||||
<div v-if="open" role="menu" class="menu absolute top-full right-0 mt-1 w-64 z-40" @keydown.esc="open = false">
|
||||
<div class="flex flex-col gap-px px-3 pt-2 pb-2.5 mb-1 border-b border-hover">
|
||||
<span class="text-body font-medium text-primary truncate">{{ auth.username }}</span>
|
||||
<span class="text-meta text-muted truncate">{{ auth.isAdmin ? 'Administrateur' : 'Invité' }}<template v-if="currentVersion"> · {{ currentVersion }}</template></span>
|
||||
</div>
|
||||
|
||||
<RouterLink v-if="auth.isAdmin" to="/settings" role="menuitem" class="menu-item" @click="open = false">
|
||||
<Settings :size="15" class="shrink-0" /> Paramètres
|
||||
</RouterLink>
|
||||
<button role="menuitem" class="menu-item" @click="startTour">
|
||||
<Compass :size="15" class="shrink-0" /> Visite guidée
|
||||
</button>
|
||||
<button v-if="auth.isAdmin" role="menuitem" class="menu-item" @click="openSetup">
|
||||
<Wand :size="15" class="shrink-0" /> Assistant de configuration
|
||||
</button>
|
||||
<a
|
||||
v-if="auth.isAdmin && updateAvailable"
|
||||
:href="latestReleaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
role="menuitem"
|
||||
class="menu-item text-accent hover:text-accent"
|
||||
@click="open = false"
|
||||
>
|
||||
<ExternalLink :size="15" class="shrink-0" /> Mise à jour {{ latestVersion }}
|
||||
</a>
|
||||
|
||||
<div class="h-px bg-hover my-1" />
|
||||
<button role="menuitem" class="menu-item text-err hover:text-err" @click="logout">
|
||||
<LogOut :size="15" class="shrink-0" /> Se déconnecter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { Compass, ExternalLink, LogOut, Search, Settings, Wand } from 'lucide-vue-next'
|
||||
import FankarrLogo from '@/components/FankarrLogo.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useTourStore } from '@/stores/tour'
|
||||
import { useLibrarySearch } from '@/composables/useLibrarySearch'
|
||||
import { useUpdateCheck } from '@/composables/useUpdateCheck'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const tour = useTourStore()
|
||||
const librarySearch = useLibrarySearch()
|
||||
|
||||
const { currentVersion, latestVersion, latestReleaseUrl, updateAvailable } = useUpdateCheck()
|
||||
|
||||
const initial = computed(() => auth.username?.charAt(0).toUpperCase() ?? '?')
|
||||
|
||||
const open = ref(false)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
onClickOutside(menuRef, () => { open.value = false })
|
||||
|
||||
function startTour() {
|
||||
open.value = false
|
||||
tour.start()
|
||||
}
|
||||
|
||||
function openSetup() {
|
||||
open.value = false
|
||||
router.push('/setup')
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
open.value = false
|
||||
await auth.logout()
|
||||
router.push('/auth')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<nav aria-label="Navigation principale" class="shrink-0 bg-sidebar border-t border-card pb-[env(safe-area-inset-bottom)]">
|
||||
<div class="flex h-16">
|
||||
<RouterLink
|
||||
v-for="item in items"
|
||||
:key="item.to"
|
||||
:to="item.to ?? '/'"
|
||||
:data-tour="item.tour"
|
||||
:aria-current="isActive(item) ? 'page' : undefined"
|
||||
class="flex-1 min-w-0 flex flex-col items-center justify-center gap-1 text-[11.5px] transition-colors"
|
||||
:class="isActive(item) ? 'text-accent font-bold' : 'text-muted font-medium hover:text-secondary'"
|
||||
>
|
||||
<span class="relative">
|
||||
<component :is="item.icon" :size="22" :stroke-width="1.75" />
|
||||
<span
|
||||
v-if="item.badge && item.badgeStyle === 'dot'"
|
||||
class="absolute -top-0.5 -right-1 w-[7px] h-[7px] rounded-full bg-accent ring-2 ring-sidebar"
|
||||
/>
|
||||
<span
|
||||
v-else-if="item.badge"
|
||||
class="absolute -top-1.5 -right-2.5 min-w-[18px] h-[18px] px-1 rounded-full bg-accent text-on-accent text-[10.5px] font-bold tabular-nums flex items-center justify-center ring-2 ring-sidebar"
|
||||
>{{ item.badge }}</span>
|
||||
</span>
|
||||
<span class="max-w-full px-1 truncate">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
|
||||
defineProps<{ items: NavItem[] }>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const isActive = (item: NavItem) => !!item.to && route.path.startsWith(item.to)
|
||||
</script>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<aside class="flex flex-col h-full bg-sidebar border-r border-card px-3.5 pt-[22px] pb-[18px]">
|
||||
|
||||
<div class="flex items-center gap-2 pl-2 pb-7">
|
||||
<RouterLink to="/dashboard" class="flex-1 min-w-0 flex items-center gap-[11px]">
|
||||
<FankarrLogo class="w-[30px] h-[31px] shrink-0" />
|
||||
<span class="font-display text-[23px] font-bold tracking-[0.01em] text-primary">FanKarr</span>
|
||||
</RouterLink>
|
||||
<div ref="helpRef" class="relative">
|
||||
<button
|
||||
data-tour="help"
|
||||
@click="helpOpen = !helpOpen"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="helpOpen"
|
||||
title="Aide"
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors"
|
||||
>
|
||||
<CircleQuestionMark :size="16" :stroke-width="1.75" />
|
||||
</button>
|
||||
<div
|
||||
v-if="helpOpen"
|
||||
role="menu"
|
||||
class="menu absolute top-full left-0 mt-2 w-60 z-30"
|
||||
@keydown.esc="helpOpen = false"
|
||||
>
|
||||
<button role="menuitem" @click="startTour" class="menu-item">
|
||||
<Compass :size="15" class="shrink-0" /> Visite guidée
|
||||
</button>
|
||||
<button v-if="auth.isAdmin" role="menuitem" @click="openSetup" class="menu-item">
|
||||
<Wand :size="15" class="shrink-0" /> Assistant de configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden flex flex-col gap-0.5">
|
||||
<template v-for="(item, i) in items" :key="i">
|
||||
<div v-if="item.separator" class="h-px bg-card mx-2 my-3.5 shrink-0" />
|
||||
<SidebarNavItem v-else :item="item" />
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<a
|
||||
v-if="auth.isAdmin && updateAvailable"
|
||||
:href="latestReleaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="mb-3 flex items-center gap-2.5 px-3 py-2.5 rounded-field bg-accent-muted hover:bg-accent/20 transition-colors group"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-meta font-bold text-accent">Mise à jour disponible</span>
|
||||
<span class="block text-[11.5px] text-muted truncate">Version {{ latestVersion }}</span>
|
||||
</span>
|
||||
<ExternalLink :size="13" class="shrink-0 text-muted group-hover:text-accent transition-colors" />
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-2 pl-2 pt-3.5 border-t border-card">
|
||||
<span class="w-[34px] h-[34px] rounded-full bg-hover text-accent flex items-center justify-center font-display text-base font-bold shrink-0">
|
||||
{{ initial }}
|
||||
</span>
|
||||
<span class="flex-1 min-w-0 flex flex-col gap-px">
|
||||
<span class="text-[13px] font-medium text-primary truncate">{{ auth.username }}</span>
|
||||
<span class="text-[11.5px] text-muted truncate">{{ auth.isAdmin ? 'Administrateur' : 'Invité' }}<template v-if="currentVersion"> · {{ currentVersion }}</template></span>
|
||||
</span>
|
||||
<button
|
||||
@click="logout"
|
||||
title="Se déconnecter"
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center shrink-0 text-muted hover:text-err hover:bg-hover transition-colors"
|
||||
>
|
||||
<LogOut :size="16" :stroke-width="1.75" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { CircleQuestionMark, Compass, ExternalLink, LogOut, Wand } from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useTourStore } from '@/stores/tour'
|
||||
import { useUpdateCheck } from '@/composables/useUpdateCheck'
|
||||
import SidebarNavItem from './SidebarNavItem.vue'
|
||||
import FankarrLogo from '@/components/FankarrLogo.vue'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
|
||||
defineProps<{ items: NavItem[] }>()
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const tour = useTourStore()
|
||||
|
||||
const initial = computed(() => auth.username?.charAt(0).toUpperCase() ?? '?')
|
||||
|
||||
const helpOpen = ref(false)
|
||||
const helpRef = ref<HTMLElement | null>(null)
|
||||
onClickOutside(helpRef, () => { helpOpen.value = false })
|
||||
|
||||
function startTour() {
|
||||
helpOpen.value = false
|
||||
tour.start()
|
||||
}
|
||||
|
||||
function openSetup() {
|
||||
helpOpen.value = false
|
||||
router.push('/setup')
|
||||
}
|
||||
|
||||
const { currentVersion, latestVersion, latestReleaseUrl, updateAvailable } = useUpdateCheck()
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
router.push('/auth')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="item.to ?? '/'"
|
||||
:data-tour="item.tour"
|
||||
class="group flex items-center gap-3 h-10 px-3 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="isActive ? 'bg-hover text-primary' : 'text-secondary hover:bg-hover/60 hover:text-primary'"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
:size="18"
|
||||
:stroke-width="1.75"
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isActive ? 'text-accent' : 'text-muted group-hover:text-secondary'"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ item.label }}</span>
|
||||
|
||||
<span
|
||||
v-if="item.badge && item.badgeStyle === 'dot'"
|
||||
class="flex items-center gap-1.5 text-xs font-bold text-secondary"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-accent" />{{ item.badge }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.badge"
|
||||
class="min-w-[22px] h-5 px-[7px] rounded-full bg-accent-muted text-accent text-xs font-bold flex items-center justify-center"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
|
||||
const props = defineProps<{ item: NavItem }>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const isActive = computed(() => !!props.item.to && route.path.startsWith(props.item.to))
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="modal-backdrop" @click.self="emit('close')">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="reject-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 id="reject-title" class="card-title">Refuser la demande</h2>
|
||||
<p class="text-meta text-muted truncate">{{ serieName }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="reject-message" class="field-label">Motif (facultatif)</label>
|
||||
<input id="reject-message" ref="inputRef" v-model="message" type="text" class="field" placeholder="Visible par l'invité" @keyup.enter="emit('confirm', message)" />
|
||||
</div>
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="emit('close')" class="btn-ghost">Annuler</button>
|
||||
<button @click="emit('confirm', message)" class="btn-danger">Refuser</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
defineProps<{ serieName: string }>()
|
||||
const emit = defineEmits<{ close: []; confirm: [message: string] }>()
|
||||
|
||||
const message = ref('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
onMounted(() => inputRef.value?.focus())
|
||||
</script>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<section class="relative">
|
||||
<!-- overflow-hidden ici et pas sur la section, qui couperait les menus -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<img
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
alt=""
|
||||
class="absolute inset-0 w-full h-full object-cover blur-[40px] saturate-[1.25] scale-[1.4] opacity-50"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-linear-100 from-main/97 via-main/86 via-52% to-main/40" />
|
||||
<div class="dot-grid [--dot-grid-angle:100deg] [--dot-grid-fade:60%]" />
|
||||
</div>
|
||||
|
||||
<div class="relative grid grid-cols-[96px_minmax(0,1fr)] gap-x-4 gap-y-4 sm:flex sm:items-start sm:gap-7 px-4 md:px-10 pt-4 pb-5 sm:py-6">
|
||||
<div class="shrink-0 w-[96px] sm:w-[152px] sm:mt-[22px]">
|
||||
<img
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
:alt="serie.title"
|
||||
class="w-[96px] h-[144px] sm:w-[152px] sm:h-[228px] object-cover rounded-lg shadow-[0_22px_44px_rgb(0_0_0/0.5),0_0_0_1px_rgb(239_233_221/0.08)]"
|
||||
/>
|
||||
<div v-else class="w-[96px] h-[144px] sm:w-[152px] sm:h-[228px] rounded-lg bg-card flex items-center justify-center text-muted">
|
||||
<Tv :size="28" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contents sm:flex sm:flex-1 sm:min-w-0 sm:flex-col sm:gap-2.5">
|
||||
<div class="min-w-0 flex flex-col gap-2.5">
|
||||
<RouterLink to="/series" class="inline-flex items-center gap-[7px] min-h-7 text-meta text-secondary hover:text-primary transition-colors w-fit">
|
||||
<ChevronLeft :size="14" :stroke-width="2" />
|
||||
Médiathèque
|
||||
</RouterLink>
|
||||
|
||||
<h1 class="font-display text-[26px] sm:text-[42px] leading-[1.1] font-extrabold text-primary">{{ serie.title }}</h1>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span v-if="serie.year" class="text-body text-secondary">{{ serie.year }}</span>
|
||||
<span
|
||||
v-if="serie.status"
|
||||
class="pill h-[22px] px-2.5"
|
||||
:class="serie.status.toLowerCase() === 'continuing' ? 'pill-ok' : 'pill-neutral'"
|
||||
>{{ statusLabel(serie.status) }}</span>
|
||||
<span v-if="episodeCount" class="text-body text-secondary">
|
||||
<template v-if="seasonCount">{{ plural(seasonCount, 'saison') }} · </template>{{ plural(episodeCount, 'épisode') }}
|
||||
</span>
|
||||
<span v-if="watched" class="pill pill-wait h-[22px] px-2.5">
|
||||
<Rss :size="11" :stroke-width="2.5" /> Surveillée
|
||||
</span>
|
||||
<a
|
||||
v-if="serie.wiki"
|
||||
:href="serie.wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 text-meta text-secondary hover:text-accent transition-colors"
|
||||
>
|
||||
<ExternalLink :size="12" /> Wiki
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 min-w-0 flex flex-col gap-2.5 empty:hidden">
|
||||
<p
|
||||
v-if="serie.plot"
|
||||
@click="expanded = !expanded"
|
||||
class="text-body leading-[1.6] text-secondary max-w-[640px] text-pretty cursor-pointer select-none"
|
||||
:class="expanded ? '' : 'line-clamp-3'"
|
||||
:title="expanded ? 'Réduire' : 'Lire la suite'"
|
||||
>
|
||||
{{ serie.plot }}
|
||||
</p>
|
||||
|
||||
<div v-if="episodeCount" class="flex items-center gap-3.5 mt-0.5 flex-wrap">
|
||||
<div class="w-[260px] max-w-full h-1 rounded-[2px] bg-hover overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-[2px] transition-[width] duration-500"
|
||||
:class="organizedCount >= episodeCount ? 'bg-ok' : 'bg-accent'"
|
||||
:style="{ width: `${Math.round(organizedCount / episodeCount * 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-meta text-secondary">{{ plural(organizedCount, `épisode ${organizedLabel}`, `épisodes ${organizedLabel}s`) }} sur {{ episodeCount }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="flex items-center gap-2.5 mt-1.5 max-sm:flex-nowrap flex-wrap">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ChevronLeft, ExternalLink, Rss, Tv } from 'lucide-vue-next'
|
||||
import { statusLabel } from '@/utils/series'
|
||||
import { plural } from '@/utils/format'
|
||||
|
||||
defineProps<{
|
||||
serie : any
|
||||
seasonCount : number
|
||||
episodeCount : number
|
||||
organizedCount: number
|
||||
organizedLabel: string
|
||||
watched? : boolean
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
</script>
|
||||
@@ -0,0 +1,713 @@
|
||||
<template>
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex items-center gap-3.5 px-4 sm:px-5 py-3.5 flex-wrap" :class="{ 'border-b border-hover': !collapsed }">
|
||||
<button
|
||||
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 -ml-1.5 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors shrink-0"
|
||||
:aria-expanded="!collapsed"
|
||||
:aria-label="collapsed ? 'Déplier la saison' : 'Replier la saison'"
|
||||
@click="emit('toggle', season.id)"
|
||||
>
|
||||
<ChevronUp :size="16" :stroke-width="2" class="transition-transform duration-200" :class="collapsed ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
|
||||
<div class="flex-1 min-w-[160px] flex flex-col gap-[3px] cursor-pointer" @click="emit('toggle', season.id)">
|
||||
<h2 class="font-display text-[17px] font-bold text-primary">
|
||||
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="font-sans text-sm font-normal text-muted">· {{ season.title }}</span>
|
||||
</h2>
|
||||
<p class="text-meta text-muted">{{ seasonMeta }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<!-- ── Mode demande ── -->
|
||||
<template v-if="requestMode">
|
||||
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok">
|
||||
<Check :size="14" :stroke-width="2.5" /> Saison complète
|
||||
</span>
|
||||
<span v-else-if="isSeasonRequested" class="flex items-center gap-[7px] text-meta text-accent">
|
||||
<Clock3 :size="14" :stroke-width="2" /> {{ requestStatus === 'approved' ? 'Demandée · bientôt là' : 'Demandée · en attente de réponse' }}
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
@click.stop="emit('requestSeason', season.season_number)"
|
||||
class="btn-secondary btn-sm"
|
||||
>
|
||||
<Clock3 :size="14" :stroke-width="2" />
|
||||
{{ missingCount === season.episodes.length ? 'Demander la saison' : missingCount > 1 ? `Demander ${missingCount} épisodes` : "Demander l'épisode manquant" }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- ── Mode téléchargement (admin) ── -->
|
||||
<template v-else>
|
||||
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok">
|
||||
<Check :size="14" :stroke-width="2.5" /> Saison complète
|
||||
</span>
|
||||
<span v-else-if="season.organized_state === 'partial'" class="text-meta text-secondary">
|
||||
Partielle {{ season.organized_count }}/{{ availableCount || season.episodes.length }}
|
||||
</span>
|
||||
|
||||
|
||||
<template v-if="season.torrents.length === 0 && hasDownloadable">
|
||||
<div v-if="uniquePackOptions.length > 1" class="relative" @click.stop>
|
||||
<button class="btn-secondary btn-sm" @click="seasonMenuOpen = !seasonMenuOpen" aria-haspopup="menu" :aria-expanded="seasonMenuOpen">
|
||||
<Download :size="14" />
|
||||
Télécharger {{ downloadableLabel }}
|
||||
<ChevronDown :size="12" :stroke-width="2.5" />
|
||||
</button>
|
||||
<div v-if="seasonMenuOpen" class="menu absolute right-0 top-full mt-1.5 w-72 max-w-[90vw] z-20" role="menu">
|
||||
<button
|
||||
v-for="opt in uniquePackOptions"
|
||||
:key="opt.infohash"
|
||||
role="menuitem"
|
||||
class="menu-item"
|
||||
@click="emit('downloadSeason', season, opt.infohash); seasonMenuOpen = false"
|
||||
>
|
||||
<Download :size="14" class="shrink-0" />
|
||||
<span class="truncate">{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
@click="emit('downloadSeason', season)"
|
||||
:disabled="downloadingSeason"
|
||||
class="btn-secondary btn-sm"
|
||||
>
|
||||
<Loader v-if="downloadingSeason" :size="14" class="animate-spin" />
|
||||
<Download v-else :size="14" />
|
||||
{{ downloadingSeason ? 'Envoi…' : `Télécharger ${downloadableLabel}` }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
v-if="season.torrents.length === 1 && season.organized_state !== 'complete'"
|
||||
class="btn-secondary btn-sm"
|
||||
:disabled="!canDownloadSeason"
|
||||
@click="canDownloadSeason && emit('download', `season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
|
||||
>
|
||||
<component :is="seasonBtnIcon" />
|
||||
{{ seasonBtnLabel }}
|
||||
</button>
|
||||
|
||||
<div v-if="season.torrents.length > 1 && season.organized_state !== 'complete'" class="relative" @click.stop>
|
||||
<button class="btn-secondary btn-sm" @click="seasonMenuOpen = !seasonMenuOpen" aria-haspopup="menu" :aria-expanded="seasonMenuOpen">
|
||||
<Download :size="14" />
|
||||
Télécharger la saison
|
||||
<ChevronDown :size="12" :stroke-width="2.5" />
|
||||
</button>
|
||||
<div v-if="seasonMenuOpen" class="menu absolute right-0 top-full mt-1.5 w-72 max-w-[90vw] z-20" role="menu">
|
||||
<button
|
||||
v-for="(t, i) in (season.torrents as any[])"
|
||||
:key="i"
|
||||
role="menuitem"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="menu-item"
|
||||
:disabled="isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)"
|
||||
@click="!isDownloaded(`season-${season.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `season-${season.id}-${i}`, t.torrent_url, t.magnet), seasonMenuOpen = false)"
|
||||
>
|
||||
<component :is="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
|
||||
<span class="truncate">{{ groupLabels(season.torrents)[i] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="season.organized_count > 0"
|
||||
@click.stop="openUnimportSeasonModal"
|
||||
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-err/10 transition-colors"
|
||||
title="Retirer la saison de la médiathèque"
|
||||
aria-label="Retirer la saison de la médiathèque"
|
||||
>
|
||||
<Trash2 :size="15" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!collapsed">
|
||||
<div
|
||||
v-for="ep in season.episodes"
|
||||
:key="ep.id"
|
||||
class="px-4 sm:px-5 py-2 border-t border-hover first:border-t-0 hover:bg-hover/30 transition-colors last:rounded-b-card"
|
||||
>
|
||||
<div class="flex items-center gap-3 sm:gap-3.5">
|
||||
<div
|
||||
class="shrink-0 w-[64px] h-[36px] sm:w-[88px] sm:h-[50px] rounded-[4px] overflow-hidden bg-main flex items-center justify-center text-muted text-xs"
|
||||
:class="{ 'opacity-50': !requestMode && !ep.available && !ep.organized }"
|
||||
>
|
||||
<img v-if="ep.thumb_image" :src="ep.thumb_image" alt="" class="w-full h-full object-cover" loading="lazy" />
|
||||
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="w-[26px] shrink-0 text-[11.5px] text-muted tabular-nums max-sm:hidden">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2, '0')}` }}
|
||||
</span>
|
||||
<span class="text-[14.5px] font-medium truncate" :class="!requestMode && !ep.available && !ep.organized ? 'text-muted' : 'text-primary'">
|
||||
{{ ep.title || `Épisode ${ep.episode_number}` }}
|
||||
</span>
|
||||
<span
|
||||
v-if="ep.fankai === false || ep.torrent?.fankai === false"
|
||||
class="shrink-0 h-[19px] px-[7px] rounded-[4px] border border-border text-[10.5px] font-bold text-muted flex items-center"
|
||||
title="Ce fichier n'est pas une version officielle Fankai"
|
||||
>Hors Fankai</span>
|
||||
</div>
|
||||
<span v-if="episodeMeta(ep)" class="sm:pl-9 text-meta text-muted truncate" :title="episodeMeta(ep)">{{ episodeMeta(ep) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ── État et actions, mode demande ── -->
|
||||
<template v-if="requestMode">
|
||||
<span v-if="ep.organized" class="shrink-0 flex items-center gap-[7px] text-meta text-ok">
|
||||
<Check :size="14" :stroke-width="2.5" /> <span class="max-sm:sr-only">Disponible</span>
|
||||
</span>
|
||||
<span v-else-if="isEpisodeRequested(ep.id)" class="shrink-0 flex items-center gap-[7px] text-meta text-accent">
|
||||
<Clock3 :size="14" :stroke-width="2" /> <span class="max-sm:sr-only">{{ requestStatus === 'approved' ? 'Demandé · bientôt là' : 'Demandé' }}</span>
|
||||
</span>
|
||||
<div v-else class="relative shrink-0" @click.stop>
|
||||
<button class="btn-secondary btn-sm pointer-fine:h-[30px] max-sm:w-10 max-sm:px-0" @click="handleEpRequestClick(ep)" title="Demander l'épisode" aria-label="Demander l'épisode">
|
||||
<Plus :size="13" :stroke-width="2" /> <span class="max-sm:hidden">Demander</span>
|
||||
<ChevronDown v-if="ep.torrents && ep.torrents.length > 1" :size="12" :stroke-width="2.5" />
|
||||
</button>
|
||||
<div v-if="epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1" class="menu absolute right-0 bottom-full mb-1.5 w-80 max-w-[90vw] z-20" role="menu">
|
||||
<p class="tag-label px-3 pt-1.5 pb-1">Choisir la version</p>
|
||||
<button
|
||||
v-for="(t, i) in (ep.torrents as any[])"
|
||||
:key="i"
|
||||
role="menuitem"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="menu-item items-start"
|
||||
@click="emit('requestEpisode', season.season_number, ep.id, t); epOptionsOpen = null"
|
||||
>
|
||||
<Clock3 :size="14" class="shrink-0 mt-0.5" />
|
||||
<span class="whitespace-normal break-words">{{ t.raw ?? t.torrent_name ?? `Option ${i + 1}` }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── État et actions, admin ── -->
|
||||
<template v-else>
|
||||
<template v-if="ep.organized">
|
||||
<button
|
||||
v-if="organizedByEpisode[String(ep.id)] && epNeedsRename(ep)"
|
||||
@click.stop="emit('renameEpisode', ep, season)"
|
||||
:disabled="epActionLoading[ep.id]"
|
||||
class="pill pill-wait h-[26px] px-2.5 shrink-0 hover:bg-accent-muted transition-colors"
|
||||
:title="`Le fichier ne porte pas le nom attendu.\nActuel : ${organizedByEpisode[String(ep.id)]?.dest_filename}\nAttendu : ${epExpectedName(ep)}`"
|
||||
>
|
||||
<Loader v-if="epActionLoading[ep.id]" :size="12" class="animate-spin" />
|
||||
<PencilLine v-else :size="12" :stroke-width="2.25" />
|
||||
<span class="max-sm:hidden">Renommer</span>
|
||||
</button>
|
||||
<span class="shrink-0 flex items-center gap-[7px] text-meta text-ok">
|
||||
<Check :size="14" :stroke-width="2.5" /> <span class="max-sm:sr-only">Importé</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<span v-else-if="epState(ep) === 'loading'" class="shrink-0 flex items-center gap-[7px] text-meta text-accent">
|
||||
<Clock3 :size="14" :stroke-width="2" />
|
||||
<template v-if="epProgress(ep) && epProgress(ep)!.progress > 0 && epProgress(ep)!.progress < 100"><span class="max-sm:hidden">Téléchargement · </span>{{ epProgress(ep)!.progress }} %</template>
|
||||
<span v-else class="max-sm:sr-only">En file d'attente</span>
|
||||
</span>
|
||||
|
||||
<span v-else-if="epState(ep) === 'unavailable'" class="shrink-0 text-meta text-muted">Sans torrent</span>
|
||||
|
||||
<div v-else class="relative shrink-0" @click.stop>
|
||||
<button class="btn-secondary btn-sm pointer-fine:h-[30px] max-sm:w-10 max-sm:px-0" @click="handleEpBtnClick(ep)" title="Télécharger l'épisode" aria-label="Télécharger l'épisode">
|
||||
<Download :size="13" /> <span class="max-sm:hidden">Télécharger</span>
|
||||
<ChevronDown v-if="ep.torrents && ep.torrents.length > 1" :size="12" :stroke-width="2.5" />
|
||||
</button>
|
||||
<div v-if="epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1" class="menu absolute right-0 bottom-full mb-1.5 w-72 max-w-[90vw] z-20" role="menu">
|
||||
<button
|
||||
v-for="(t, i) in (ep.torrents as any[])"
|
||||
:key="i"
|
||||
role="menuitem"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="menu-item"
|
||||
:disabled="isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)"
|
||||
@click="!isDownloaded(`ep-${ep.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `ep-${ep.id}-${i}`, t.torrent_url, t.magnet, t.file_index ?? null, t.file_path ?? null, t.infohash ?? null), epOptionsOpen = null)"
|
||||
>
|
||||
<component :is="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
|
||||
<span class="truncate">{{ groupLabels(ep.torrents)[i] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="ep.organized && organizedByEpisode[String(ep.id)]"
|
||||
@click.stop="openUnimportModal(ep, season)"
|
||||
:disabled="epActionLoading[ep.id]"
|
||||
class="w-[30px] h-[30px] pointer-coarse:w-10 pointer-coarse:h-10 shrink-0 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-err/10 transition-colors"
|
||||
title="Retirer l'épisode de la médiathèque"
|
||||
aria-label="Retirer l'épisode de la médiathèque"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
v-if="ep.plot"
|
||||
@click.stop="togglePlot(ep.id)"
|
||||
class="w-[30px] h-[30px] pointer-coarse:w-10 pointer-coarse:h-10 shrink-0 rounded-full flex items-center justify-center transition-colors"
|
||||
:class="plotOpen === ep.id ? 'text-accent' : 'text-muted hover:text-primary hover:bg-hover'"
|
||||
:aria-expanded="plotOpen === ep.id"
|
||||
title="Voir le synopsis"
|
||||
>
|
||||
<ChevronDown :size="14" class="transition-transform duration-200" :class="plotOpen === ep.id ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="progress mt-2 sm:ml-[138px]">
|
||||
<div class="progress-bar" :style="{ width: `${epProgress(ep)!.progress}%` }" />
|
||||
</div>
|
||||
|
||||
<p v-if="plotOpen === ep.id && ep.plot" class="mt-2 mb-1 sm:ml-[138px] text-meta text-secondary leading-relaxed whitespace-pre-line max-w-[760px]">
|
||||
{{ parseEpChap(ep.plot).cleanPlot }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportModal" class="modal-backdrop" @click.self="unimportModal = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-ep-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 id="unimport-ep-title" class="card-title">Retirer l'épisode de la médiathèque</h3>
|
||||
<p class="text-meta text-muted">L'épisode sera retiré de la médiathèque. Le fichier reste sur le disque, sauf si vous cochez la case.</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteFileOnUnimport" class="w-4 h-4 rounded" />
|
||||
<span class="text-body text-secondary">Supprimer le fichier du disque</span>
|
||||
</label>
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="unimportModal = false" class="btn-ghost">Annuler</button>
|
||||
<button @click="confirmUnimport" class="btn-danger">
|
||||
{{ deleteFileOnUnimport ? 'Supprimer' : 'Retirer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportSeasonModal" class="modal-backdrop" @click.self="unimportSeasonModal = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-season-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 id="unimport-season-title" class="card-title">Retirer la saison de la médiathèque</h3>
|
||||
<p class="text-meta text-muted">Les épisodes importés de cette saison seront retirés de la médiathèque. Les fichiers restent sur le disque, sauf si vous cochez la case.</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteSeasonFiles" class="w-4 h-4 rounded" />
|
||||
<span class="text-body text-secondary">Supprimer les fichiers du disque</span>
|
||||
</label>
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="unimportSeasonModal = false" class="btn-ghost">Annuler</button>
|
||||
<button @click="confirmUnimportSeason" class="btn-danger">
|
||||
{{ deleteSeasonFiles ? 'Supprimer' : 'Retirer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
|
||||
import { ChevronDown, ChevronUp, Clock3, Download, Loader, Check, PencilLine, Plus, X, Trash2 } from 'lucide-vue-next'
|
||||
import { plural } from '@/utils/format'
|
||||
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number; priority?: number }[] }
|
||||
|
||||
const props = defineProps<{
|
||||
season : any
|
||||
collapsed : boolean
|
||||
activeTorrents : ActiveTorrent[]
|
||||
downloading : string[]
|
||||
downloaded : string[]
|
||||
organizedByEpisode: Record<string, any>
|
||||
epActionLoading : Record<number, boolean>
|
||||
downloadingSeason ?: boolean
|
||||
nfoSupport ?: boolean
|
||||
requestMode ?: boolean // true = boutons demande au lieu de téléchargement
|
||||
requestedSeasons ?: number[] // saisons déjà demandées par l'utilisateur pour cette série
|
||||
requestedEpisodes ?: number[] // IDs d'épisodes déjà demandés par l'utilisateur pour cette série
|
||||
requestStatus ?: string // statut de la demande en cours de l'utilisateur
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle : [id: number]
|
||||
download : [key: string, url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null]
|
||||
downloadSeason : [season: any, packHash?: string]
|
||||
renameEpisode : [ep: any, season: any]
|
||||
unimportEpisode: [ep: any, season: any, deleteFile: boolean]
|
||||
unimportSeason : [season: any, deleteFiles: boolean]
|
||||
// Mode demande
|
||||
requestSeason : [seasonNumber: number]
|
||||
requestEpisode : [seasonNumber: number, episodeId: number, torrent?: any]
|
||||
}>()
|
||||
|
||||
const isSeasonRequested = computed(() =>
|
||||
props.requestedSeasons?.includes(props.season.season_number) ?? false
|
||||
)
|
||||
|
||||
function isEpisodeRequested(episodeId: number): boolean {
|
||||
return isSeasonRequested.value || (props.requestedEpisodes?.includes(episodeId) ?? false)
|
||||
}
|
||||
|
||||
const epOptionsOpen = ref<number | null>(null)
|
||||
const plotOpen = ref<number | null>(null)
|
||||
const seasonMenuOpen = ref(false)
|
||||
const unimportModal = ref(false)
|
||||
const deleteFileOnUnimport = ref(false)
|
||||
const unimportEpTarget = ref<{ ep: any; season: any } | null>(null)
|
||||
const unimportSeasonModal = ref(false)
|
||||
const deleteSeasonFiles = ref(false)
|
||||
const downloadIcon = h(Download, { size: 14, class: 'shrink-0' })
|
||||
const checkIcon = h(Check, { size: 14, class: 'shrink-0' })
|
||||
|
||||
// ── Hash et progression ──
|
||||
function extractHash(torrent: any): string | null {
|
||||
// Préférer l'infohash direct (présent même si le magnet est absent)
|
||||
if (torrent?.infohash) return torrent.infohash.toLowerCase()
|
||||
if (!torrent?.magnet) return null
|
||||
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||
return m ? m[1].toLowerCase() : null
|
||||
}
|
||||
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
|
||||
if (!hash) return null
|
||||
return props.activeTorrents.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
|
||||
}
|
||||
function isDownloading(key: string) { return props.downloading.includes(key) }
|
||||
function isDownloaded(key: string) { return props.downloaded.includes(key) }
|
||||
function isAlreadyQueued(torrent: any): boolean {
|
||||
// Fichier d'un pack : le hash ne suffit pas, on se fie à downloading/downloaded
|
||||
if (torrent?.file_index != null) return false
|
||||
const hash = extractHash(torrent)
|
||||
if (!hash) return false
|
||||
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase())
|
||||
}
|
||||
|
||||
// ── État de la saison ──
|
||||
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
|
||||
const hasDownloadable = computed(() =>
|
||||
props.season.episodes.some((ep: any) =>
|
||||
ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)
|
||||
)
|
||||
)
|
||||
const canDownloadSeason = computed(() =>
|
||||
!!props.season.torrent && !isDownloading(`season-${props.season.id}`) && !isDownloaded(`season-${props.season.id}`) && !isAlreadyQueued(props.season.torrent) && props.season.organized_state !== 'complete'
|
||||
)
|
||||
const seasonBtnLabel = computed(() => {
|
||||
if (props.season.organized_state === 'complete') return 'Importée'
|
||||
if (isAlreadyQueued(props.season.torrent)) return 'Déjà dans le client'
|
||||
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyée au client'
|
||||
return 'Télécharger la saison'
|
||||
})
|
||||
const seasonBtnIcon = computed(() => {
|
||||
if (props.season.organized_state === 'complete' || isDownloaded(`season-${props.season.id}`) || isAlreadyQueued(props.season.torrent)) return h(Check, { size: 14 })
|
||||
if (isDownloading(`season-${props.season.id}`)) return h(Loader, { size: 14, class: 'animate-spin' })
|
||||
if (!props.season.torrent) return h(X, { size: 14 })
|
||||
return h(Download, { size: 14 })
|
||||
})
|
||||
|
||||
// ── Résumés de saison ──
|
||||
const queuedCount = computed(() => props.season.episodes.filter((ep: any) => !ep.organized && epState(ep) === 'loading').length)
|
||||
const missingCount = computed(() => props.season.episodes.filter((ep: any) => !ep.organized).length)
|
||||
const downloadableCount = computed(() =>
|
||||
props.season.episodes.filter((ep: any) => ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)).length
|
||||
)
|
||||
const downloadableLabel = computed(() => {
|
||||
if (downloadableCount.value === props.season.episodes.length) return 'la saison'
|
||||
return downloadableCount.value > 1 ? `${downloadableCount.value} épisodes` : "l'épisode"
|
||||
})
|
||||
|
||||
// Admin : « 12 épisodes · 12 disponibles · 5 importés · 1 en file » ; invité : ce qui est déjà visible
|
||||
const seasonMeta = computed(() => {
|
||||
const total = props.season.episodes.length
|
||||
const parts = [plural(total, 'épisode')]
|
||||
if (props.requestMode) {
|
||||
const ready = props.season.organized_count ?? 0
|
||||
parts.push(ready === 0 ? 'aucun disponible' : ready >= total ? 'tous disponibles' : plural(ready, 'disponible'))
|
||||
return parts.join(' · ')
|
||||
}
|
||||
if (availableCount.value > 0) parts.push(plural(availableCount.value, 'disponible'))
|
||||
if (props.season.organized_count > 0) parts.push(plural(props.season.organized_count, 'importé'))
|
||||
if (queuedCount.value > 0) parts.push(`${queuedCount.value} en file`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
// Date · durée · langue · versions · plages d'épisodes et de chapitres, en texte
|
||||
function episodeMeta(ep: any): string {
|
||||
const { episodes, chapters } = parseEpChap(ep.plot)
|
||||
const sources = ep.torrents?.length > 1 ? `${ep.torrents.length} versions` : ''
|
||||
return [ep.aired && formatDate(ep.aired), ep.duration && formatDuration(ep.duration), epLang(ep), sources, episodes, chapters]
|
||||
.filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
// ── État de l'épisode ──
|
||||
|
||||
// Clés ep-{id} (torrent unique) et ep-{id}-{i} (plusieurs torrents)
|
||||
function epAnyDownloaded(ep: any): boolean {
|
||||
if (isDownloaded(`ep-${ep.id}`)) return true
|
||||
const count = ep.torrents?.length ?? 0
|
||||
for (let i = 0; i < count; i++) { if (isDownloaded(`ep-${ep.id}-${i}`)) return true }
|
||||
return false
|
||||
}
|
||||
function epAnyDownloading(ep: any): boolean {
|
||||
if (isDownloading(`ep-${ep.id}`)) return true
|
||||
const count = ep.torrents?.length ?? 0
|
||||
for (let i = 0; i < count; i++) { if (isDownloading(`ep-${ep.id}-${i}`)) return true }
|
||||
return false
|
||||
}
|
||||
|
||||
function epProgress(ep: any): ActiveTorrent | null {
|
||||
if (ep.organized) return null
|
||||
|
||||
// Chercher parmi toutes les options de torrent, pas seulement ep.torrent (= torrents[0])
|
||||
const candidates: Array<{ torrent: any; key0: string; keyI: string }> = []
|
||||
if (ep.torrents?.length > 0) {
|
||||
ep.torrents.forEach((t: any, i: number) => candidates.push({ torrent: t, key0: `ep-${ep.id}`, keyI: `ep-${ep.id}-${i}` }))
|
||||
} else if (ep.torrent) {
|
||||
candidates.push({ torrent: ep.torrent, key0: `ep-${ep.id}`, keyI: `ep-${ep.id}` })
|
||||
}
|
||||
|
||||
for (const { torrent: t, key0, keyI } of candidates) {
|
||||
const active = torrentProgress(extractHash(t))
|
||||
if (!active) continue
|
||||
|
||||
if (t.file_index != null) {
|
||||
const sessionDl = isDownloaded(key0) || isDownloaded(keyI)
|
||||
if (active.files && active.files.length > 0) {
|
||||
const file = active.files.find(f => f.index === t.file_index)
|
||||
// Fichier à 100 % hors session (ex. retiré puis revu) : pas d'indicateur
|
||||
if (file != null && file.priority !== 0 && (sessionDl || file.progress < 1)) {
|
||||
return { ...active, progress: Math.round(file.progress * 100) }
|
||||
}
|
||||
if (!sessionDl) continue
|
||||
} else {
|
||||
if (!sessionDl) continue
|
||||
return { ...active, progress: active.state === 'seeding' ? 100 : active.progress }
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function handleEpBtnClick(ep: any) {
|
||||
if (ep.organized) return
|
||||
if (ep.torrents && ep.torrents.length > 1) {
|
||||
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
|
||||
return
|
||||
}
|
||||
if (ep.torrent && !isAlreadyQueued(ep.torrent)) {
|
||||
emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet, ep.torrent.file_index ?? null, ep.torrent.file_path ?? null, ep.torrent.infohash ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEpRequestClick(ep: any) {
|
||||
if (isEpisodeRequested(ep.id)) return
|
||||
if (ep.torrents && ep.torrents.length > 1) {
|
||||
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
|
||||
return
|
||||
}
|
||||
emit('requestEpisode', props.season.season_number, ep.id, ep.torrent ?? undefined)
|
||||
}
|
||||
|
||||
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
|
||||
if (ep.organized) return 'done'
|
||||
if (epAnyDownloaded(ep) || epAnyDownloading(ep) || isAlreadyQueued(ep.torrent)) return 'loading'
|
||||
const prog = epProgress(ep)
|
||||
if (prog) return 'loading'
|
||||
if (!ep.torrent || !ep.available) return 'unavailable'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
// ── Renommage ──
|
||||
function epExpectedName(ep: any): string {
|
||||
const entry = props.organizedByEpisode[String(ep.id)]
|
||||
if (!entry) return ''
|
||||
const srcExt = entry.dest_filename ? '.' + entry.dest_filename.split('.').pop() : '.mkv'
|
||||
if (props.nfoSupport) {
|
||||
return ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : entry.dest_filename
|
||||
} else {
|
||||
const usedHash = entry.hash?.toLowerCase() ?? null
|
||||
const matchedName = usedHash
|
||||
? (ep.torrents ?? []).find((t: any) => t.infohash === usedHash)?.formatted_name
|
||||
: null
|
||||
const name = matchedName
|
||||
?? (ep.torrents ?? []).find((t: any) => t.formatted_name)?.formatted_name
|
||||
?? ep.formatted_name
|
||||
return name?.trim() ? name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : entry.dest_filename
|
||||
}
|
||||
}
|
||||
function epNeedsRename(ep: any): boolean {
|
||||
const entry = props.organizedByEpisode[String(ep.id)]
|
||||
if (!entry) return false
|
||||
return entry.needs_rename === true
|
||||
}
|
||||
|
||||
// ── Épisodes et chapitres cités dans le synopsis ──
|
||||
// Cache : le synopsis est analysé plusieurs fois par rendu
|
||||
const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parseEpChap>>()
|
||||
// Extrait les plages d'épisodes et de chapitres, et retire du synopsis les lignes qui les citent
|
||||
function _parseEpChap(plot: string | null | undefined): {
|
||||
episodes: string | null
|
||||
season: string | null
|
||||
chapters: string |null
|
||||
cleanPlot: string
|
||||
} {
|
||||
const text = plot ?? ''
|
||||
|
||||
const epMatch = text.match(
|
||||
/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?(?:\s+de\s+la\s+saison\s+(\d+))?/i
|
||||
)
|
||||
|
||||
const seasonMatch = text.match(/saison\s+(\d+)/i)
|
||||
|
||||
const season = epMatch?.[3] ?? seasonMatch?.[1] ?? null
|
||||
|
||||
const chapMatch = text.match(
|
||||
/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i
|
||||
)
|
||||
|
||||
const episodes = epMatch
|
||||
? `${season ? `S${season} · ` : ''}${
|
||||
epMatch[2]
|
||||
? `Ép. ${epMatch[1]}–${epMatch[2]}`
|
||||
: `Ép. ${epMatch[1]}`
|
||||
}`
|
||||
: null
|
||||
|
||||
|
||||
|
||||
const chapters = chapMatch
|
||||
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
|
||||
: null
|
||||
|
||||
let cleanPlot = text
|
||||
if (episodes || chapters || season) {
|
||||
cleanPlot = text
|
||||
.split(/\r?\n/)
|
||||
.filter(line => !/(?:[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+|saison\s+\d+)/i.test(line))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
return { episodes, season, chapters, cleanPlot }
|
||||
}
|
||||
function parseEpChap(plot: string | null | undefined) {
|
||||
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
|
||||
const result = _parseEpChap(plot)
|
||||
_epChapCache.set(plot, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Langue ──
|
||||
function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
|
||||
const sources: string[] = [
|
||||
ep.formatted_name ?? '',
|
||||
ep.torrent?.torrent_name ?? '',
|
||||
ep.torrent?.raw ?? '',
|
||||
...((ep.torrents ?? []) as any[]).map((t: any) => `${t.torrent_name ?? ''} ${t.raw ?? ''}`),
|
||||
]
|
||||
for (const s of sources) {
|
||||
if (!s) continue
|
||||
if (/\bMULTI\b/i.test(s)) return 'MULTI'
|
||||
if (/\bVOSTFR\b/i.test(s)) return 'VOSTFR'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Libellés des versions ──
|
||||
function labelFromRaw(raw: string, index: number): string {
|
||||
if (!raw) return `Option ${index + 1}`
|
||||
return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
|
||||
}
|
||||
|
||||
function groupLabels(torrents: any[]): string[] {
|
||||
const names = torrents.map((t: any) => t.torrent_name ?? null)
|
||||
const allPresent = names.every(n => n !== null && String(n).trim() !== '')
|
||||
const allUnique = allPresent && new Set(names).size === names.length
|
||||
if (allUnique) return names as string[]
|
||||
return torrents.map((t: any, i: number) => labelFromRaw(t.raw ?? '', i))
|
||||
}
|
||||
|
||||
// Packs couvrant ≥ 2 épisodes, proposés si la saison n'a pas de pack dédié
|
||||
const uniquePackOptions = computed(() => {
|
||||
if (props.season.torrents?.length) return []
|
||||
|
||||
const hashCount = new Map<string, number>()
|
||||
const hashTorrent = new Map<string, any>()
|
||||
for (const ep of props.season.episodes) {
|
||||
if (!ep.available) continue
|
||||
for (const t of (ep.torrents ?? [])) {
|
||||
const hash = t.infohash ?? extractHash(t)
|
||||
if (!hash) continue
|
||||
hashCount.set(hash, (hashCount.get(hash) ?? 0) + 1)
|
||||
if (!hashTorrent.has(hash)) hashTorrent.set(hash, t)
|
||||
}
|
||||
}
|
||||
|
||||
const packs = [...hashCount.entries()]
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([hash]) => ({ infohash: hash, torrent: hashTorrent.get(hash)! }))
|
||||
|
||||
if (packs.length <= 1) return []
|
||||
const labels = groupLabels(packs.map(r => r.torrent))
|
||||
return packs.map((r, i) => ({ infohash: r.infohash, label: labels[i] }))
|
||||
})
|
||||
|
||||
function formatDate(d: string): string {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
// 4 h 10, 24 min
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return ''
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) return m > 0 ? `${h} h ${String(m).padStart(2, '0')}` : `${h} h`
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
function togglePlot(epId: number) {
|
||||
plotOpen.value = plotOpen.value === epId ? null : epId
|
||||
}
|
||||
|
||||
function openUnimportModal(ep: any, season: any) {
|
||||
unimportEpTarget.value = { ep, season }
|
||||
deleteFileOnUnimport.value = false
|
||||
unimportModal.value = true
|
||||
}
|
||||
|
||||
function confirmUnimport() {
|
||||
if (!unimportEpTarget.value) return
|
||||
emit('unimportEpisode', unimportEpTarget.value.ep, unimportEpTarget.value.season, deleteFileOnUnimport.value)
|
||||
unimportModal.value = false
|
||||
unimportEpTarget.value = null
|
||||
}
|
||||
|
||||
function openUnimportSeasonModal() {
|
||||
deleteSeasonFiles.value = false
|
||||
unimportSeasonModal.value = true
|
||||
}
|
||||
|
||||
function confirmUnimportSeason() {
|
||||
emit('unimportSeason', props.season, deleteSeasonFiles.value)
|
||||
unimportSeasonModal.value = false
|
||||
}
|
||||
|
||||
function closeAllMenus() {
|
||||
epOptionsOpen.value = null
|
||||
seasonMenuOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', closeAllMenus))
|
||||
onUnmounted(() => document.removeEventListener('click', closeAllMenus))
|
||||
|
||||
defineExpose({ closeEpMenu: closeAllMenus })
|
||||
</script>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div data-tour="series-toolbar" class="flex flex-col gap-[18px]">
|
||||
|
||||
<header class="flex items-center justify-between gap-x-4 gap-y-3 flex-wrap min-h-11">
|
||||
<div class="flex items-baseline gap-3.5">
|
||||
<h1 class="page-title tracking-[0.01em]">Médiathèque</h1>
|
||||
<span class="text-[13px] text-muted">{{ plural(count, 'série') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2.5 w-full md:w-auto">
|
||||
<label class="flex-1 md:flex-none md:w-[320px] h-10 rounded-full bg-card border border-border-light flex items-center gap-2.5 pl-4 pr-2 cursor-text focus-within:border-accent transition-colors">
|
||||
<Search :size="16" :stroke-width="1.75" class="text-muted shrink-0" />
|
||||
<input
|
||||
data-library-search
|
||||
:value="search"
|
||||
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||
@keydown.esc="emit('update:search', '')"
|
||||
type="text"
|
||||
placeholder="Rechercher une série"
|
||||
aria-label="Rechercher une série"
|
||||
class="flex-1 min-w-0 bg-transparent outline-none text-sm text-primary placeholder:text-muted"
|
||||
/>
|
||||
<button v-if="search" @click="emit('update:search', '')" aria-label="Effacer la recherche" class="w-7 h-7 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors">
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<button
|
||||
v-if="selectable"
|
||||
data-tour="series-select"
|
||||
@click="emit('update:selecting', !selecting)"
|
||||
class="h-10 px-4 rounded-full border text-body font-medium flex items-center gap-2 shrink-0 transition-colors"
|
||||
:class="selecting ? 'border-accent text-accent bg-accent-muted' : 'border-border-light text-secondary hover:text-primary hover:bg-hover'"
|
||||
:title="selecting ? 'Quitter la sélection' : 'Sélectionner plusieurs séries'"
|
||||
>
|
||||
<SquareCheck :size="16" :stroke-width="1.75" />
|
||||
<span class="hidden sm:inline">Sélectionner</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div ref="rowRef" class="relative">
|
||||
<div class="chip-row md:justify-between md:gap-x-4 md:gap-y-2.5" @scroll="moreOpen = false; sortOpen = false">
|
||||
<div class="flex items-center gap-1.5 md:flex-wrap md:shrink md:min-w-0">
|
||||
<button
|
||||
v-for="f in filters" :key="f.value"
|
||||
@click="emit('update:activeFilter', f.value)"
|
||||
class="chip shrink-0"
|
||||
:class="{ 'is-active': activeFilter === f.value }"
|
||||
:aria-pressed="activeFilter === f.value"
|
||||
>
|
||||
{{ f.label }}<span class="chip-count" :class="{ 'text-accent! font-bold': f.attention && f.count > 0 && activeFilter !== f.value }">{{ f.count }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="moreFilters.length"
|
||||
ref="moreBtnRef"
|
||||
@click="toggleMore"
|
||||
class="chip shrink-0 pr-3"
|
||||
:class="{ 'is-active': activeMore }"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="moreOpen"
|
||||
>
|
||||
{{ activeMore ? activeMore.label : 'Plus' }}<span v-if="activeMore" class="chip-count">{{ activeMore.count }}</span>
|
||||
<ChevronDown :size="14" class="transition-transform" :class="{ 'rotate-180': moreOpen }" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button ref="sortBtnRef" @click="toggleSort" class="chip shrink-0 pr-3" aria-haspopup="menu" :aria-expanded="sortOpen">
|
||||
{{ sortOptions.find(s => s.value === activeSort)?.label }}
|
||||
<ChevronDown :size="14" class="transition-transform" :class="{ 'rotate-180': sortOpen }" />
|
||||
</button>
|
||||
|
||||
<!-- Sur mobile, la grille garde deux colonnes -->
|
||||
<div class="segmented max-sm:hidden" role="group" aria-label="Taille des affiches">
|
||||
<button
|
||||
v-for="(_, size) in posterSizes" :key="size"
|
||||
@click="emit('update:posterSize', size)"
|
||||
class="segmented-item w-[34px] justify-center px-0 text-[11.5px] font-bold"
|
||||
:class="posterSize === size ? 'bg-hover text-primary' : 'text-muted'"
|
||||
:aria-pressed="posterSize === size"
|
||||
:title="`Taille des affiches : ${size}`"
|
||||
>
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="moreOpen" ref="moreMenuRef" role="menu" class="menu absolute top-full mt-1.5 w-56 z-20" :style="{ left: `${menuLeft}px` }">
|
||||
<button
|
||||
v-for="f in moreFilters" :key="f.value"
|
||||
role="menuitem"
|
||||
@click="emit('update:activeFilter', activeFilter === f.value ? 'all' : f.value); moreOpen = false"
|
||||
class="menu-item justify-between"
|
||||
:class="{ 'text-primary! bg-hover': activeFilter === f.value }"
|
||||
>
|
||||
{{ f.label }}<span class="text-meta text-muted">{{ f.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="sortOpen" ref="sortMenuRef" role="menu" class="menu absolute top-full mt-1.5 w-44 z-20" :style="{ left: `${menuLeft}px` }">
|
||||
<button
|
||||
v-for="s in sortOptions" :key="s.value"
|
||||
role="menuitem"
|
||||
@click="emit('update:activeSort', s.value); sortOpen = false"
|
||||
class="menu-item justify-between"
|
||||
:class="{ 'text-primary! bg-hover': activeSort === s.value }"
|
||||
>
|
||||
{{ s.label }}<Check v-if="activeSort === s.value" :size="14" class="text-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Check, ChevronDown, Search, SquareCheck, X } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { plural } from '@/utils/format'
|
||||
|
||||
export interface LibraryFilter { value: string; label: string; count: number; attention?: boolean }
|
||||
|
||||
const props = defineProps<{
|
||||
search : string
|
||||
activeFilter : string
|
||||
activeSort : string
|
||||
posterSize : string
|
||||
posterSizes : Record<string, string>
|
||||
count : number
|
||||
filters : LibraryFilter[]
|
||||
moreFilters : LibraryFilter[]
|
||||
sortOptions : { label: string; value: string }[]
|
||||
selectable? : boolean
|
||||
selecting? : boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:search' : [value: string]
|
||||
'update:activeFilter': [value: string]
|
||||
'update:activeSort' : [value: string]
|
||||
'update:posterSize' : [value: string]
|
||||
'update:selecting' : [value: boolean]
|
||||
}>()
|
||||
|
||||
const activeMore = computed(() => props.moreFilters.find(f => f.value === props.activeFilter) ?? null)
|
||||
|
||||
const moreOpen = ref(false)
|
||||
const sortOpen = ref(false)
|
||||
const rowRef = ref<HTMLElement | null>(null)
|
||||
const moreBtnRef = ref<HTMLElement | null>(null)
|
||||
const sortBtnRef = ref<HTMLElement | null>(null)
|
||||
const moreMenuRef = ref<HTMLElement | null>(null)
|
||||
const sortMenuRef = ref<HTMLElement | null>(null)
|
||||
const menuLeft = ref(0)
|
||||
|
||||
onClickOutside(moreMenuRef, () => { moreOpen.value = false }, { ignore: [moreBtnRef] })
|
||||
onClickOutside(sortMenuRef, () => { sortOpen.value = false }, { ignore: [sortBtnRef] })
|
||||
|
||||
// Menu sous son bouton, aligné à droite s'il déborde de la ligne
|
||||
function placeMenu(btn: HTMLElement | null, width: number) {
|
||||
if (!btn || !rowRef.value) return
|
||||
const row = rowRef.value.getBoundingClientRect()
|
||||
const b = btn.getBoundingClientRect()
|
||||
const left = b.left - row.left + width > row.width ? b.right - row.left - width : b.left - row.left
|
||||
menuLeft.value = Math.max(0, left)
|
||||
}
|
||||
|
||||
function toggleMore() {
|
||||
sortOpen.value = false
|
||||
moreOpen.value = !moreOpen.value
|
||||
if (moreOpen.value) placeMenu(moreBtnRef.value, 224)
|
||||
}
|
||||
|
||||
function toggleSort() {
|
||||
moreOpen.value = false
|
||||
sortOpen.value = !sortOpen.value
|
||||
if (sortOpen.value) placeMenu(sortBtnRef.value, 176)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="jellyfin-url" class="field-label">Adresse du serveur</label>
|
||||
<input id="jellyfin-url" v-model="jellyfinUrl" type="url" class="field" placeholder="http://jellyfin:8096" />
|
||||
<p class="text-xs text-muted">Adresse joignable depuis FanKarr (réseau local ou Docker).</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="jellyfin-token" class="field-label">Clé API administrateur</label>
|
||||
<input id="jellyfin-token" v-model="jellyfinAdminToken" type="password" class="field" autocomplete="off"
|
||||
:placeholder="hasToken ? '•••••••••••••••••••• (configurée)' : 'Collez la clé API'" />
|
||||
<p class="text-xs text-muted">Dans Jellyfin : Tableau de bord › Clés API › Créer une clé.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<button @click="save" :disabled="saving" class="btn-primary pointer-fine:h-[38px]">
|
||||
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
|
||||
</button>
|
||||
<button @click="testConnection" :disabled="testing" class="btn-secondary pointer-fine:h-[38px]">
|
||||
{{ testing ? 'Test…' : 'Tester la connexion' }}
|
||||
</button>
|
||||
<span v-if="testResult" class="pill" :class="testResult.ok ? 'pill-ok' : 'pill-err'">
|
||||
<Check v-if="testResult.ok" :size="12" :stroke-width="2.5" />
|
||||
<TriangleAlert v-else :size="12" :stroke-width="2.25" />
|
||||
{{ testResult.ok ? `Connecté · Jellyfin ${testResult.version}` : testResult.error }}
|
||||
</span>
|
||||
<span v-if="saved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
|
||||
<span v-if="saveError" class="text-meta text-err">{{ saveError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Check, TriangleAlert } from 'lucide-vue-next'
|
||||
|
||||
interface JellyfinTestResult { ok: boolean; version?: string; error?: string }
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [state: { jellyfinUrl: string; hasToken: boolean; test: JellyfinTestResult | null }]
|
||||
}>()
|
||||
|
||||
const jellyfinUrl = ref('')
|
||||
const jellyfinAdminToken = ref('')
|
||||
const hasToken = ref(false)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
const testing = ref(false)
|
||||
const testResult = ref<JellyfinTestResult | null>(null)
|
||||
|
||||
function notify() {
|
||||
emit('change', { jellyfinUrl: jellyfinUrl.value, hasToken: hasToken.value, test: testResult.value })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch('/api/jellyfin/settings', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
jellyfinUrl.value = d.jellyfinUrl ?? ''
|
||||
hasToken.value = d.hasToken ?? false
|
||||
notify()
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
saveError.value = null
|
||||
const body: Record<string, string> = { jellyfinUrl: jellyfinUrl.value }
|
||||
if (jellyfinAdminToken.value) body.jellyfinAdminToken = jellyfinAdminToken.value
|
||||
const res = await fetch('/api/jellyfin/settings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => null)
|
||||
if (!res?.ok) {
|
||||
const d = await res?.json().catch(() => ({}))
|
||||
saveError.value = d?.error ?? 'Impossible d\'enregistrer les réglages Jellyfin.'
|
||||
} else {
|
||||
const d = await res.json()
|
||||
hasToken.value = d.hasToken
|
||||
jellyfinAdminToken.value = ''
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
notify()
|
||||
// Le test du serveur n'utilise que les valeurs enregistrées
|
||||
if (jellyfinUrl.value && hasToken.value) await testConnection()
|
||||
}
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const res = await fetch('/api/jellyfin/test', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
jellyfinUrl,
|
||||
jellyfinAdminToken
|
||||
})
|
||||
})
|
||||
testResult.value = await res.json()
|
||||
} catch {
|
||||
testResult.value = { ok: false, error: 'Impossible de contacter le serveur' }
|
||||
}
|
||||
testing.value = false
|
||||
notify()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="modal-backdrop" @click.self="emit('close')">
|
||||
<div class="bg-card border border-border rounded-card w-full max-w-lg flex flex-col max-h-[85vh] overflow-y-auto shadow-[0_24px_60px_rgb(0_0_0/0.55)]" role="dialog" aria-modal="true" aria-labelledby="plex-title">
|
||||
|
||||
<div class="flex items-center justify-between gap-4 px-6 py-4 border-b border-hover shrink-0">
|
||||
<div>
|
||||
<h3 id="plex-title" class="font-display text-xl font-bold text-primary">Configuration Plex</h3>
|
||||
<p class="text-meta text-muted mt-0.5">
|
||||
{{ plexStep === 'auth' ? 'Connectez votre compte Plex' :
|
||||
plexStep === 'server' ? 'Choisissez votre serveur Plex' :
|
||||
plexStep === 'library' ? 'Créez la bibliothèque Fankai' :
|
||||
plexStep === 'done' ? 'Configuration terminée' :
|
||||
'' }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<!-- Étape 1 : connexion -->
|
||||
<div v-if="plexStep === 'auth'" class="flex flex-col gap-4 px-6 py-5">
|
||||
<div v-if="!plexAuthMethod" class="flex flex-col gap-3">
|
||||
<button
|
||||
@click="startOAuth" :disabled="plexLoading"
|
||||
class="flex items-center gap-3 p-4 rounded-field border border-border-light hover:border-accent/40 hover:bg-accent-muted transition-colors text-left"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full bg-accent-muted text-accent flex items-center justify-center shrink-0">
|
||||
<Clock3 :size="16" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">{{ plexLoading ? 'Ouverture…' : 'Connexion via Plex' }}</p>
|
||||
<p class="text-meta text-muted">Google, Apple ou compte Plex</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="plexAuthMethod = 'credentials'"
|
||||
class="flex items-center gap-3 p-4 rounded-field border border-border-light hover:bg-hover transition-colors text-left"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full bg-hover text-secondary flex items-center justify-center shrink-0">
|
||||
<User :size="16" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">E-mail et mot de passe</p>
|
||||
<p class="text-meta text-muted">Sans passer par la page de connexion Plex</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="plexAuthMethod === 'oauth'" class="flex flex-col items-center gap-4 py-4 text-center">
|
||||
<div class="w-10 h-10 border-2 border-border border-t-accent rounded-full animate-spin" />
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">En attente de la connexion Plex…</p>
|
||||
<p class="text-meta text-muted mt-1">Connectez-vous dans l'onglet Plex qui vient de s'ouvrir. Rien ne s'est ouvert ? Autorisez les fenêtres pop-up.</p>
|
||||
</div>
|
||||
<button @click="cancelOAuth" class="btn-ghost btn-sm">Annuler</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="plexAuthMethod === 'credentials'">
|
||||
<div>
|
||||
<label class="field-label mb-1 block">Adresse e-mail Plex</label>
|
||||
<input v-model="plexForm.username" type="email" class="field w-full" placeholder="email@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label mb-1 block">Mot de passe</label>
|
||||
<input v-model="plexForm.password" type="password" class="field w-full" placeholder="••••••••" @keyup.enter="plexConnect" />
|
||||
</div>
|
||||
<div v-if="plexNeeds2FA">
|
||||
<label class="field-label mb-1 block">Code de validation (6 chiffres)</label>
|
||||
<input v-model="plexForm.code" type="text" class="field w-full" placeholder="123456" maxlength="6" @keyup.enter="plexConnect" />
|
||||
</div>
|
||||
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="plexConnect" :disabled="plexLoading || !plexForm.username || !plexForm.password" class="btn-primary">
|
||||
{{ plexLoading ? 'Connexion…' : 'Se connecter' }}
|
||||
</button>
|
||||
<button @click="plexAuthMethod = null" class="btn-secondary">Retour</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="plexError && !plexAuthMethod" class="text-meta text-err">{{ plexError }}</p>
|
||||
<button v-if="!plexAuthMethod" @click="emit('close')" class="btn-secondary self-start">Annuler</button>
|
||||
</div>
|
||||
|
||||
<!-- Étape 2 : choix du serveur -->
|
||||
<div v-else-if="plexStep === 'server'" class="flex flex-col gap-3 px-6 py-5">
|
||||
<p class="text-meta text-muted">{{ plural(plexServers.length, 'serveur trouvé', 'serveurs trouvés') }}</p>
|
||||
<div
|
||||
v-for="(server, i) in plexServers" :key="i"
|
||||
@click="selectServer(server)"
|
||||
class="flex items-center justify-between p-3 rounded-field border cursor-pointer transition-colors"
|
||||
:class="plexSelectedServer === server ? 'border-accent/50 bg-accent-muted' : 'border-border-light hover:bg-hover'"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">{{ server.name }}</p>
|
||||
<p class="text-meta text-muted">{{ server.owned ? 'Votre serveur' : 'Partagé avec vous' }}</p>
|
||||
</div>
|
||||
<Check v-if="plexSelectedServer === server" :size="15" :stroke-width="2.5" class="text-accent" />
|
||||
</div>
|
||||
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<button @click="plexConnectServer" :disabled="!plexSelectedServer || plexLoading" class="btn-primary">
|
||||
{{ plexLoading ? 'Connexion…' : 'Continuer' }}
|
||||
</button>
|
||||
<button @click="plexStep = 'auth'" class="btn-secondary">Retour</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 3 : Bibliothèque -->
|
||||
<div v-else-if="plexStep === 'library'" class="flex flex-col gap-4 px-6 py-5">
|
||||
<div>
|
||||
<label class="field-label mb-1 block">Nom de la bibliothèque</label>
|
||||
<input v-model="plexForm.libraryName" type="text" class="field w-full" placeholder="Fankai" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label mb-1 block">Chemin de la médiathèque</label>
|
||||
<p class="text-xs text-muted mb-1.5">Le dossier de la médiathèque tel que Plex le voit (il peut différer du chemin FanKarr).</p>
|
||||
<input v-model="plexForm.libraryPath" type="text" class="field w-full" placeholder="/data/media/fankai" />
|
||||
</div>
|
||||
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="plexSetup" :disabled="plexLoading || !plexForm.libraryName || !plexForm.libraryPath" class="btn-primary">
|
||||
{{ plexLoading ? 'Création…' : 'Créer la bibliothèque' }}
|
||||
</button>
|
||||
<button @click="plexStep = 'server'" class="btn-secondary">Retour</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 4 : Résultat -->
|
||||
<div v-else-if="plexStep === 'done'" class="flex flex-col gap-4 px-6 py-5">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="s in plexSteps" :key="s.step" class="flex items-center gap-2.5">
|
||||
<div class="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
|
||||
:class="s.ok ? 'bg-ok/15 text-ok' : 'bg-err/15 text-err'">
|
||||
<Check v-if="s.ok" :size="11" :stroke-width="3" />
|
||||
<X v-else :size="11" :stroke-width="3" />
|
||||
</div>
|
||||
<p class="text-meta" :class="s.ok ? 'text-primary' : 'text-err'">{{ s.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="plexManualSetup" class="bg-accent/5 border border-accent/30 rounded-field p-3">
|
||||
<p class="text-meta text-accent font-bold mb-2">Configuration manuelle de l'agent requise (Plex < 1.43)</p>
|
||||
<ol class="text-xs text-muted space-y-1 list-decimal list-inside">
|
||||
<li>Rendez-vous sur la page <span class="text-primary">Metadata Agent</span> de Plex</li>
|
||||
<li>Dans <span class="text-primary">Metadata Provider</span>, ajoutez : <code class="bg-hover px-1 rounded">https://metadata.fankai.fr/plex</code></li>
|
||||
<li>Dans <span class="text-primary">Metadata Agent</span>, créez un agent qui utilise ce fournisseur</li>
|
||||
<li>Dans le menu ⋯ de votre bibliothèque, <span class="text-primary">Manage Library › Edit › Advanced › Agent</span> : sélectionnez l'agent Fankai</li>
|
||||
<li>Actualisez les métadonnées</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<button @click="emit('close')" class="btn-primary self-end">Fermer</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { Check, Clock3, User, X } from 'lucide-vue-next'
|
||||
import { plural } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{ mediaPath: string }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const plexStep = ref<'auth' | 'server' | 'library' | 'done'>('auth')
|
||||
const plexLoading = ref(false)
|
||||
const plexError = ref('')
|
||||
const plexNeeds2FA = ref(false)
|
||||
const plexToken = ref('')
|
||||
const plexServers = ref<any[]>([])
|
||||
const plexSelectedServer = ref<any>(null)
|
||||
const plexServerUrl = ref('')
|
||||
const plexManualSetup = ref(false)
|
||||
const plexSteps = ref<{ step: string; ok: boolean; message: string }[]>([])
|
||||
const plexAuthMethod = ref<'oauth' | 'credentials' | null>(null)
|
||||
const plexOAuthPinId = ref<string | null>(null)
|
||||
let plexOAuthTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const plexForm = ref({
|
||||
username : '',
|
||||
password : '',
|
||||
code : '',
|
||||
libraryName: 'Fankai',
|
||||
libraryPath: props.mediaPath,
|
||||
})
|
||||
|
||||
// ── Connexion via Plex ──
|
||||
async function startOAuth() {
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/plex/oauth/start', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { plexError.value = data.error ?? "Impossible d'ouvrir la connexion Plex."; return }
|
||||
plexOAuthPinId.value = String(data.pinId)
|
||||
plexAuthMethod.value = 'oauth'
|
||||
window.open(data.authUrl, '_blank')
|
||||
let attempts = 0
|
||||
plexOAuthTimer = setInterval(async () => {
|
||||
attempts++
|
||||
if (attempts > 150) { cancelOAuth(); plexError.value = 'Délai dépassé. Réessayez.'; return }
|
||||
try {
|
||||
const r = await fetch(`/api/plex/oauth/poll/${plexOAuthPinId.value}`, { credentials: 'include' })
|
||||
const d = await r.json()
|
||||
if (d.ok && !d.pending) {
|
||||
cancelOAuth()
|
||||
plexToken.value = d.token
|
||||
plexServers.value = d.servers
|
||||
if (d.servers.length === 1) { plexSelectedServer.value = d.servers[0]; await plexConnectServer() }
|
||||
else plexStep.value = 'server'
|
||||
}
|
||||
} catch {}
|
||||
}, 2000)
|
||||
} catch {
|
||||
plexError.value = "Impossible d'ouvrir la connexion Plex."
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelOAuth() {
|
||||
if (plexOAuthTimer) { clearInterval(plexOAuthTimer); plexOAuthTimer = null }
|
||||
plexOAuthPinId.value = null
|
||||
if (plexAuthMethod.value === 'oauth') plexAuthMethod.value = null
|
||||
}
|
||||
|
||||
// ── Identifiants ──
|
||||
async function plexConnect() {
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/plex/connect', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ username: plexForm.value.username, password: plexForm.value.password, code: plexForm.value.code || undefined }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez le code de validation en deux étapes.' }
|
||||
else plexError.value = data.error ?? 'Impossible de se connecter à Plex. Vérifiez vos identifiants.'
|
||||
return
|
||||
}
|
||||
plexToken.value = data.token
|
||||
plexServers.value = data.servers
|
||||
if (data.servers.length === 1) { plexSelectedServer.value = data.servers[0]; await plexConnectServer() }
|
||||
else plexStep.value = 'server'
|
||||
} catch {
|
||||
plexError.value = 'Impossible de contacter le serveur.'
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectServer(server: any) { plexSelectedServer.value = server }
|
||||
|
||||
async function plexConnectServer() {
|
||||
if (!plexSelectedServer.value) return
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
try {
|
||||
const conns = plexSelectedServer.value.connections ?? []
|
||||
const conn = conns.find((c: any) => c.local && !c.relay) ?? conns.find((c: any) => !c.local && !c.relay) ?? conns.find((c: any) => c.relay)
|
||||
plexServerUrl.value = conn?.uri ?? ''
|
||||
if (!plexServerUrl.value) throw new Error('Ce serveur Plex ne propose aucune adresse de connexion.')
|
||||
plexForm.value.libraryPath = props.mediaPath
|
||||
plexStep.value = 'library'
|
||||
} catch (err) {
|
||||
plexError.value = err instanceof Error ? err.message : 'Impossible de se connecter à ce serveur.'
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function plexSetup() {
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/plex/setup', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ token: plexToken.value, serverUrl: plexServerUrl.value, libraryName: plexForm.value.libraryName, libraryPath: plexForm.value.libraryPath }),
|
||||
})
|
||||
const data = await res.json()
|
||||
plexSteps.value = data.steps ?? []
|
||||
plexManualSetup.value = data.manualSetup ?? false
|
||||
plexStep.value = 'done'
|
||||
if (!data.ok) plexError.value = data.error ?? 'Impossible de configurer Plex.'
|
||||
} catch {
|
||||
plexError.value = 'Impossible de contacter le serveur.'
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => cancelOAuth())
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<section class="card flex flex-col gap-3.5">
|
||||
<div v-if="title || $slots.actions" class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div class="flex flex-col gap-0.5 min-w-0 flex-1 basis-[280px]">
|
||||
<h3 class="card-title">{{ title }}</h3>
|
||||
<p v-if="description || $slots.description" class="text-meta text-muted">
|
||||
<slot name="description">{{ description }}</slot>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="flex items-center gap-2.5 flex-wrap shrink-0">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title?: string; description?: string }>()
|
||||
</script>
|
||||