Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e13d43d186 | ||
|
|
804436ff70 | ||
|
|
c0452661d4 | ||
|
|
fedc3e82a4 | ||
|
|
bd8c233e45 | ||
|
|
8940ced4b1 | ||
|
|
7809a16685 | ||
|
|
ddc6d665b6 | ||
|
|
e8dc76e94a | ||
|
|
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 |
No files matched your search
@@ -5,4 +5,5 @@ node_modules
|
||||
config
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.example
|
||||
.test-sandbox
|
||||
@@ -1,22 +1,27 @@
|
||||
# FanKarr — Configuration (binaire bun)
|
||||
# Copier ce fichier en .env dans le même répertoire que le binaire fankarr
|
||||
# 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, mais recommandé de la fixer)
|
||||
# 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é du token de connexion
|
||||
# Exemples : "1d", "7d", "30d", "1y", "never" ou "0" pour pas d'expiration
|
||||
# Durée de validité de la session
|
||||
# Exemples : "1d", "7d", "30d", "1y" ; "never" ou "0" : pas d'expiration
|
||||
# AUTH_TOKEN_EXPIRY=30d
|
||||
|
||||
# Authentification par proxy (oauth2-proxy, Authelia…) : en-tête contenant le
|
||||
# nom d'utilisateur, lu uniquement pour les adresses de PROXY_AUTH_TRUSTED_PROXIES
|
||||
# PROXY_AUTH_HEADER=X-Forwarded-Preferred-Username
|
||||
# PROXY_AUTH_TRUSTED_PROXIES=127.0.0.1
|
||||
# PROXY_AUTH_AUTO_CREATE=false
|
||||
# PROXY_AUTH_LOGOUT_URL=/oauth2/sign_out
|
||||
|
||||
# ── Serveur ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Port d'écoute (défaut : 9898)
|
||||
# PORT=9898
|
||||
|
||||
# ── Données ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Répertoire de données (défaut : ./config/ à côté du binaire)
|
||||
# Utile pour pointer vers un volume Docker ou un répertoire partagé
|
||||
# DATA_DIR=/opt/fankarr/data
|
||||
# Sous-dossier derrière un reverse proxy (défaut : racine du domaine)
|
||||
# Exemple : /fankarr pour https://exemple.tld/fankarr
|
||||
# BASE_URL=/fankarr
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
|
Before Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 83 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: 820 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 92 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
|
||||
@@ -78,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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -27,3 +27,8 @@ binaries
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Instance de test locale
|
||||
.test-sandbox
|
||||
scripts/seed-test-sandbox.mjs
|
||||
docker-compose.test.yml
|
||||
@@ -1,5 +1,365 @@
|
||||
# Changelog
|
||||
|
||||
## [3.25.0](https://github.com/Masutayunikon/FanKarr/compare/v3.24.2...v3.25.0) (2026-09-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** connexion avec un compte Jellyfin ([bd8c233](https://github.com/Masutayunikon/FanKarr/commit/bd8c233e45147a8ed3187907f7531379e5d15d09))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **jellyfin:** vérifier que le jeton Jellyfin appartient à l'utilisateur annoncé ([bd8c233](https://github.com/Masutayunikon/FanKarr/commit/bd8c233e45147a8ed3187907f7531379e5d15d09))
|
||||
|
||||
## [3.24.2](https://github.com/Masutayunikon/FanKarr/compare/v3.24.1...v3.24.2) (2026-09-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **surveillance:** détection des nouveaux épisodes par la date ([#152](https://github.com/Masutayunikon/FanKarr/issues/152)) ([ddc6d66](https://github.com/Masutayunikon/FanKarr/commit/ddc6d665b6a0564f3a37a6fd211519381a813f5a))
|
||||
|
||||
## [3.24.1](https://github.com/Masutayunikon/FanKarr/compare/v3.24.0...v3.24.1) (2026-09-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **jellyfin:** fix test route to take body ([9097e36](https://github.com/Masutayunikon/FanKarr/commit/9097e369c80096daa66ac1df454691b949267ef1))
|
||||
|
||||
## [3.24.0](https://github.com/Masutayunikon/FanKarr/compare/v3.23.1...v3.24.0) (2026-09-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **onboarding:** assistant de configuration au premier lancement et visite guidée des écrans ([3ede3e8](https://github.com/Masutayunikon/FanKarr/commit/3ede3e808c1ad27f8534960a048dac8a3283096a))
|
||||
* **onboarding:** assistant de configuration au premier lancement et visite guidée des écrans ([4b3c629](https://github.com/Masutayunikon/FanKarr/commit/4b3c629ff918a907d04e6c54a1ef2046930523e0))
|
||||
* **ui:** refonte de l'interface ([09eb1fa](https://github.com/Masutayunikon/FanKarr/commit/09eb1fa3aa369d8961dd3a49cf74edd89ead3da7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correction mineures ([6a3cb21](https://github.com/Masutayunikon/FanKarr/commit/6a3cb21adf004dc63ede7df4701c0fec49ad5d3c))
|
||||
|
||||
## [3.23.1](https://github.com/Masutayunikon/FanKarr/compare/v3.23.0...v3.23.1) (2026-09-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **scrap:** Correction fallback si scraper KO ([f64d2e1](https://github.com/Masutayunikon/FanKarr/commit/f64d2e19f7f9ab838cee6d33f5523fabb22b5cf7))
|
||||
* **scrap:** Correction fallback si scraper KO ([6348cc4](https://github.com/Masutayunikon/FanKarr/commit/6348cc40cb42dd23e3b3e77aa1e96f813b9d2687))
|
||||
|
||||
## [3.23.0](https://github.com/Masutayunikon/FanKarr/compare/v3.22.0...v3.23.0) (2026-09-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **import:** catalogue unifié, renommage de dossier série et nettoya……ge des orphelins corection dédoublonnage ([c6b26d8](https://github.com/Masutayunikon/FanKarr/commit/c6b26d88f0a9c5b1092e162f43ff052efdbffb4d))
|
||||
* **import:** catalogue unifié, renommage de dossier série et nettoyage des orphelins corection dédoublonnage ([0ff7f21](https://github.com/Masutayunikon/FanKarr/commit/0ff7f21958ad33432123f76a2fa7830655329cbb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **import:** repli sur l'API Fankai quand une série est absente du scraper et retrait de Kodi des NFO ([1ca7eac](https://github.com/Masutayunikon/FanKarr/commit/1ca7eac48c9337c39b1d40a43a90c993e90d11ea))
|
||||
* **import:** repli sur l'API Fankai quand une série est absente du scraper et retrait de Kodi des NFO ([67794d9](https://github.com/Masutayunikon/FanKarr/commit/67794d92c8ab48a402bdcb5a2a855df39c86bba8))
|
||||
|
||||
## [3.22.0](https://github.com/Masutayunikon/FanKarr/compare/v3.21.0...v3.22.0) (2026-09-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* actions de masse sur les séries et fiabilisation des imports ([f4f2135](https://github.com/Masutayunikon/FanKarr/commit/f4f213508de8500aa5e32e8782f358a22e47ec36))
|
||||
* actions de masse sur les séries et fiabilisation des imports et correction protection admin ([7f06dd6](https://github.com/Masutayunikon/FanKarr/commit/7f06dd654a72b4960195202ef5597411a54f8d75))
|
||||
* **torrent:** ajout d'une option pour ignorer les erreurs de certificat ssl ([7479ddf](https://github.com/Masutayunikon/FanKarr/commit/7479ddf8eb63579727cbb0f497ead98c90145da5))
|
||||
* **torrent:** ajout d'une option pour ignorer les erreurs de certificat SSL ([#138](https://github.com/Masutayunikon/FanKarr/issues/138)) ([0768964](https://github.com/Masutayunikon/FanKarr/commit/07689640413cf9ea57ea69e1b50273c80dffd640))
|
||||
|
||||
## [3.21.0](https://github.com/Masutayunikon/FanKarr/compare/v3.20.3...v3.21.0) (2026-06-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **settings:** ajout de englishDirectory qui permet d'avoir Season XX au lieu de Saison X ([6f1e0dc](https://github.com/Masutayunikon/FanKarr/commit/6f1e0dc844e8f97b0966b223d558c09ab498ddc9))
|
||||
|
||||
## [3.20.3](https://github.com/Masutayunikon/FanKarr/compare/v3.20.2...v3.20.3) (2026-06-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **serie:** ajout du label de saisons avec les episodes ([63146fd](https://github.com/Masutayunikon/FanKarr/commit/63146fdc27d98acb70cf86c7890ea1236388d717))
|
||||
|
||||
## [3.20.2](https://github.com/Masutayunikon/FanKarr/compare/v3.20.1...v3.20.2) (2026-06-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **client:** add placeholder from config ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||
* **client:** remove alldebrid ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||
* **label:** add season of episodes ([98b317d](https://github.com/Masutayunikon/FanKarr/commit/98b317d493b7742c330d49ca816c0fecd983e82a))
|
||||
* **scraper:** fallback on empty env for GITHUB_BASE ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||
|
||||
## [3.20.1](https://github.com/Masutayunikon/FanKarr/compare/v3.20.0...v3.20.1) (2026-06-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **organize:** import manuel ne traite que les fichiers 100% complets ([f311c8e](https://github.com/Masutayunikon/FanKarr/commit/f311c8e2f3b9f53b6d03a327cf1b5bfd69cc6424))
|
||||
|
||||
## [3.20.0](https://github.com/Masutayunikon/FanKarr/compare/v3.19.1...v3.20.0) (2026-05-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **torrent:** système générique de suivi des tâches gérées ([a7250a5](https://github.com/Masutayunikon/FanKarr/commit/a7250a5f804189a7ddecef44193436cf6b324c31))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **organize:** fallback par torrent_name quand le hash n'est pas résolu ([f6f735a](https://github.com/Masutayunikon/FanKarr/commit/f6f735ad8838d0755472f739571684d75f9dc1b5))
|
||||
* **synology:** filtrage par savePath et retour au GET pour add() ([d326e08](https://github.com/Masutayunikon/FanKarr/commit/d326e0883477a7dbf453ab28a3fc619f759a8604))
|
||||
|
||||
## [3.19.1](https://github.com/Masutayunikon/FanKarr/compare/v3.19.0...v3.19.1) (2026-05-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** rerun deployment ([099b088](https://github.com/Masutayunikon/FanKarr/commit/099b0889b069f0226b7cb2ec4fa3b1ca535a82a6))
|
||||
|
||||
## [3.19.0](https://github.com/Masutayunikon/FanKarr/compare/v3.18.3...v3.19.0) (2026-05-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **synology:** files par fichier + getFiles() comme qbittorrent ([c6c52a1](https://github.com/Masutayunikon/FanKarr/commit/c6c52a1ae742dcb02ee86f3de04831ac1f041940))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **organize:** markOrganized manquant en mode copy pour les packs ([98ed3c7](https://github.com/Masutayunikon/FanKarr/commit/98ed3c7199915d2aafbb7be87b41fb725bb0c1ee))
|
||||
* **synology:** resolution hash via cache URI + magnet + detail.hash ([ee1c7d5](https://github.com/Masutayunikon/FanKarr/commit/ee1c7d579c3795701158e39159511be3cc1789d7))
|
||||
* **torrent:** correction suppression torrent après déplacement ([ed556ac](https://github.com/Masutayunikon/FanKarr/commit/ed556acd1d2990a6375d4a60f745048a79854249))
|
||||
|
||||
## [3.18.3](https://github.com/Masutayunikon/FanKarr/compare/v3.18.2...v3.18.3) (2026-05-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **synology:** double prefix dbid_ et fallback hash ([2587768](https://github.com/Masutayunikon/FanKarr/commit/25877685eaf02b3b1c02ff06db618a37aebf0e6b))
|
||||
|
||||
## [3.18.2](https://github.com/Masutayunikon/FanKarr/compare/v3.18.1...v3.18.2) (2026-05-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **client:** synology use default savepath when 403 ([63f6c12](https://github.com/Masutayunikon/FanKarr/commit/63f6c1276bd48a14e5806ff997b6be63c458d94c))
|
||||
|
||||
## [3.18.1](https://github.com/Masutayunikon/FanKarr/compare/v3.18.0...v3.18.1) (2026-05-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **client:** suppresion de alldebrid ([8b4b198](https://github.com/Masutayunikon/FanKarr/commit/8b4b198697ba3296444759c522bb0139be69e364))
|
||||
|
||||
## [3.18.0](https://github.com/Masutayunikon/FanKarr/compare/v3.17.1...v3.18.0) (2026-05-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **client:** ajout deluge ([045213d](https://github.com/Masutayunikon/FanKarr/commit/045213d363a75bf56bdb5a05e3965a040ead2893))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **client:** alldebrid ([045213d](https://github.com/Masutayunikon/FanKarr/commit/045213d363a75bf56bdb5a05e3965a040ead2893))
|
||||
|
||||
## [3.17.1](https://github.com/Masutayunikon/FanKarr/compare/v3.17.0...v3.17.1) (2026-05-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** la recherche ne retourne plus les series sans torrents ([7b18631](https://github.com/Masutayunikon/FanKarr/commit/7b1863180f28cfaaccff69cb949fa6116a2c8cc6))
|
||||
* **client:** alldebrid utilise maintenant les routes 4.1 ([7b18631](https://github.com/Masutayunikon/FanKarr/commit/7b1863180f28cfaaccff69cb949fa6116a2c8cc6))
|
||||
* **readme:** update ([edec048](https://github.com/Masutayunikon/FanKarr/commit/edec048e30e1a5a528f1b2888f3db1708eeb15f9))
|
||||
* **readme:** update ([bf9b18e](https://github.com/Masutayunikon/FanKarr/commit/bf9b18ec0002e84b040738f7229ed4570f5dcd32))
|
||||
|
||||
## [3.17.0](https://github.com/Masutayunikon/FanKarr/compare/v3.16.0...v3.17.0) (2026-05-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ajout du parametre auto download ([0ac4812](https://github.com/Masutayunikon/FanKarr/commit/0ac48126aa3f69ded8ad0b7e83b1324360b25def))
|
||||
* **auth:** migration multi-utilisateurs, rôles admin/user et token API par compte ([dabd1d4](https://github.com/Masutayunikon/FanKarr/commit/dabd1d429ee78b597d1e403c45645f0c89cb7ccc))
|
||||
* **client:** ajour de alldebrid client ([6ce59b9](https://github.com/Masutayunikon/FanKarr/commit/6ce59b9e0458ecf5b3bc55d703c132a4ac39db66))
|
||||
* **client:** ajout de alldebrid dans le tableau de comparatif ([f3d535d](https://github.com/Masutayunikon/FanKarr/commit/f3d535d5774ac7b25da6e948c481e32dcf63a5db))
|
||||
* **demande:** ajout des routes pour recuperer les saisons et episodes depuis le plugin jellyfin ([1176523](https://github.com/Masutayunikon/FanKarr/commit/1176523d6165465a1f07fcc6251e35c239c896f7))
|
||||
* **demandes:** ajout d'un bouton supprimé pour les admin et supprime les demandes "disponible" si la serie a été supprimé apres ([858a5b3](https://github.com/Masutayunikon/FanKarr/commit/858a5b34bf36bbeec3b18e87623e9b009e69ebcd))
|
||||
* **demandes:** ajout des demandes par épisodes ([473d97e](https://github.com/Masutayunikon/FanKarr/commit/473d97e39283d5a0f28dd674ca837d3b61673831))
|
||||
* **demandes:** telechargement auto lors de l'aprobation ([473d97e](https://github.com/Masutayunikon/FanKarr/commit/473d97e39283d5a0f28dd674ca837d3b61673831))
|
||||
* **jellyfin:** synchronisation des comptes, SSO token exchange et API publique v1 ([dabd1d4](https://github.com/Masutayunikon/FanKarr/commit/dabd1d429ee78b597d1e403c45645f0c89cb7ccc))
|
||||
* passer les demandes en disponible une fois l'import fait ([423ba6a](https://github.com/Masutayunikon/FanKarr/commit/423ba6a777322077257839ddc39ea03430846517))
|
||||
* **requests:** demandes de séries avec fusion des saisons, vue admin et widget dashboard ([dabd1d4](https://github.com/Masutayunikon/FanKarr/commit/dabd1d429ee78b597d1e403c45645f0c89cb7ccc))
|
||||
* **search:** ajout des requetes deja demandé si existante sur la serie ([4a6510a](https://github.com/Masutayunikon/FanKarr/commit/4a6510adde991bba85d20d7bb420a9f7ad6a2f0c))
|
||||
* **search:** ajout des requetes deja demandé si existante sur la serie ([b7c7e9d](https://github.com/Masutayunikon/FanKarr/commit/b7c7e9d33dd6190cb97a82a685d414601c353e6e))
|
||||
* **search:** ajout des requetes deja demandé si existante sur la serie ([ec1d170](https://github.com/Masutayunikon/FanKarr/commit/ec1d1705e76934d149a1a4afce5b36613cf0e22d))
|
||||
* **search:** ajout description ([16b3f74](https://github.com/Masutayunikon/FanKarr/commit/16b3f741e7dbf10daff2f1bb8b1785ff67594515))
|
||||
* **search:** retourne maintenant l'année et le rating ([99c8d86](https://github.com/Masutayunikon/FanKarr/commit/99c8d86c6e63d362067e18a3a5135ccfa49aff34))
|
||||
* **users:** gestion des comptes et système d'invitations avec page d'administration ([dabd1d4](https://github.com/Masutayunikon/FanKarr/commit/dabd1d429ee78b597d1e403c45645f0c89cb7ccc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ajout cors type ([0bac6da](https://github.com/Masutayunikon/FanKarr/commit/0bac6da9686edc45d79cde48387d5e3f89772646))
|
||||
* ajout du parametre auto download ([921f6eb](https://github.com/Masutayunikon/FanKarr/commit/921f6eb8d28bbeb6cc4f5f72a6c1c79a14da03ba))
|
||||
* argument type ([5d06a23](https://github.com/Masutayunikon/FanKarr/commit/5d06a231985f540bb66014c5ac8726f8740b3fde))
|
||||
* button contrast and font size on card overlay ([bd5b16e](https://github.com/Masutayunikon/FanKarr/commit/bd5b16e17c3a4f2005c058399b2c30038f350f55))
|
||||
* copy ([0544a51](https://github.com/Masutayunikon/FanKarr/commit/0544a515b654f9bb9183e2fb9d6a4b99de5af87d))
|
||||
* cors, alldebrid ([911ca86](https://github.com/Masutayunikon/FanKarr/commit/911ca86a0d1025d41870fd1e9cb39ca038df28cb))
|
||||
* demandes d'épisode qui demande toutes la serie ([62c5b2e](https://github.com/Masutayunikon/FanKarr/commit/62c5b2e622aa872236cf7d620a811c90606222bd))
|
||||
* demandes d'épisode qui demande toutes la serie ([73678be](https://github.com/Masutayunikon/FanKarr/commit/73678be2c895a3487081b4e811810c8ac36cf1fb))
|
||||
* **demandes:** multiples torrent et fix episode qui ne s'ajoute pas ([85a2180](https://github.com/Masutayunikon/FanKarr/commit/85a218042de55ae72efab2ce6f36084e14efa108))
|
||||
* **demandes:** titres torrent ([e6b0bea](https://github.com/Masutayunikon/FanKarr/commit/e6b0bea455413e99155c11003bf055ac244c259a))
|
||||
* **import:** supprime les entrees organiser quand on supprime un fichier ([6ce59b9](https://github.com/Masutayunikon/FanKarr/commit/6ce59b9e0458ecf5b3bc55d703c132a4ac39db66))
|
||||
* **log:** changement pour une notification plus explicit lors du test client ([6ce59b9](https://github.com/Masutayunikon/FanKarr/commit/6ce59b9e0458ecf5b3bc55d703c132a4ac39db66))
|
||||
* search retourne une image mtn ([5478fa6](https://github.com/Masutayunikon/FanKarr/commit/5478fa6d80314e2b0aaf745ef2d667b42e083d26))
|
||||
* **serie:** 403 pour les non admin ([f3d535d](https://github.com/Masutayunikon/FanKarr/commit/f3d535d5774ac7b25da6e948c481e32dcf63a5db))
|
||||
* **settings:** bug ou les parametre se sauvegarder en rentrant sur lla page avancé ([f3d535d](https://github.com/Masutayunikon/FanKarr/commit/f3d535d5774ac7b25da6e948c481e32dcf63a5db))
|
||||
* **settings:** la clé api n'apparait que dans un menu pour les admin ([f3d535d](https://github.com/Masutayunikon/FanKarr/commit/f3d535d5774ac7b25da6e948c481e32dcf63a5db))
|
||||
* **start:** url cliquable au demarrage dans le terminal ([6ce59b9](https://github.com/Masutayunikon/FanKarr/commit/6ce59b9e0458ecf5b3bc55d703c132a4ac39db66))
|
||||
* **supression:** supprime maintenant le dossier de la serie et l'ajoute a organized.json ([161f4f5](https://github.com/Masutayunikon/FanKarr/commit/161f4f5051a327421cede35b1d714286588e4f5b))
|
||||
* telechargement depuis les demandes de saison et integral ([7f4121d](https://github.com/Masutayunikon/FanKarr/commit/7f4121d9e4aab53a5baf105b46c987d69d4909a8))
|
||||
* update request state after submit + override theme hover on modal buttons ([41addf9](https://github.com/Masutayunikon/FanKarr/commit/41addf9e4fa437f31c478d7074ce86b05d79c689))
|
||||
|
||||
## [3.16.0](https://github.com/Masutayunikon/FanKarr/compare/v3.15.0...v3.16.0) (2026-05-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **log:** + de logs sur les erreurs d'import (chemin tenté et fichier du torrent chercher) ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
|
||||
* **rss:** ajout du rss sur les series ([086ca30](https://github.com/Masutayunikon/FanKarr/commit/086ca3053f5e563d8c3c2bb82379ff45d77a5499))
|
||||
* **UI/UX:** affichage de la serie et des episodes sur la page download du torrent ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **client:** fix de plusieurs torrent et refonte du tableau de compatibilité ([086ca30](https://github.com/Masutayunikon/FanKarr/commit/086ca3053f5e563d8c3c2bb82379ff45d77a5499))
|
||||
* **docker:** ajout du fichier de versions pour la navbar dans docker lors du build ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
|
||||
* **download:** le telechargement de saison ne propose plus tout les torrents des episodes de la saisons si la serie n'a que des torrent par episodes ([c9040ea](https://github.com/Masutayunikon/FanKarr/commit/c9040ea68c52e65a656824ccfd101a04a2251edd))
|
||||
* **import:** les episodes avec une association changé n'apparait plus sur la page serie ([8972679](https://github.com/Masutayunikon/FanKarr/commit/8972679e2588b6e5cd3e9fd53b0223e3d1a6e56c))
|
||||
* **import:** les imports se font maintenant sur les series sans torrents qui n'avais pas de fichier de catalogue depuis le scraper ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
|
||||
* **import:** on ne peux plus importer plusieurs fichiers sur le meme episodes, celui deja importé ce retire de l'importation dans le modal ([ad829c0](https://github.com/Masutayunikon/FanKarr/commit/ad829c0a99b29ade7eba4673b1218c0ab768a2d7))
|
||||
* **import:** si on change un fichier son association alors il est bien retirer de l'import ([ad829c0](https://github.com/Masutayunikon/FanKarr/commit/ad829c0a99b29ade7eba4673b1218c0ab768a2d7))
|
||||
* les torrents n'utilise plus la pause ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
|
||||
* **rss:** le rss est maintenant sur les ajout apres l'activation de la surveillance ([a5e2aa0](https://github.com/Masutayunikon/FanKarr/commit/a5e2aa0da1b3af445a102d1acd77a3a08017689a))
|
||||
* **UI/UX:** la bar de progression de saison a été retirer et les bars de progression s'affiche meme si le fichier est à 0% dans le torrent ([f07b608](https://github.com/Masutayunikon/FanKarr/commit/f07b608288afd4b01079367b428123b72182b714))
|
||||
* **UI/UX:** retour sur le modal d'import et refresh ([a5e2aa0](https://github.com/Masutayunikon/FanKarr/commit/a5e2aa0da1b3af445a102d1acd77a3a08017689a))
|
||||
* **UI/UX:** retour visuel lors de l'import d'une serie/episode ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
|
||||
* utilisation de URLSearchParams pour les appels autre que pour le add du fichier .torrent ([42c9795](https://github.com/Masutayunikon/FanKarr/commit/42c979536b51e8ed263b23559b7c4a2d4d2b2902))
|
||||
|
||||
## [3.15.0](https://github.com/Masutayunikon/FanKarr/compare/v3.14.6...v3.15.0) (2026-05-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ajout build docker pour arm32 ([950a2ab](https://github.com/Masutayunikon/FanKarr/commit/950a2abc49fe0b7b5bcc0f47d781a45a01838ec3))
|
||||
|
||||
## [3.14.6](https://github.com/Masutayunikon/FanKarr/compare/v3.14.5...v3.14.6) (2026-05-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pnpm build ([fcb176d](https://github.com/Masutayunikon/FanKarr/commit/fcb176d519b1bff49c40b79192e71b6062671998))
|
||||
|
||||
## [3.14.5](https://github.com/Masutayunikon/FanKarr/compare/v3.14.4...v3.14.5) (2026-05-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pnpm build ([d04ca79](https://github.com/Masutayunikon/FanKarr/commit/d04ca79e0057e3d6cf92e49822d812266f629dba))
|
||||
|
||||
## [3.14.4](https://github.com/Masutayunikon/FanKarr/compare/v3.14.3...v3.14.4) (2026-05-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* lock file for build ([ef527c2](https://github.com/Masutayunikon/FanKarr/commit/ef527c2072a93650f9159de12ae875a671fb845c))
|
||||
|
||||
## [3.14.3](https://github.com/Masutayunikon/FanKarr/compare/v3.14.2...v3.14.3) (2026-05-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* build binaries node version ([6d8ea36](https://github.com/Masutayunikon/FanKarr/commit/6d8ea36260cb76e0592bbee3ce5450d2424b9209))
|
||||
|
||||
## [3.14.2](https://github.com/Masutayunikon/FanKarr/compare/v3.14.1...v3.14.2) (2026-05-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* build binaries node version ([9ebaca9](https://github.com/Masutayunikon/FanKarr/commit/9ebaca92f18213fd6a79bf55f22cfb8358c4f7e0))
|
||||
|
||||
## [3.14.1](https://github.com/Masutayunikon/FanKarr/compare/v3.14.0...v3.14.1) (2026-05-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* eslint build ([25ab373](https://github.com/Masutayunikon/FanKarr/commit/25ab373539c20942202e82cf678e5b7577ff0027))
|
||||
|
||||
## [3.14.0](https://github.com/Masutayunikon/FanKarr/compare/v3.13.2...v3.14.0) (2026-05-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **qbittorrent:** support API key authentication (≥5.2.0) ([702847a](https://github.com/Masutayunikon/FanKarr/commit/702847a5c2eb5956f54a8475fefc80a08fca70f1))
|
||||
* **qbittorrent:** support cookie name (≥5.2.0) ([aa8b500](https://github.com/Masutayunikon/FanKarr/commit/aa8b500f57efb97aae10eb0bd8e254388a8aff42))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ajout de log pour le cookie ([2b3fd79](https://github.com/Masutayunikon/FanKarr/commit/2b3fd79a92bbc7d7ed2142b7a23e03799efe0a28))
|
||||
* cookie ([7da5a8d](https://github.com/Masutayunikon/FanKarr/commit/7da5a8d4e853f7e92770f20352478a76ee91642d))
|
||||
* le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode ([105cc05](https://github.com/Masutayunikon/FanKarr/commit/105cc05bdf38127089869ea201165ac1d3479275))
|
||||
* on ne vois plus le telechargement de fichier qui ont une priorité basse (ne pas télécharger) ([1f50f9a](https://github.com/Masutayunikon/FanKarr/commit/1f50f9ae72ec9958bfb92b2e6936c699e19272f3))
|
||||
* renaming dans le managements des series ([1e92934](https://github.com/Masutayunikon/FanKarr/commit/1e92934f531c82b9da90c8597ec08cd162b5a9ee))
|
||||
* renaming dans le managements des series ([bf3f303](https://github.com/Masutayunikon/FanKarr/commit/bf3f30356e086d310b7213b267356bc20976589c))
|
||||
* rtorrent priority default to 1 ([bfabf9b](https://github.com/Masutayunikon/FanKarr/commit/bfabf9b7937f055579add331cd918a13f05084bf))
|
||||
|
||||
## [3.13.2](https://github.com/Masutayunikon/FanKarr/compare/v3.13.1...v3.13.2) (2026-05-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode ([0d5d44a](https://github.com/Masutayunikon/FanKarr/commit/0d5d44a4840df27d8b23bde9935d12c664d5ad6c))
|
||||
|
||||
## [3.13.1](https://github.com/Masutayunikon/FanKarr/compare/v3.13.0...v3.13.1) (2026-05-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ajout du champ wiki dans l'api ([95d2b27](https://github.com/Masutayunikon/FanKarr/commit/95d2b270dcb3052b65f5918ec770799b43316cd6))
|
||||
|
||||
## [3.13.0](https://github.com/Masutayunikon/FanKarr/compare/v3.12.1...v3.13.0) (2026-05-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ajout de la mise a jour des id's ([39463bf](https://github.com/Masutayunikon/FanKarr/commit/39463bfbcb74c7241420504ba51bd91108df20e4))
|
||||
* ajout de la prise en charges des codex ([83437de](https://github.com/Masutayunikon/FanKarr/commit/83437def32618c8a5b9ca4364db992762b9359f8))
|
||||
* **organize:** vérification de complétion par fichier avant d'importer ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* **serie:** badges épisodes et chapitres extraits du synopsis ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* **serie:** lien wiki dans le header de la série si serie.wiki est défini ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* **torrent-clients:** ajout du driver Real-Debrid ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ajout de log ([d299b76](https://github.com/Masutayunikon/FanKarr/commit/d299b76bd3fc8a8411e71f9f05318901df8372ef))
|
||||
* **organize:** retry EBUSY sur unlink — 5 tentatives × 500 ms (Windows) ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* **organize:** suppression des entrées orphelines lors de la migration d'IDs ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* progression ([35655d7](https://github.com/Masutayunikon/FanKarr/commit/35655d7784025c9b2d7f992b24181d62b8488130))
|
||||
* **qbittorrent:** FanKarr télécharge le .torrent et l'uploade directement à qBit ([434da54](https://github.com/Masutayunikon/FanKarr/commit/434da54ede8ece06c1fbcf4d66fe6ecdf87557a5))
|
||||
* **qbittorrent:** pause uniquement pour les .torrent, pas pour les magnets ([9f90b6a](https://github.com/Masutayunikon/FanKarr/commit/9f90b6ab6c0aa97e33244d827370492abcfe9a13))
|
||||
* **qbittorrent:** suppression de paused/stopped à l'ajout du torrent ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* **serie:** header mobile — items-start + aspect-[2/3] sur le poster ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
|
||||
* utilisation de formated_name depuis les paths ([39463bf](https://github.com/Masutayunikon/FanKarr/commit/39463bfbcb74c7241420504ba51bd91108df20e4))
|
||||
|
||||
## [3.12.1](https://github.com/Masutayunikon/FanKarr/compare/v3.12.0...v3.12.1) (2026-04-29)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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
|
||||
|
||||
@@ -16,6 +16,7 @@ RUN pnpm run build
|
||||
|
||||
# ── Production ─────────────────────────────────────────────────
|
||||
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
|
||||
@@ -25,7 +26,8 @@ COPY --from=build /app/dist/client /app/public
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
RUN mkdir -p /config
|
||||
RUN echo "$BUILD_VERSION" > /app/version.txt
|
||||
|
||||
EXPOSE 3001
|
||||
EXPOSE 9898
|
||||
ENV NODE_ENV=production
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -11,7 +11,7 @@
|
||||

|
||||
|
||||
**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
|
||||
|
||||

|
||||
|
||||
@@ -21,62 +21,91 @@ Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
|
||||
|
||||
## Aperçu
|
||||
|
||||
<!-- [img] : vue catalogue complète — grille des affiches avec les barres d'état en bas de chaque affiche (couleurs : dispo / en cours / importé), barre de recherche visible -->
|
||||

|
||||

|
||||
|
||||
> Vue catalogue : toutes les séries Fankai. Une barre en bas de chaque affiche indique l'état — disponible, en cours de téléchargement ou déjà importé dans votre médiathèque.
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
<!-- [img] : vue série — détail des saisons et épisodes avec badges VOSTFR/MULTI + drapeaux, boutons de téléchargement avec dropdown multi-sources, barres de progression par épisode -->
|
||||

|
||||

|
||||
|
||||
> Vue série : épisodes avec badge de langue (VOSTFR / MULTI), progression par fichier, et dropdown de sélection quand plusieurs sources sont disponibles.
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
<!-- [img] : vue téléchargements — liste des torrents actifs avec barres de progression, état d'organisation, badges erreur au hover -->
|
||||

|
||||

|
||||
|
||||
> Vue téléchargements : suivi en temps réel, état d'organisation par torrent et détail des erreurs au survol.
|
||||
> 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
|
||||
|
||||
### 📥 Téléchargement
|
||||
- **Catalogue complet** des séries Fankai avec affiches et état de disponibilité
|
||||
- **Téléchargement en un clic** vers votre client torrent — par épisode, par saison ou en intégrale
|
||||
- **Dropdown intelligent** sur chaque bouton quand plusieurs sources sont disponibles (qualités différentes, packs différents)
|
||||
- **"Tout télécharger"** unifié — sélectionne automatiquement les épisodes non couverts par l'intégrale choisie et complète avec les packs individuels
|
||||
- **Progression par fichier** — barre de téléchargement au niveau de l'épisode, même pour les fichiers dans un pack intégrale
|
||||
### 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 fichiers terminés vers votre médiathèque, déclenché dès la fin d'un téléchargement ou toutes les 5 minutes en arrière-plan
|
||||
- **Scan de médiathèque** — détecte les fichiers déjà présents sur le disque et les référence sans les déplacer
|
||||
- **Mode hardlink** (recommandé), **déplacement** ou **copie** — le hardlink préserve le seeding du client torrent
|
||||
- **Désimport automatique** optionnel quand un fichier est supprimé du disque
|
||||
### 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
|
||||
|
||||
### 🏷️ Métadonnées & Affichage
|
||||
- **Badges de langue** VOSTFR et MULTI avec drapeaux sur chaque épisode (détectés automatiquement)
|
||||
- **Support NFO** — nommage compatible avec les plugins Jellyfin/Plex utilisant les fichiers `.nfo`
|
||||
- **Intégration Plex** — connexion à votre serveur Plex pour déclencher le scan médiathèque après import
|
||||
### 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
|
||||
|
||||
### ⚙️ Système
|
||||
- **Logs centralisés** — filtre par niveau et source, rotation automatique, clear depuis l'UI
|
||||
- **Authentification** par mot de passe avec session JWT
|
||||
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
|
||||
- **Compatible Docker / Runtipi / Binaire autonome**
|
||||
### 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
|
||||
- Import des utilisateurs Jellyfin, au choix ou automatiquement toutes les heures
|
||||
- Connexion à FanKarr avec le compte Jellyfin, depuis l'interface web ou le plugin 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 FanKarr ou Jellyfin, session JWT, tentatives limitées
|
||||
- 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 torrent — Scraper
|
||||
## Données des torrents (scraper)
|
||||
|
||||
Les données du catalogue sont récupérées depuis le repo [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
|
||||
Les données du catalogue viennent du dépôt [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
|
||||
|
||||
Ce repo contient un pipeline Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, les croise avec l'API metadata de Fankai pour identifier chaque épisode, et publie le résultat automatiquement **toutes les 6 heures** via GitHub Actions. Les données sont organisées par série dans un dossier `/serie` et des fichiers d'index à la racine du repo.
|
||||
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`).
|
||||
|
||||
FanKarr récupère ces données 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -84,7 +113,7 @@ FanKarr récupère ces données au démarrage et via le bouton **Mettre à jour*
|
||||
|
||||
### Docker Compose (recommandé)
|
||||
|
||||
**Prérequis** : Docker + Docker Compose, un client torrent accessible en réseau.
|
||||
**Prérequis** : Docker et Docker Compose, et un client torrent joignable depuis FanKarr.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -94,10 +123,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:
|
||||
- ./config:/config # Config, logs, base de données
|
||||
- /votre/chemin:/media # Racine de votre médiathèque (voir note hardlinks)
|
||||
- ./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:
|
||||
- 9898:9898
|
||||
restart: unless-stopped
|
||||
@@ -107,79 +136,173 @@ services:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> **⚠️ Note sur les hardlinks** : Pour que le mode `hardlink` fonctionne, vos dossiers de téléchargement complets et votre médiathèque doivent être sur le **même filesystem**. La solution recommandée est de monter un seul volume racine (ex: `/votre/chemin:/media`) et de configurer vos chemins à l'intérieur — comme Radarr et Sonarr le font.
|
||||
> **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.
|
||||
>
|
||||
> Plusieurs volumes sont possibles mais les hardlinks ne fonctionneront pas entre deux volumes différents — FanKarr basculera automatiquement en copie.
|
||||
> 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).
|
||||
|
||||
---
|
||||
|
||||
### Binaire autonome (Windows / Linux / macOS)
|
||||
### Binaire autonome (Windows, Linux, macOS)
|
||||
|
||||
Téléchargez l'archive correspondant à votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
|
||||
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 Legacy (anciens CPU) | `fankarr-windows-legacy.zip` |
|
||||
| Linux x64 | `fankarr-linux-x64.zip` |
|
||||
| macOS (Apple Silicon) | `fankarr-macos.zip` |
|
||||
| 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 — tous les fichiers doivent rester ensemble dans le même répertoire :
|
||||
Extrayez l'archive dans un dossier. Tous les fichiers doivent rester ensemble :
|
||||
|
||||
```
|
||||
fankarr-windows-x64/
|
||||
├── fankarr.exe ← lancer ce fichier
|
||||
├── organize-worker.js ← worker d'organisation (ne pas déplacer)
|
||||
├── .env ← configuration optionnelle (voir Variables d'environnement)
|
||||
└── public/ ← assets frontend (ne pas déplacer)
|
||||
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)
|
||||
```
|
||||
|
||||
> Le fichier `.env` permet de configurer le port, la durée des sessions, etc. sans passer par des variables d'environnement système. Il est chargé automatiquement au démarrage si présent à côté du binaire.
|
||||
> 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 / macOS** — rendre le binaire exécutable avant le premier lancement :
|
||||
**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:9898`. La configuration, les logs et la base de données sont sauvegardés dans un dossier `config/` 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` | `9898` | Port d'écoute du serveur |
|
||||
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `/config/secret.key` si absent |
|
||||
| `AUTH_TOKEN_EXPIRY`| `30d` | Durée de validité du token de connexion (ex: `7d`, `1y`, `never`) |
|
||||
| `DATA_DIR` | `./config` | Répertoire de stockage des données (binaire uniquement) |
|
||||
| `GITHUB_RAW_URL` | repo scraper | URL de base du repo scraper si vous hébergez votre propre instance |
|
||||
| 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 |
|
||||
| `BASE_URL` | non défini | Sous-dossier derrière un reverse proxy (ex. `/fankarr`) |
|
||||
| `PROXY_AUTH_HEADER` | non défini | En-tête contenant le nom d'utilisateur authentifié par le proxy (ex. `Remote-User`) |
|
||||
| `PROXY_AUTH_TRUSTED_PROXIES` | non défini | Adresses autorisées à envoyer cet en-tête (IP ou plages IPv4 CIDR, séparées par des virgules) |
|
||||
| `PROXY_AUTH_AUTO_CREATE` | `false` | Crée un compte utilisateur à la première connexion d'un nom inconnu |
|
||||
| `PROXY_AUTH_LOGOUT_URL` | non défini | Page de déconnexion du proxy, ouverte par « Se déconnecter » (ex. `/oauth2/sign_out`) |
|
||||
|
||||
Par défaut, `GITHUB_BASE` vaut `https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main`.
|
||||
|
||||
---
|
||||
|
||||
### Premier démarrage
|
||||
### Reverse proxy en sous-dossier
|
||||
|
||||
1. Ouvrir `http://localhost:9898`
|
||||
2. Créer un mot de passe à la première connexion
|
||||
3. Aller dans **Paramètres** → configurer votre client torrent
|
||||
4. Renseigner le chemin vers le dossier des **torrents terminés** et celui de votre **médiathèque**
|
||||
5. Choisir le mode d'organisation : `hardlink` (recommandé), `move` ou `copy`
|
||||
6. Optionnel : lancer le **scan** pour référencer les fichiers déjà présents sur le disque
|
||||
7. Retourner au catalogue et télécharger
|
||||
Par défaut, FanKarr est servi à la racine du domaine. Pour l'héberger sous un
|
||||
sous-dossier (`https://exemple.tld/fankarr`), renseignez `BASE_URL` :
|
||||
|
||||
<!-- [img] : page paramètres — formulaire de configuration client torrent, chemins, mode d'organisation, toggles autoImport / NFO / désimport auto -->
|
||||

|
||||
```yaml
|
||||
environment:
|
||||
- BASE_URL=/fankarr
|
||||
```
|
||||
|
||||
L'application, l'API et l'API publique v1 sont alors servies sous ce préfixe
|
||||
(`/fankarr/api/v1/...`), et une requête sur `/` y est redirigée. Aucune
|
||||
reconstruction de l'image n'est nécessaire : le préfixe est appliqué au
|
||||
démarrage.
|
||||
|
||||
Côté proxy, transmettez l'URL **sans réécrire le chemin** :
|
||||
|
||||
```nginx
|
||||
location /fankarr/ {
|
||||
proxy_pass http://127.0.0.1:9898; # pas de / final : le chemin est conservé
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
Laissez `BASE_URL` vide pour un sous-domaine dédié (`https://fankarr.exemple.tld`).
|
||||
|
||||
---
|
||||
|
||||
### Authentification par proxy (oauth2-proxy, Authelia…)
|
||||
|
||||
Si un proxy d'authentification (oauth2-proxy devant Keycloak, Authelia,
|
||||
Authentik…) contrôle déjà l'accès à FanKarr, FanKarr peut ouvrir la session
|
||||
directement à partir du nom d'utilisateur qu'il transmet, sans second écran de
|
||||
connexion :
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- PROXY_AUTH_HEADER=X-Forwarded-Preferred-Username # en-tête posé par le proxy
|
||||
- PROXY_AUTH_TRUSTED_PROXIES=172.18.0.0/16 # adresse(s) du reverse proxy
|
||||
- PROXY_AUTH_LOGOUT_URL=/oauth2/sign_out # facultatif
|
||||
# - PROXY_AUTH_AUTO_CREATE=true # facultatif
|
||||
```
|
||||
|
||||
- Le nom transmis est rapproché d'un compte FanKarr existant, sans tenir compte
|
||||
de la casse. Un nom inconnu est refusé (avertissement dans les journaux), sauf
|
||||
avec `PROXY_AUTH_AUTO_CREATE=true` : un compte **utilisateur** sans mot de
|
||||
passe est alors créé. Les rôles se gèrent dans Paramètres › Utilisateurs.
|
||||
- L'en-tête n'est lu que pour les connexions venant de
|
||||
`PROXY_AUTH_TRUSTED_PROXIES` : sans cette variable, la fonction reste
|
||||
désactivée. Ne publiez pas le port de FanKarr au-delà du proxy et des
|
||||
applications qui utilisent l'API.
|
||||
- Le premier compte administrateur se crée toujours avec l'assistant.
|
||||
- La connexion par mot de passe et par Jellyfin reste possible, ainsi que les
|
||||
jetons d'API.
|
||||
|
||||
Exemple avec NPMplus et oauth2-proxy : choisissez `oauth2proxy` dans
|
||||
« Auth Request » de la location FanKarr. L'en-tête `X-User` que NPMplus
|
||||
transmet contient l'identifiant interne de l'utilisateur (`sub`, un UUID avec
|
||||
Keycloak) et non son nom de connexion. Transmettez plutôt ce dernier en ajoutant
|
||||
dans la configuration avancée de la location :
|
||||
|
||||
```nginx
|
||||
auth_request_set $preferred_username $upstream_http_x_auth_request_preferred_username;
|
||||
proxy_set_header X-Forwarded-Preferred-Username $preferred_username;
|
||||
```
|
||||
|
||||
puis `PROXY_AUTH_HEADER=X-Forwarded-Preferred-Username`. oauth2-proxy doit
|
||||
tourner avec `--set-xauthrequest`.
|
||||
|
||||
---
|
||||
|
||||
### 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 les fichiers terminés vers la structure attendue par Jellyfin / Plex :
|
||||
FanKarr range les fichiers terminés selon la structure attendue par Jellyfin et Plex :
|
||||
|
||||
```
|
||||
Black Lagoon Henshū/
|
||||
@@ -196,51 +319,187 @@ Black Lagoon Henshū/
|
||||
└── Black Lagoon Henshū.S03E08.MULTI.1080p.x264-FANKAI.mkv
|
||||
```
|
||||
|
||||
Le mode **hardlink** est recommandé si vos dossiers de téléchargement et de médiathèque sont sur le même filesystem — les fichiers ne sont pas déplacés et le client torrent continue de seeder sans interruption.
|
||||
Une option permet de nommer les dossiers de saison « Season 01 » plutôt que « Saison 1 ».
|
||||
|
||||
L'organisation se déclenche :
|
||||
- **Automatiquement** dès la fin d'un téléchargement, et toutes les 5 minutes en arrière-plan pour les torrents déjà en seeding
|
||||
- **Manuellement** depuis le bouton **Importer** dans la vue téléchargements
|
||||
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.
|
||||
|
||||
Le **scan médiathèque** permet de référencer des fichiers déjà présents sur le disque sans les déplacer — utile si vous avez déjà une bibliothèque existante.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Logs
|
||||
## Journaux
|
||||
|
||||
<!-- [img] : page logs — liste des événements avec filtres niveau/source, bouton clear, affichage compact -->
|
||||

|
||||

|
||||
|
||||
> Tous les événements sont horodatés et filtrables par source (`organize`, `api`, `torrent`, …) et niveau (`info`, `warn`, `error`, `debug`).
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
## Comptes Jellyfin
|
||||
|
||||
Vos utilisateurs Jellyfin peuvent se connecter à FanKarr avec leurs identifiants Jellyfin. Il faut renseigner l'adresse du serveur et une clé API dans **Paramètres › Jellyfin et API**.
|
||||
|
||||
### Import des utilisateurs
|
||||
|
||||
Un utilisateur Jellyfin importé reçoit un compte invité, sans mot de passe FanKarr. Si un compte FanKarr porte déjà son nom, ce compte est lié à l'utilisateur Jellyfin au lieu d'être recréé. Pour importer :
|
||||
- choisissez les comptes depuis **Paramètres › Jellyfin et API › Importer des utilisateurs** ;
|
||||
- ou laissez l'import automatique, qui importe toutes les heures chaque utilisateur Jellyfin actif (activé par défaut).
|
||||
|
||||
### Connexion
|
||||
|
||||
Sur l'écran de connexion, un utilisateur importé saisit son nom et son mot de passe Jellyfin. FanKarr les fait vérifier par Jellyfin et ne les conserve pas. Deux réglages dans **Paramètres › Jellyfin et API** :
|
||||
- **Connexion à FanKarr avec un compte Jellyfin** (activé par défaut) ;
|
||||
- **Autoriser les comptes pas encore importés** (désactivé par défaut) : un compte invité est créé à la première connexion.
|
||||
|
||||
Un compte FanKarr qui n'a pas été importé ou lié ne peut pas s'ouvrir avec un mot de passe Jellyfin. Après 5 échecs en 15 minutes pour un même nom d'utilisateur depuis la même adresse, FanKarr bloque les tentatives jusqu'à la fin de ces 15 minutes. Un mot de passe erroné saisi dans FanKarr compte aussi comme un échec dans Jellyfin. Si le verrouillage après plusieurs échecs est activé pour un utilisateur dans Jellyfin (désactivé par défaut), son compte y est désactivé une fois la limite atteinte.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
Les utilisateurs se connectent au plugin avec leur compte Jellyfin : le plugin échange leur session Jellyfin contre leur jeton FanKarr. Le compte doit être importé (voir [Comptes Jellyfin](#comptes-jellyfin)), sauf si les comptes pas encore importés sont autorisés.
|
||||
|
||||
---
|
||||
|
||||
## 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. FanKarr vérifie auprès de Jellyfin (`/Users/Me`) que le jeton appartient bien à `jellyfinUserId`. Si l'utilisateur Jellyfin n'est pas encore importé, la réponse est une erreur 404, sauf si les comptes pas encore importés sont autorisés : le compte est alors créé.
|
||||
|
||||
### 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 v4 |
|
||||
| Backend | Express 5 + TypeScript |
|
||||
| Auth | JWT + bcrypt |
|
||||
| Worker | Node Worker Threads (organisation async) |
|
||||
| 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
|
||||
@@ -1,14 +1,15 @@
|
||||
fankarr:
|
||||
image: masutayunikon/fankarr:latest
|
||||
container_name: fankarr
|
||||
environment:
|
||||
- PUID=1000 # UID de votre utilisateur (id -u)
|
||||
- PGID=1000 # GID de votre utilisateur (id -g)
|
||||
- TZ=Europe/Paris # Votre timezone
|
||||
volumes:
|
||||
- ./config:/config # Config, logs, base de données
|
||||
- /votre/chemin:/media # Racine de votre mediatheque (hardlinks compatibles)
|
||||
ports:
|
||||
- 9898:9898
|
||||
restart: unless-stopped
|
||||
|
||||
services:
|
||||
fankarr:
|
||||
image: masutayunikon/fankarr:latest
|
||||
container_name: fankarr
|
||||
environment:
|
||||
- PUID=1000 # UID de votre utilisateur (id -u)
|
||||
- PGID=1000 # GID de votre utilisateur (id -g)
|
||||
- TZ=Europe/Paris # Fuseau horaire
|
||||
# - BASE_URL=/fankarr # Sous-dossier derrière un reverse proxy (facultatif)
|
||||
volumes:
|
||||
- ./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:
|
||||
- 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
|
||||
# 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",
|
||||
@@ -20,6 +22,7 @@
|
||||
"@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",
|
||||
@@ -33,6 +36,7 @@
|
||||
"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",
|
||||
@@ -42,6 +46,7 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^5.0.0",
|
||||
"vue-tsc": "^3.1.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ importers:
|
||||
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
|
||||
@@ -54,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
|
||||
@@ -81,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)
|
||||
@@ -277,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==}
|
||||
|
||||
@@ -317,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==}
|
||||
@@ -446,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==}
|
||||
@@ -504,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==}
|
||||
|
||||
@@ -512,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==}
|
||||
|
||||
@@ -555,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==}
|
||||
|
||||
@@ -667,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'}
|
||||
@@ -701,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'}
|
||||
@@ -758,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==}
|
||||
|
||||
@@ -815,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'}
|
||||
@@ -834,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'}
|
||||
@@ -1004,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==}
|
||||
@@ -1076,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'}
|
||||
@@ -1126,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'}
|
||||
@@ -1163,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:
|
||||
@@ -1272,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'}
|
||||
@@ -1280,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'}
|
||||
@@ -1311,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'}
|
||||
@@ -1406,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==}
|
||||
|
||||
@@ -1445,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'}
|
||||
@@ -1584,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
|
||||
@@ -1743,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
|
||||
@@ -1751,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':
|
||||
@@ -1800,6 +1973,17 @@ snapshots:
|
||||
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
|
||||
@@ -1950,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
|
||||
@@ -1992,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
|
||||
@@ -2045,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: {}
|
||||
@@ -2084,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
|
||||
@@ -2123,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
|
||||
@@ -2164,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
|
||||
@@ -2364,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: {}
|
||||
@@ -2401,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
|
||||
@@ -2427,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
|
||||
@@ -2590,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
|
||||
@@ -2622,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: {}
|
||||
@@ -2685,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)):
|
||||
@@ -2731,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,112 +1,211 @@
|
||||
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 { DATA_DIR, BASE_URL } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
import {
|
||||
findByUsername, findById, findByApiToken, findByJellyfinId, importJellyfinUser,
|
||||
createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen, markLogin,
|
||||
type User,
|
||||
} from './users.js'
|
||||
import { readSettings, type Settings } from './settings.js'
|
||||
import { startOnboarding } from './onboarding.js'
|
||||
import { isJellyfinConfigured, authenticateJellyfinUser, getJellyfinServerId } from './lib/jellyfin.js'
|
||||
import { loginRetryAfter, recordLoginFailure, clearLoginFailures } from './lib/login-throttle.js'
|
||||
import { proxyAuthUser, PROXY_AUTH_ENABLED, PROXY_AUTH_LOGOUT_URL } from './lib/proxy-auth.js'
|
||||
|
||||
// AUTH_TOKEN_EXPIRY : durée de validité du token JWT
|
||||
// Exemples : "1d", "7d", "30d", "1y", "never" / "0" = pas d'expiration
|
||||
const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d'
|
||||
const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never'
|
||||
// ── Typage Express étendu ─────────────────────────────────────
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request { user?: User }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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(username: string): string {
|
||||
// Le cookie est limité au chemin de base : deux instances derrière le même
|
||||
// domaine (/fankarr, /fankarr-test) ne partagent pas leur session
|
||||
const COOKIE_PATH = BASE_URL || '/'
|
||||
|
||||
function signToken(user: User): string {
|
||||
const payload = { id: user.id, username: user.username, role: user.role }
|
||||
return NO_EXPIRY
|
||||
? jwt.sign({ username }, JWT_SECRET)
|
||||
: jwt.sign({ username }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any })
|
||||
? jwt.sign(payload, JWT_SECRET)
|
||||
: jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any })
|
||||
}
|
||||
|
||||
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
interface AuthData {
|
||||
username : string
|
||||
passwordHash: string
|
||||
function setSessionCookie(res: Response, user: User): string {
|
||||
user.lastLoginAt = markLogin(user.id) ?? user.lastLoginAt
|
||||
const token = signToken(user)
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax', path: COOKIE_PATH })
|
||||
return token
|
||||
}
|
||||
|
||||
// ── 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 }
|
||||
function setCookieAndRespond(res: Response, user: User): void {
|
||||
setSessionCookie(res, user)
|
||||
res.json({ success: true, user: safeUser(user) })
|
||||
}
|
||||
|
||||
function writeAuth(data: AuthData): void {
|
||||
fs.mkdirSync(path.dirname(DATA_PATH), { recursive: true })
|
||||
fs.writeFileSync(DATA_PATH, JSON.stringify(data, null, 2))
|
||||
// Identifiant du compte de la session, ou undefined si le cookie est absent ou invalide
|
||||
function sessionUserId(req: Request): string | undefined {
|
||||
const token = req.cookies?.fankarr_token
|
||||
if (!token) return undefined
|
||||
try { return (jwt.verify(token, JWT_SECRET) as any).id } catch { return undefined }
|
||||
}
|
||||
|
||||
// ── Authentification par proxy ────────────────────────────────
|
||||
// Placé avant les routes : quand le proxy désigne un compte, la session FanKarr
|
||||
// est ouverte (ou remplacée si elle appartient à un autre compte), et la suite
|
||||
// du traitement voit un cookie valide comme après une connexion classique.
|
||||
export function proxyAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const user = proxyAuthUser(req)
|
||||
if (user && sessionUserId(req) !== user.id) {
|
||||
const token = setSessionCookie(res, user)
|
||||
req.cookies = { ...req.cookies, fankarr_token: token }
|
||||
logger.info('auth', `Connexion de « ${user.username} » via le proxy d'authentification (rôle : ${user.role})`)
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/auth/status
|
||||
export function authStatus(req: Request, res: Response): void {
|
||||
const existing = readAuth()
|
||||
const token = req.cookies?.fankarr_token
|
||||
|
||||
let loggedIn = false
|
||||
if (token) {
|
||||
try {
|
||||
jwt.verify(token, JWT_SECRET)
|
||||
loggedIn = true
|
||||
} catch {
|
||||
logger.debug('auth', 'Token invalide ou expiré')
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ setup: !!existing, loggedIn })
|
||||
function jellyfinLoginEnabled(s: Settings): boolean {
|
||||
return s.jellyfinLogin && isJellyfinConfigured(s)
|
||||
}
|
||||
|
||||
// POST /api/auth/setup
|
||||
// GET /api/auth/status
|
||||
export function authStatus(req: Request, res: Response): void {
|
||||
const settings = readSettings()
|
||||
const setup = hasUsers()
|
||||
const jellyfinLogin = jellyfinLoginEnabled(settings)
|
||||
const proxy = { proxyAuth: PROXY_AUTH_ENABLED, logoutUrl: PROXY_AUTH_LOGOUT_URL || null }
|
||||
const token = req.cookies?.fankarr_token
|
||||
|
||||
if (!token) { res.json({ setup, loggedIn: false, jellyfinLogin, ...proxy }); return }
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as any
|
||||
const user = findById(payload.id)
|
||||
if (!user) { res.json({ setup, loggedIn: false, jellyfinLogin, ...proxy }); return }
|
||||
res.json({
|
||||
setup, loggedIn: true, jellyfinLogin, ...proxy, role: user.role, username: user.username, userId: user.id,
|
||||
onboardingDone: !!settings.onboardingCompletedAt,
|
||||
tourSeen : !!user.tourSeenAt,
|
||||
})
|
||||
} catch {
|
||||
res.json({ setup, loggedIn: false, jellyfinLogin, ...proxy })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/auth/setup (premier lancement uniquement)
|
||||
export function authSetup(req: Request, res: Response): void {
|
||||
if (readAuth()) {
|
||||
logger.warn('auth', 'Tentative de setup alors qu\'un compte existe déjà')
|
||||
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 })
|
||||
logger.info('auth', `Compte créé pour "${username}"`)
|
||||
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' })
|
||||
}
|
||||
}
|
||||
|
||||
const token = signToken(username)
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
res.json({ success: true })
|
||||
// Compte FanKarr lié à l'utilisateur Jellyfin authentifié, ou undefined si refusé
|
||||
async function loginWithJellyfin(username: string, password: string, ip: string): Promise<{ user?: User; unreachable?: boolean }> {
|
||||
const result = await authenticateJellyfinUser(username, password, ip)
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'unreachable') {
|
||||
logger.warn('auth', `Connexion Jellyfin de « ${username} » impossible : ${result.error}`)
|
||||
return { unreachable: true }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const serverId = await getJellyfinServerId()
|
||||
if (!serverId) {
|
||||
logger.error('auth', `Connexion Jellyfin de « ${username} » refusée : impossible de lire l'identifiant du serveur Jellyfin avec la clé API`)
|
||||
return {}
|
||||
}
|
||||
if (result.serverId !== serverId) {
|
||||
logger.error('auth', `Connexion Jellyfin de « ${username} » refusée : la réponse vient d'un autre serveur Jellyfin que celui configuré`)
|
||||
return {}
|
||||
}
|
||||
|
||||
const linked = findByJellyfinId(result.id)
|
||||
if (linked) return { user: linked }
|
||||
|
||||
if (!readSettings().jellyfinNewUserLogin) {
|
||||
logger.warn('auth', `Connexion Jellyfin de « ${result.name} » refusée : compte pas encore importé`)
|
||||
return {}
|
||||
}
|
||||
const imported = importJellyfinUser({ Id: result.id, Name: result.name })
|
||||
if (imported?.action === 'created') logger.info('auth', `Compte créé pour « ${result.name} » à sa première connexion Jellyfin`)
|
||||
return { user: imported?.user }
|
||||
}
|
||||
|
||||
// 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
|
||||
export async function authLogin(req: Request, res: Response): Promise<void> {
|
||||
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
|
||||
const { username, password } = req.body ?? {}
|
||||
if (typeof username !== 'string' || typeof password !== 'string' || !username || !password) {
|
||||
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
|
||||
}
|
||||
|
||||
if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) {
|
||||
logger.warn('auth', `Échec de connexion pour "${username}"`)
|
||||
res.status(401).json({ error: 'Identifiants incorrects' })
|
||||
return
|
||||
const ip = req.ip ?? ''
|
||||
const wait = loginRetryAfter(ip, username)
|
||||
if (wait > 0) {
|
||||
logger.warn('auth', `Connexion de « ${username} » bloquée : trop de tentatives depuis ${ip}`)
|
||||
res.set('Retry-After', String(wait))
|
||||
res.status(429).json({ error: `Trop de tentatives. Réessayez dans ${Math.ceil(wait / 60)} min.` }); return
|
||||
}
|
||||
|
||||
const token = signToken(username)
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
logger.info('auth', `Connexion réussie pour "${username}" (expiry: ${NO_EXPIRY ? 'aucune' : TOKEN_EXPIRY})`)
|
||||
res.json({ success: true })
|
||||
const session = NO_EXPIRY ? 'sans expiration' : TOKEN_EXPIRY
|
||||
const user = findByUsername(username)
|
||||
if (user && bcrypt.compareSync(password, user.passwordHash)) {
|
||||
clearLoginFailures(ip, username)
|
||||
logger.info('auth', `Connexion de « ${username} » (rôle : ${user.role}, session : ${session})`)
|
||||
setCookieAndRespond(res, user); return
|
||||
}
|
||||
|
||||
// Jellyfin n'est interrogé que pour un compte importé, ou un nouveau compte si le réglage l'autorise
|
||||
const settings = readSettings()
|
||||
const tryJellyfin = jellyfinLoginEnabled(settings) && (user ? !!user.jellyfinId : settings.jellyfinNewUserLogin)
|
||||
if (tryJellyfin) {
|
||||
const jf = await loginWithJellyfin(username, password, ip)
|
||||
if (jf.user) {
|
||||
clearLoginFailures(ip, username)
|
||||
logger.info('auth', `Connexion de « ${jf.user.username} » via Jellyfin (rôle : ${jf.user.role}, session : ${session})`)
|
||||
setCookieAndRespond(res, jf.user); return
|
||||
}
|
||||
if (jf.unreachable) {
|
||||
res.status(503).json({ error: 'Connexion Jellyfin impossible : Jellyfin ne répond pas.' }); return
|
||||
}
|
||||
}
|
||||
|
||||
recordLoginFailure(ip, username)
|
||||
logger.warn('auth', `Échec de la connexion de « ${username} »`)
|
||||
res.status(401).json({ error: 'Identifiants incorrects' })
|
||||
}
|
||||
|
||||
// POST /api/auth/logout
|
||||
@@ -114,27 +213,94 @@ export function authLogout(req: Request, res: Response): void {
|
||||
const token = req.cookies?.fankarr_token
|
||||
if (token) {
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as any
|
||||
logger.info('auth', `Déconnexion de "${payload.username}"`)
|
||||
const p = jwt.verify(token, JWT_SECRET) as any
|
||||
logger.info('auth', `Déconnexion de « ${p.username} »`)
|
||||
} catch {}
|
||||
}
|
||||
res.clearCookie('fankarr_token')
|
||||
res.clearCookie('fankarr_token', { path: COOKIE_PATH })
|
||||
// Cookie éventuellement posé à la racine avant l'activation de BASE_URL
|
||||
if (BASE_URL) res.clearCookie('fankarr_token', { path: '/' })
|
||||
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) {
|
||||
logger.debug('auth', `Accès refusé — non authentifié (${req.method} ${req.path})`)
|
||||
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 {
|
||||
logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`)
|
||||
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)
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import path from 'path'
|
||||
export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
|
||||
// ── Chargement anticipé du fichier .env pour les binaires bun ─────────────
|
||||
// Doit s'exécuter avant tout autre module qui lit process.env
|
||||
// (secret.ts, auth.ts…) car config.ts est la dépendance la plus profonde.
|
||||
// Charge le .env du binaire Bun avant tout module qui lit process.env
|
||||
;(() => {
|
||||
if (!_isBunBinary) return
|
||||
const binDir = path.dirname((process as any).execPath)
|
||||
@@ -21,17 +19,15 @@ export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
if (eq === -1) continue
|
||||
const key = line.slice(0, eq).trim()
|
||||
let val = line.slice(eq + 1).trim()
|
||||
// Supprimer les guillemets optionnels
|
||||
if ((val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1)
|
||||
}
|
||||
// Ne pas écraser les variables déjà définies (CLI/système prioritaires)
|
||||
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 .env : ${err}`)
|
||||
console.warn(`[config] Impossible de charger le fichier .env : ${err}`)
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -40,19 +36,16 @@ export const BASE_DIR = _isBunBinary
|
||||
: process.cwd()
|
||||
|
||||
const isInContainer = (): boolean => {
|
||||
// Docker
|
||||
if (fs.existsSync('/.dockerenv')) return true;
|
||||
|
||||
// Podman (crée un fichier /.containerenv)
|
||||
// Podman
|
||||
if (fs.existsSync('/.containerenv')) return true;
|
||||
|
||||
// Vérification via cgroup (Docker, Podman, LXC, etc.)
|
||||
try {
|
||||
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
|
||||
if (/docker|podman|containerd|lxc/i.test(cgroup)) return true;
|
||||
} catch {}
|
||||
|
||||
// Vérification via /proc/1/environ (fallback général)
|
||||
try {
|
||||
const environ = fs.readFileSync('/proc/1/environ', 'utf8');
|
||||
if (environ.includes('container=')) return true;
|
||||
@@ -63,4 +56,21 @@ const isInContainer = (): boolean => {
|
||||
|
||||
export const DATA_DIR = isInContainer()
|
||||
? '/config'
|
||||
: path.join(BASE_DIR, 'config');
|
||||
: path.join(BASE_DIR, 'config');
|
||||
|
||||
// ── Chemin de base (reverse proxy en sous-dossier) ─────────────
|
||||
// BASE_URL=/fankarr : l'application est servie sous https://exemple.tld/fankarr
|
||||
// Valeur normalisée : '' (racine) ou '/xxx' sans slash final
|
||||
function normalizeBaseUrl(raw: string | undefined): string {
|
||||
const trimmed = (raw ?? '').trim()
|
||||
if (!trimmed || trimmed === '/') return ''
|
||||
const withLead = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||||
const cleaned = withLead.replace(/\/+$/, '')
|
||||
if (!/^(\/[A-Za-z0-9._~-]+)+$/.test(cleaned)) {
|
||||
console.warn(`[config] BASE_URL « ${trimmed} » invalide (attendu : /mon-app), valeur ignorée`)
|
||||
return ''
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
export const BASE_URL = normalizeBaseUrl(process.env.BASE_URL)
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* gitlab-map.ts
|
||||
* =============
|
||||
* Map des titres API → titres GitLab pour le téléchargement des NFO.
|
||||
* Partagé entre organize-worker.ts et index.ts.
|
||||
*/
|
||||
/** 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ū',
|
||||
|
||||
@@ -3,7 +3,9 @@ import cookieParser from 'cookie-parser'
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { authStatus, authSetup, authLogin, authLogout } from './auth.js'
|
||||
import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, authTourSeen, requireAuth, requireAdmin, proxyAuth } 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'
|
||||
@@ -11,13 +13,26 @@ 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 { autoOrganizeAll, scanMediaPath, syncFilenameChanges } from './organize.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, loadEnrichedSeriesData } from './lib/github-cache.js'
|
||||
import { DATA_DIR, BASE_DIR, BASE_URL } 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 { runJellyfinImport } 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'
|
||||
@@ -27,84 +42,182 @@ 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)
|
||||
|
||||
const app = express()
|
||||
const PORT = Number(process.env.PORT) || 9898
|
||||
|
||||
// Toutes les routes de l'application sont montées sous BASE_URL ('' = racine)
|
||||
const site = express.Router()
|
||||
|
||||
app.use(express.json())
|
||||
app.use(cookieParser())
|
||||
|
||||
|
||||
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))
|
||||
// index: false — index.html passe par le fallback SPA, qui y injecte BASE_URL
|
||||
site.use(express.static(PUBLIC_PATH, { index: false }))
|
||||
}
|
||||
|
||||
// ── 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()
|
||||
|
||||
// ── Routes ─────────────────────────────────────────────────────
|
||||
app.use('/api', settingsRouter)
|
||||
app.use('/api', torrentClientsRouter)
|
||||
app.use('/api', seriesRouter)
|
||||
app.use('/api', downloadsRouter)
|
||||
app.use('/api', organizeRouter)
|
||||
app.use('/api', importRouter)
|
||||
app.use('/api', systemRouter)
|
||||
app.use('/api', nfoUpdatesRouter)
|
||||
app.use('/api', plexRouter)
|
||||
// ── Authentification ───────────────────────────────────────────
|
||||
// Session ouverte d'après l'en-tête du proxy d'authentification (PROXY_AUTH_HEADER)
|
||||
site.use(proxyAuth)
|
||||
site.get ('/api/auth/status', authStatus)
|
||||
site.post('/api/auth/setup', authSetup)
|
||||
site.post('/api/auth/login', authLogin)
|
||||
site.post('/api/auth/logout', authLogout)
|
||||
site.get ('/api/auth/me', requireAuth, authMe)
|
||||
site.post('/api/auth/change-password', requireAuth, authChangePassword)
|
||||
site.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken)
|
||||
site.post('/api/auth/tour-seen', requireAuth, authTourSeen)
|
||||
|
||||
// ── API publique v1 (authentification par jeton) ──────────────
|
||||
site.use('/api', publicApiRouter)
|
||||
|
||||
// ── 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',
|
||||
]
|
||||
site.use('/api', (req, res, next) => {
|
||||
if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) {
|
||||
return requireAdmin(req, res, next)
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
// ── Routes (admins et utilisateurs connectés) ──────────────────
|
||||
site.use('/api', usersRouter)
|
||||
site.use('/api', jellyfinRouter)
|
||||
site.use('/api', settingsRouter)
|
||||
site.use('/api', torrentClientsRouter)
|
||||
site.use('/api', downloadsRouter)
|
||||
site.use('/api', organizeRouter)
|
||||
site.use('/api', importRouter)
|
||||
site.use('/api', systemRouter)
|
||||
site.use('/api', nfoUpdatesRouter)
|
||||
site.use('/api', plexRouter)
|
||||
site.use('/api', rssSyncRouter)
|
||||
site.use('/api', invitesRouter)
|
||||
site.use('/api', requireAuth, seriesRouter)
|
||||
site.use('/api', requireAuth, requestsRouter)
|
||||
|
||||
// ── Application web (toutes les autres routes) ─────────────────
|
||||
// index.html est servi avec <base href> et window.__BASE_URL__ : le client
|
||||
// (routeur et appels /api) suit ainsi le préfixe du reverse proxy sans rebuild.
|
||||
let indexHtmlCache: string | null = null
|
||||
function renderIndexHtml(): string {
|
||||
if (indexHtmlCache !== null) return indexHtmlCache
|
||||
const raw = fs.readFileSync(path.join(PUBLIC_PATH, 'index.html'), 'utf-8')
|
||||
const injected =
|
||||
`<base href="${BASE_URL}/">\n` +
|
||||
` <script>window.__BASE_URL__ = ${JSON.stringify(BASE_URL)}</script>`
|
||||
indexHtmlCache = raw.replace('<head>', `<head>\n ${injected}`)
|
||||
return indexHtmlCache
|
||||
}
|
||||
|
||||
// ── Catch-all SPA ──────────────────────────────────────────────
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) })
|
||||
site.get('*path', (_req, res) => {
|
||||
res.type('html').send(renderIndexHtml())
|
||||
})
|
||||
}
|
||||
|
||||
// ── Montage sous le chemin de base ─────────────────────────────
|
||||
app.use(BASE_URL || '/', site)
|
||||
if (BASE_URL) {
|
||||
// Accès à la racine : redirection vers l'application
|
||||
app.get('/', (_req, res) => { res.redirect(`${BASE_URL}/`) })
|
||||
}
|
||||
|
||||
// ── Démarrage ──────────────────────────────────────────────────
|
||||
const server = http.createServer({ maxHeaderSize: 32768 }, app)
|
||||
|
||||
server.listen(PORT, async () => {
|
||||
logger.info('api', `Serveur démarré sur le port ${PORT}`)
|
||||
const url = `http://localhost:${PORT}${BASE_URL}`
|
||||
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', `Cache GitHub initialisé — ${available.length} séries disponibles`)
|
||||
logger.info('api', `Catalogue chargé : ${available.length} séries`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`)
|
||||
logger.warn('api', `Impossible de charger le catalogue (available.json) au démarrage : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
const { mediaPath } = readSettings()
|
||||
|
||||
// Migration organized.json : si ancien format (valeurs string) → reset + rescan
|
||||
// Ancien format d'organized.json (valeurs texte) : réinitialisation, l'analyse le reconstruit
|
||||
try {
|
||||
if (fs.existsSync(ORGANIZED_PATH)) {
|
||||
const raw = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
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é → réinitialisation')
|
||||
fs.writeFileSync(ORGANIZED_PATH, '{}', 'utf-8')
|
||||
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 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++
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
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}`)
|
||||
}
|
||||
|
||||
loadEnrichedSeriesData()
|
||||
.then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData))
|
||||
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : 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}`))
|
||||
|
||||
const autoOrganize = async () => {
|
||||
autoImportSchedule.lastRunAt = new Date().toISOString()
|
||||
planNextAutoImport()
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const infohashMap = await readInfohashMap()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const seriesData = await loadCatalog()
|
||||
await autoOrganizeAll(
|
||||
() => dispatchList(category ?? 'fankai', infohashMap),
|
||||
seriesData,
|
||||
@@ -112,44 +225,82 @@ server.listen(PORT, async () => {
|
||||
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) {
|
||||
try {
|
||||
await dispatchRemove(result.hash, false)
|
||||
logger.info('api', `Torrent "${result.name}" supprimé après move`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Impossible de supprimer le torrent "${result.name}" : ${err instanceof Error ? err.message : err}`)
|
||||
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) {
|
||||
logger.error('api', `Auto-organise échoué : ${err instanceof Error ? err.message : err}`)
|
||||
logger.error('api', `Échec de l'import automatique : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
planNextAutoImport(10_000)
|
||||
setTimeout(() => {
|
||||
autoOrganize()
|
||||
setInterval(autoOrganize, 5 * 60_000)
|
||||
setInterval(autoOrganize, AUTO_IMPORT_INTERVAL_MS)
|
||||
}, 10_000)
|
||||
|
||||
setTimeout(() => {
|
||||
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif initiale échouée : ${err instanceof Error ? err.message : err}`))
|
||||
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', `Vérif horaire échouée : ${err instanceof Error ? err.message : err}`))
|
||||
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)
|
||||
|
||||
// Sync automatique des noms de fichiers toutes les heures.
|
||||
// Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches.
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json'))
|
||||
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', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
|
||||
logger.info('api', `Renommage automatique : ${renamed} fichier(s) renommé(s)`)
|
||||
} catch (err) {
|
||||
logger.error('api', `Sync noms auto échoué : ${err instanceof Error ? err.message : 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é ou si l'import automatique est coupé
|
||||
const autoImportJellyfin = () => {
|
||||
if (!readSettings().jellyfinAutoImport) return
|
||||
runJellyfinImport().catch(err => logger.error('jellyfin', `Échec de l'import des utilisateurs Jellyfin : ${err instanceof Error ? err.message : err}`))
|
||||
}
|
||||
setTimeout(() => {
|
||||
autoImportJellyfin()
|
||||
setInterval(autoImportJellyfin, 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)
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import { DATA_DIR } from '../config.js'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
const GITHUB_BASE = process.env.GITHUB_BASE
|
||||
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
||||
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 }
|
||||
interface CacheEntry<T> { data: T; expiresAt: number; fetchedAt: number }
|
||||
const _cache = new Map<string, CacheEntry<any>>()
|
||||
|
||||
export function cacheGet<T>(key: string): T | null {
|
||||
@@ -18,41 +19,51 @@ export function cacheGet<T>(key: string): T | null {
|
||||
}
|
||||
|
||||
export function cacheSet<T>(key: string, data: T): void {
|
||||
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
|
||||
_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 }
|
||||
|
||||
export async function githubGet(urlPath: string): Promise<any> {
|
||||
const cached = cacheGet(urlPath)
|
||||
if (cached) return cached
|
||||
const res = await fetch(`${GITHUB_BASE}/${urlPath}`)
|
||||
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
|
||||
/** 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(): Promise<number[]> {
|
||||
try { return await githubGet('available.json') as number[] }
|
||||
export async function readAvailable(force = false): Promise<number[]> {
|
||||
try { return await githubGet('available.json', force) as number[] }
|
||||
catch { return [] }
|
||||
}
|
||||
|
||||
export async function readInfohashMap(): Promise<Record<string, string>> {
|
||||
try { return await githubGet('infohash_map.json') as Record<string, string> }
|
||||
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): Promise<any | null> {
|
||||
try { return await githubGet(`series/${serieId}.json`) }
|
||||
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(): Promise<any[]> {
|
||||
const ids = await readAvailable()
|
||||
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
|
||||
import crypto from 'crypto'
|
||||
import { readSettings, writeSettings, type Settings } from '../settings.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const CLIENT_HEADER = 'MediaBrowser Client="FanKarr", Device="Server", DeviceId="fankarr-server", Version="1.0"'
|
||||
const TIMEOUT_MS = 10_000
|
||||
|
||||
export function isJellyfinConfigured(s: Settings = readSettings()): boolean {
|
||||
return !!(s.jellyfinUrl && s.jellyfinAdminToken)
|
||||
}
|
||||
|
||||
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
|
||||
ServerId? : 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()
|
||||
}
|
||||
|
||||
// ── Identifiant du serveur ────────────────────────────────────
|
||||
|
||||
export async function getJellyfinServerId(): Promise<string | null> {
|
||||
const { jellyfinUrl, jellyfinAdminToken, jellyfinServerId } = readSettings()
|
||||
if (jellyfinServerId) return jellyfinServerId
|
||||
if (!jellyfinUrl || !jellyfinAdminToken) return null
|
||||
|
||||
try {
|
||||
const res = await fetch(`${jellyfinUrl.replace(/\/$/, '')}/System/Info`, {
|
||||
headers: adminHeaders(jellyfinAdminToken),
|
||||
signal : AbortSignal.timeout(TIMEOUT_MS),
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
if (!data?.Id) return null
|
||||
const id = normalizeJellyfinId(data.Id)
|
||||
writeSettings({ jellyfinServerId: id })
|
||||
logger.info('jellyfin', `Identifiant du serveur Jellyfin enregistré (${id.slice(0, 8)}…)`)
|
||||
return id
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Authentification par nom et mot de passe ──────────────────
|
||||
|
||||
export type JellyfinAuthResult =
|
||||
| { ok: true; id: string; name: string; serverId: string }
|
||||
| { ok: false; reason: 'credentials' | 'unreachable'; error?: string }
|
||||
|
||||
export async function authenticateJellyfinUser(username: string, password: string, clientIp?: string): Promise<JellyfinAuthResult> {
|
||||
const { jellyfinUrl } = readSettings()
|
||||
if (!jellyfinUrl) return { ok: false, reason: 'unreachable', error: 'Jellyfin non configuré' }
|
||||
|
||||
const base = jellyfinUrl.replace(/\/$/, '')
|
||||
const deviceId = `fankarr-web-${crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16)}`
|
||||
const client = `MediaBrowser Client="FanKarr", Device="FanKarr (connexion web)", DeviceId="${deviceId}", Version="1.0"`
|
||||
|
||||
let data: any
|
||||
try {
|
||||
const res = await fetch(`${base}/Users/AuthenticateByName`, {
|
||||
method : 'POST',
|
||||
headers: {
|
||||
'Authorization': client,
|
||||
'Content-Type' : 'application/json',
|
||||
...(clientIp ? { 'X-Forwarded-For': clientIp.replace(/^::ffff:/, '') } : {}),
|
||||
},
|
||||
body : JSON.stringify({ Username: username, Pw: password }),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
})
|
||||
if (res.status === 401 || res.status === 403) return { ok: false, reason: 'credentials' }
|
||||
if (!res.ok) return { ok: false, reason: 'unreachable', error: `Jellyfin a répondu HTTP ${res.status}` }
|
||||
data = await res.json()
|
||||
} catch (err) {
|
||||
return { ok: false, reason: 'unreachable', error: err instanceof Error ? err.message : 'Erreur réseau' }
|
||||
}
|
||||
|
||||
if (!data?.User?.Id) return { ok: false, reason: 'unreachable', error: 'Réponse Jellyfin inattendue' }
|
||||
|
||||
// Session fermée aussitôt : FanKarr ne garde aucun jeton Jellyfin
|
||||
if (data.AccessToken) {
|
||||
fetch(`${base}/Sessions/Logout`, {
|
||||
method : 'POST',
|
||||
headers: { 'Authorization': `${client}, Token="${data.AccessToken}"` },
|
||||
signal : AbortSignal.timeout(TIMEOUT_MS),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
return {
|
||||
ok : true,
|
||||
id : normalizeJellyfinId(data.User.Id),
|
||||
name : data.User.Name,
|
||||
serverId: normalizeJellyfinId(data.ServerId ?? data.User.ServerId ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation d'un jeton utilisateur Jellyfin ────────────────
|
||||
|
||||
export interface JellyfinTokenInfo {
|
||||
valid : boolean
|
||||
userId? : string
|
||||
username?: string
|
||||
error? : string
|
||||
}
|
||||
|
||||
export function normalizeJellyfinId(id: string): string {
|
||||
return String(id).replace(/-/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
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(/\/$/, '')
|
||||
// /Users/{id}
|
||||
const res = await fetch(`${base}/Users/Me`, {
|
||||
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 (normalizeJellyfinId(user.Id) !== normalizeJellyfinId(jellyfinUserId)) {
|
||||
logger.warn('jellyfin', `Jeton de « ${user.Name} » présenté pour un autre utilisateur Jellyfin (${String(jellyfinUserId).slice(0, 8)}…)`)
|
||||
return { valid: false, error: 'Ce jeton Jellyfin appartient à un autre utilisateur' }
|
||||
}
|
||||
if (user.Policy?.IsDisabled) return { valid: false, error: 'Compte Jellyfin désactivé' }
|
||||
|
||||
const serverId = await getJellyfinServerId()
|
||||
if (serverId && user.ServerId && normalizeJellyfinId(user.ServerId) !== serverId) {
|
||||
logger.error('jellyfin', `Jeton de « ${user.Name} » refusé : il vient d'un autre serveur Jellyfin que celui configuré`)
|
||||
return { valid: false, error: 'Ce jeton vient d\'un autre serveur Jellyfin' }
|
||||
}
|
||||
|
||||
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,37 @@
|
||||
const MAX_FAILURES = 5
|
||||
const WINDOW_MS = 15 * 60_000
|
||||
|
||||
const failures = new Map<string, { count: number; firstAt: number }>()
|
||||
|
||||
function keyOf(ip: string, username: string): string {
|
||||
return `${ip}|${username.toLowerCase()}`
|
||||
}
|
||||
|
||||
function sweep(now: number): void {
|
||||
for (const [key, entry] of failures) {
|
||||
if (now - entry.firstAt >= WINDOW_MS) failures.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Secondes avant une nouvelle tentative, 0 si la connexion est permise
|
||||
export function loginRetryAfter(ip: string, username: string): number {
|
||||
const key = keyOf(ip, username)
|
||||
const entry = failures.get(key)
|
||||
if (!entry) return 0
|
||||
const elapsed = Date.now() - entry.firstAt
|
||||
if (elapsed >= WINDOW_MS) { failures.delete(key); return 0 }
|
||||
return entry.count >= MAX_FAILURES ? Math.ceil((WINDOW_MS - elapsed) / 1000) : 0
|
||||
}
|
||||
|
||||
export function recordLoginFailure(ip: string, username: string): void {
|
||||
const now = Date.now()
|
||||
if (failures.size > 10_000) sweep(now)
|
||||
const key = keyOf(ip, username)
|
||||
const entry = failures.get(key)
|
||||
if (!entry || now - entry.firstAt >= WINDOW_MS) failures.set(key, { count: 1, firstAt: now })
|
||||
else entry.count++
|
||||
}
|
||||
|
||||
export function clearLoginFailures(ip: string, username: string): void {
|
||||
failures.delete(keyOf(ip, username))
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export async function downloadNfoFolder(gitlabTitle: string, destRoot: string):
|
||||
downloaded++
|
||||
} catch { skipped++ }
|
||||
}
|
||||
logger.info('nfo-update', `"${gitlabTitle}" — ${downloaded} fichiers mis à jour, ${skipped} ignorés`)
|
||||
logger.info('nfo-update', `NFO de « ${gitlabTitle} » : ${downloaded} fichier(s) mis à jour, ${skipped} échec(s)`)
|
||||
}
|
||||
|
||||
export async function checkNfoUpdates() {
|
||||
@@ -82,15 +82,15 @@ export async function checkNfoUpdates() {
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
const latest = await getLatestGitlabCommit(gitlabTitle)
|
||||
if (!latest || commits[gitlabTitle] === latest.sha) continue
|
||||
logger.info('nfo-update', `MAJ NFO détectée pour "${gitlabTitle}" (${latest.sha.slice(0, 8)})`)
|
||||
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 inconnue'
|
||||
logger.error('nfo-update', `Échec MAJ NFO "${gitlabTitle}" : ${msg}`)
|
||||
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()
|
||||
|
||||
@@ -1,12 +1,54 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
|
||||
export interface OrganizeNotif {
|
||||
hash: string; name: string; done: number; skipped: number; errors: number
|
||||
hash: string; name: string; serieId?: number | null; done: number; skipped: number; errors: number
|
||||
errorFiles: { file: string; error: string }[]; at: string
|
||||
}
|
||||
|
||||
export const recentOrganized: OrganizeNotif[] = []
|
||||
const MAX_NOTIFS = 20
|
||||
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_NOTIFS) recentOrganized.pop()
|
||||
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,111 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const users = vi.hoisted(() => ({
|
||||
list: [] as { id: string; username: string; role: string }[],
|
||||
}))
|
||||
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../users.js', () => ({
|
||||
hasUsers : () => users.list.length > 0,
|
||||
findByUsername: (name: string) => users.list.find(u => u.username.toLowerCase() === name.toLowerCase()),
|
||||
createUser : (username: string, _password: string, role: string) => {
|
||||
const user = { id: `id-${username}`, username, role }
|
||||
users.list.push(user)
|
||||
return user
|
||||
},
|
||||
}))
|
||||
|
||||
const ENV_KEYS = ['PROXY_AUTH_HEADER', 'PROXY_AUTH_TRUSTED_PROXIES', 'PROXY_AUTH_AUTO_CREATE', 'PROXY_AUTH_LOGOUT_URL']
|
||||
|
||||
async function load(env: Record<string, string>) {
|
||||
for (const key of ENV_KEYS) delete process.env[key]
|
||||
Object.assign(process.env, env)
|
||||
vi.resetModules()
|
||||
return import('./proxy-auth.js')
|
||||
}
|
||||
|
||||
function request(remoteAddress: string, headers: Record<string, string> = {}): Request {
|
||||
return { socket: { remoteAddress }, headers } as unknown as Request
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
users.list = [{ id: 'id-alice', username: 'Alice', role: 'admin' }]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) delete process.env[key]
|
||||
})
|
||||
|
||||
describe('adresses de confiance', () => {
|
||||
it('reconnaît une plage IPv4, une adresse exacte et une IPv4 vue par un socket IPv6', async () => {
|
||||
const { parseTrustedProxies, isTrustedAddress } = await load({})
|
||||
const trusted = parseTrustedProxies('172.18.0.0/16, 10.0.0.5, fd00::1')
|
||||
|
||||
expect(isTrustedAddress('172.18.3.4', trusted)).toBe(true)
|
||||
expect(isTrustedAddress('::ffff:172.18.0.2', trusted)).toBe(true)
|
||||
expect(isTrustedAddress('10.0.0.5', trusted)).toBe(true)
|
||||
expect(isTrustedAddress('FD00::1', trusted)).toBe(true)
|
||||
|
||||
expect(isTrustedAddress('172.19.0.1', trusted)).toBe(false)
|
||||
expect(isTrustedAddress('10.0.0.6', trusted)).toBe(false)
|
||||
expect(isTrustedAddress(undefined, trusted)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignore les entrées invalides', async () => {
|
||||
const { parseTrustedProxies } = await load({})
|
||||
expect(parseTrustedProxies('pas-une-ip, 10.0.0.0/40, fd00::/64')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('proxyAuthUser', () => {
|
||||
const enabled = { PROXY_AUTH_HEADER: 'X-User', PROXY_AUTH_TRUSTED_PROXIES: '172.18.0.0/16' }
|
||||
|
||||
it('est désactivé sans adresse de confiance, même si l\'en-tête est défini', async () => {
|
||||
const mod = await load({ PROXY_AUTH_HEADER: 'X-User' })
|
||||
expect(mod.PROXY_AUTH_ENABLED).toBe(false)
|
||||
expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'alice' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renvoie le compte désigné par un proxy de confiance, sans tenir compte de la casse', async () => {
|
||||
const mod = await load(enabled)
|
||||
expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'alice' }))?.id).toBe('id-alice')
|
||||
})
|
||||
|
||||
it('ignore l\'en-tête venant d\'une adresse non déclarée', async () => {
|
||||
const mod = await load(enabled)
|
||||
expect(mod.proxyAuthUser(request('192.168.1.50', { 'x-user': 'alice' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ne fait rien sans en-tête', async () => {
|
||||
const mod = await load(enabled)
|
||||
expect(mod.proxyAuthUser(request('172.18.0.2'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ne crée pas de compte inconnu par défaut', async () => {
|
||||
const mod = await load(enabled)
|
||||
expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'bob' }))).toBeUndefined()
|
||||
expect(users.list).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('crée un compte utilisateur avec PROXY_AUTH_AUTO_CREATE', async () => {
|
||||
const mod = await load({ ...enabled, PROXY_AUTH_AUTO_CREATE: 'true' })
|
||||
const user = mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'bob' }))
|
||||
expect(user).toMatchObject({ username: 'bob', role: 'user' })
|
||||
})
|
||||
|
||||
it('laisse l\'assistant créer le premier compte', async () => {
|
||||
users.list = []
|
||||
const mod = await load({ ...enabled, PROXY_AUTH_AUTO_CREATE: 'true' })
|
||||
expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'bob' }))).toBeUndefined()
|
||||
expect(users.list).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('n\'expose l\'adresse de déconnexion que si l\'authentification par proxy est active', async () => {
|
||||
expect((await load({ PROXY_AUTH_LOGOUT_URL: '/oauth2/sign_out' })).PROXY_AUTH_LOGOUT_URL).toBe('')
|
||||
expect((await load({ ...enabled, PROXY_AUTH_LOGOUT_URL: '/oauth2/sign_out' })).PROXY_AUTH_LOGOUT_URL).toBe('/oauth2/sign_out')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import net from 'net'
|
||||
import type { Request } from 'express'
|
||||
import { logger } from '../logger.js'
|
||||
import { findByUsername, createUser, hasUsers, type User } from '../users.js'
|
||||
|
||||
// ── Authentification déléguée à un proxy (oauth2-proxy, Authelia…) ──
|
||||
// Le proxy authentifie l'utilisateur et transmet son nom dans un en-tête.
|
||||
// L'en-tête n'est lu que pour les connexions venant d'une adresse déclarée
|
||||
// dans PROXY_AUTH_TRUSTED_PROXIES : sans cela, n'importe quel client joignant
|
||||
// directement le port de FanKarr pourrait se faire passer pour un autre.
|
||||
|
||||
function parseBool(raw: string | undefined): boolean {
|
||||
return ['1', 'true', 'yes', 'on'].includes((raw ?? '').trim().toLowerCase())
|
||||
}
|
||||
|
||||
type Matcher = (ip: string) => boolean
|
||||
|
||||
function ipv4ToInt(ip: string): number {
|
||||
return ip.split('.').reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0
|
||||
}
|
||||
|
||||
// '::ffff:172.18.0.5' (IPv4 vue par un socket IPv6) → '172.18.0.5'
|
||||
export function normalizeIp(ip: string): string {
|
||||
const trimmed = ip.trim()
|
||||
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(trimmed)
|
||||
return mapped ? mapped[1] : trimmed.toLowerCase()
|
||||
}
|
||||
|
||||
// Entrée acceptée : adresse IPv4/IPv6 exacte ou plage IPv4 en notation CIDR
|
||||
function parseTrustedEntry(entry: string): Matcher | null {
|
||||
const [addr, bitsRaw] = entry.split('/')
|
||||
const ip = normalizeIp(addr)
|
||||
if (bitsRaw === undefined) {
|
||||
return net.isIP(ip) ? (candidate) => candidate === ip : null
|
||||
}
|
||||
const bits = Number(bitsRaw)
|
||||
if (net.isIPv4(ip) && Number.isInteger(bits) && bits >= 0 && bits <= 32) {
|
||||
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0
|
||||
const base = ipv4ToInt(ip) & mask
|
||||
return (candidate) => net.isIPv4(candidate) && (ipv4ToInt(candidate) & mask) === base
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseTrustedProxies(raw: string | undefined): Matcher[] {
|
||||
const matchers: Matcher[] = []
|
||||
for (const entry of (raw ?? '').split(',').map(e => e.trim()).filter(Boolean)) {
|
||||
const matcher = parseTrustedEntry(entry)
|
||||
if (matcher) matchers.push(matcher)
|
||||
else logger.warn('auth', `PROXY_AUTH_TRUSTED_PROXIES : « ${entry} » ignoré (attendu : adresse IP ou plage IPv4 CIDR)`)
|
||||
}
|
||||
return matchers
|
||||
}
|
||||
|
||||
export function isTrustedAddress(remoteAddress: string | undefined, matchers: Matcher[]): boolean {
|
||||
if (!remoteAddress) return false
|
||||
const ip = normalizeIp(remoteAddress)
|
||||
return matchers.some(match => match(ip))
|
||||
}
|
||||
|
||||
const HEADER = (process.env.PROXY_AUTH_HEADER ?? '').trim().toLowerCase()
|
||||
const TRUSTED = parseTrustedProxies(process.env.PROXY_AUTH_TRUSTED_PROXIES)
|
||||
const AUTO_CREATE = parseBool(process.env.PROXY_AUTH_AUTO_CREATE)
|
||||
const LOGOUT_URL = (process.env.PROXY_AUTH_LOGOUT_URL ?? '').trim()
|
||||
|
||||
export const PROXY_AUTH_ENABLED = (() => {
|
||||
if (!HEADER) return false
|
||||
if (TRUSTED.length === 0) {
|
||||
logger.error('auth', 'PROXY_AUTH_HEADER est défini mais PROXY_AUTH_TRUSTED_PROXIES est vide : authentification par proxy désactivée')
|
||||
return false
|
||||
}
|
||||
logger.info('auth', `Authentification par proxy activée (en-tête ${HEADER}${AUTO_CREATE ? ', création automatique des comptes' : ''})`)
|
||||
return true
|
||||
})()
|
||||
|
||||
export const PROXY_AUTH_LOGOUT_URL = PROXY_AUTH_ENABLED ? LOGOUT_URL : ''
|
||||
|
||||
// Avertissements déjà émis, pour ne pas les répéter à chaque requête
|
||||
const warned = new Set<string>()
|
||||
function warnOnce(key: string, message: string): void {
|
||||
if (warned.has(key) || warned.size > 1_000) return
|
||||
warned.add(key)
|
||||
logger.warn('auth', message)
|
||||
}
|
||||
|
||||
// Compte FanKarr désigné par le proxy, ou undefined si la requête n'en vient pas
|
||||
export function proxyAuthUser(req: Request): User | undefined {
|
||||
if (!PROXY_AUTH_ENABLED) return undefined
|
||||
|
||||
const raw = req.headers[HEADER]
|
||||
const username = (Array.isArray(raw) ? raw[0] : raw)?.trim()
|
||||
if (!username) return undefined
|
||||
|
||||
// Adresse du socket et non req.ip : X-Forwarded-For est fourni par le client
|
||||
if (!isTrustedAddress(req.socket.remoteAddress, TRUSTED)) {
|
||||
warnOnce(`ip|${req.socket.remoteAddress}`, `En-tête ${HEADER} ignoré : la connexion vient de ${req.socket.remoteAddress}, absent de PROXY_AUTH_TRUSTED_PROXIES`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Premier lancement : le compte admin est créé par l'assistant
|
||||
if (!hasUsers()) return undefined
|
||||
|
||||
const user = findByUsername(username)
|
||||
if (user) return user
|
||||
|
||||
if (!AUTO_CREATE) {
|
||||
warnOnce(`user|${username.toLowerCase()}`, `Utilisateur « ${username} » transmis par le proxy inconnu de FanKarr : créez le compte ou activez PROXY_AUTH_AUTO_CREATE`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
// Sans mot de passe FanKarr, comme un compte importé de Jellyfin : la connexion passe par le proxy
|
||||
return createUser(username, '', 'user')
|
||||
} catch (err) {
|
||||
logger.error('auth', `Création du compte « ${username} » transmis par le proxy impossible : ${err instanceof Error ? err.message : err}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -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,289 @@
|
||||
/** Surveillance : envoie au client les épisodes dont le torrent est publié 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
|
||||
}
|
||||
|
||||
// ── Torrents publiés ──────────────────────────────────────────
|
||||
|
||||
// pub_date nyaa : « AAAA-MM-JJ HH:MM » en UTC
|
||||
function pubTime(t: any): number {
|
||||
return typeof t.pub_date === 'string' ? Date.parse(`${t.pub_date.replace(' ', 'T')}Z`) : NaN
|
||||
}
|
||||
|
||||
interface ReleaseEpisode { key: string; name: string; pathEntry: any }
|
||||
interface Release { torrent: any; episodes: ReleaseEpisode[] }
|
||||
|
||||
// Torrents Fankai de la série et épisodes qu'ils contiennent
|
||||
function listReleases(sd: any): Release[] {
|
||||
const byHash = new Map<string, Release>()
|
||||
const add = (t: any) => {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (t.fankai && hash && (t.torrent_url || t.magnet) && !byHash.has(hash)) byHash.set(hash, { torrent: t, episodes: [] })
|
||||
}
|
||||
for (const t of sd.torrents ?? []) add(t)
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) add(t)
|
||||
for (const ep of season.episodes ?? []) for (const t of ep.torrents ?? []) add(t)
|
||||
}
|
||||
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const key = `${season.season_number}:${ep.episode_number}`
|
||||
const name = `S${String(season.season_number).padStart(2, '0')}E${String(ep.episode_number).padStart(2, '0')}`
|
||||
const hashes = new Set<string>([...(ep.paths ?? []), ...(ep.torrents ?? [])].map((x: any) => x?.infohash?.toLowerCase()).filter(Boolean))
|
||||
for (const hash of hashes) {
|
||||
const release = byHash.get(hash)
|
||||
if (!release || release.episodes.some(e => e.key === key)) continue
|
||||
release.episodes.push({ key, name, pathEntry: (ep.paths ?? []).find((p: any) => p?.infohash?.toLowerCase() === hash) ?? null })
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byHash.values()]
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
const releases = listReleases(sd)
|
||||
|
||||
logger.info('rss-sync', `Vérification de « ${title} » (surveillée depuis le ${activatedAt.toLocaleDateString('fr-FR')})`)
|
||||
|
||||
// Clé saison:épisode : les variantes x264/x265 d'un même épisode comptent pour un
|
||||
const organizedKeys = new Set<string>()
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? [])
|
||||
if (organizedIds.has(ep.id)) organizedKeys.add(`${season.season_number}:${ep.episode_number}`)
|
||||
|
||||
// Nouvel épisode
|
||||
const firstPub = new Map<string, number>()
|
||||
for (const { torrent, episodes } of releases) {
|
||||
const time = pubTime(torrent)
|
||||
for (const { key } of episodes)
|
||||
firstPub.set(key, Math.min(firstPub.get(key) ?? Infinity, Number.isNaN(time) ? -Infinity : time))
|
||||
}
|
||||
const candidates = new Set([...firstPub].filter(([key, time]) => time > activatedAt.getTime() && !organizedKeys.has(key)).map(([key]) => key))
|
||||
|
||||
if (candidates.size === 0) {
|
||||
logger.info('rss-sync', `« ${title} » : aucun nouvel épisode depuis l'activation`)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.info('rss-sync', `« ${title} » : ${candidates.size} épisode(s) à télécharger`)
|
||||
|
||||
const toDownload: { url: string; magnet: string | null; file_index: number | null; file_path: string | null; infohash: string; label: string }[] = []
|
||||
const covered = new Set<string>()
|
||||
const missing = (r: Release) => r.episodes.filter(e => candidates.has(e.key) && !covered.has(e.key))
|
||||
|
||||
for (const release of [...releases].sort((a, b) => missing(b).length - missing(a).length)) {
|
||||
const eps = missing(release)
|
||||
if (eps.length === 0) continue
|
||||
for (const e of eps) covered.add(e.key)
|
||||
|
||||
const hash = release.torrent.infohash.toLowerCase()
|
||||
if (activeHashes.has(hash)) { skipped++; continue }
|
||||
|
||||
const single = eps.length === 1 ? eps[0] : null
|
||||
toDownload.push({
|
||||
url : release.torrent.torrent_url ?? release.torrent.magnet,
|
||||
magnet : release.torrent.magnet ?? null,
|
||||
infohash : hash,
|
||||
file_index: single?.pathEntry?.file_index ?? null,
|
||||
file_path : single?.pathEntry?.path ?? null,
|
||||
label : `« ${title} » ${single ? single.name : `(${eps.length} épisodes)`}`,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
magnet : dl.magnet,
|
||||
})
|
||||
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()
|
||||
}
|
||||
@@ -1,11 +1,113 @@
|
||||
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(`Fankai API ${res.status}: ${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?:\/\//, '')
|
||||
@@ -43,6 +145,61 @@ export function extractTorrentsFromSerieData(sd: any): any[] {
|
||||
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()
|
||||
@@ -63,40 +220,50 @@ export function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: numb
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, any>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
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'
|
||||
const allTorrents = extractTorrentsFromSerieData(serieData)
|
||||
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'
|
||||
}
|
||||
|
||||
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
|
||||
|
||||
const allEpisodeIds = new Set<number>()
|
||||
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 ?? []) {
|
||||
allEpisodeIds.add(ep.id)
|
||||
const key = `${season.season_number}:${ep.episode_number}`
|
||||
uniqueEpKeys.add(key)
|
||||
epIdToKey.set(ep.id, key)
|
||||
}
|
||||
}
|
||||
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
const organizedEpKeys = new Set<string>()
|
||||
|
||||
for (const t of allTorrents) {
|
||||
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) organizedEpisodeIds.add(ep.episode_id)
|
||||
if (isOrganized) {
|
||||
const key = epIdToKey.get(ep.episode_id)
|
||||
if (key) organizedEpKeys.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const manualOrg = organized['manual'] ?? {}
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
if (allEpisodeIds.size === 0 || organizedEpisodeIds.size === 0) return 'none'
|
||||
if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete'
|
||||
return 'partial'
|
||||
return { total: uniqueEpKeys.size, organized: organizedEpKeys.size }
|
||||
}
|
||||
@@ -1,10 +1,3 @@
|
||||
/**
|
||||
* 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'
|
||||
@@ -13,27 +6,35 @@ 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(DATA_DIR, 'logs.jsonl')
|
||||
const MAX_LINES = 2000 // rotation auto au-delà
|
||||
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 {}
|
||||
}
|
||||
@@ -43,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)
|
||||
}
|
||||
@@ -65,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[] {
|
||||
@@ -76,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,8 +1,3 @@
|
||||
/**
|
||||
* organize-worker.ts
|
||||
* ==================
|
||||
* Worker thread dédié à l'organisation des fichiers.
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import fsp from 'fs/promises'
|
||||
@@ -10,10 +5,9 @@ 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'
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
|
||||
// ─── Utils log ────────────────────────────────────────────────
|
||||
// ─── 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 }) }
|
||||
@@ -28,40 +22,32 @@ export interface OrgEntry {
|
||||
src_filename : string
|
||||
dest_filename: string
|
||||
dest_path : string
|
||||
dest_dir : string
|
||||
}
|
||||
|
||||
// organized.json : Record<hash, Record<episode_id_string, OrgEntry>>
|
||||
type Organized = Record<string, Record<string, OrgEntry>>
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
|
||||
// ─── Réglages ─────────────────────────────────────────────────
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean, englishDirectory: boolean } {
|
||||
try {
|
||||
const p = path.join(DATA_DIR, 'settings.json')
|
||||
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
|
||||
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 } }
|
||||
}
|
||||
|
||||
// ─── Organized log ────────────────────────────────────────────
|
||||
function loadOrganized(): Organized {
|
||||
try {
|
||||
if (!fs.existsSync(ORGANIZED_PATH)) return {}
|
||||
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
} catch { return {} }
|
||||
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false , englishDirectory: false } }
|
||||
}
|
||||
|
||||
// ─── Suivi des imports ────────────────────────────────────────
|
||||
function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
|
||||
const data = loadOrganized()
|
||||
if (!data[hash]) data[hash] = {}
|
||||
data[hash][String(episodeId)] = entry
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
parentPort?.postMessage({ type: 'mark', hash, episodeId, entry })
|
||||
}
|
||||
|
||||
function isOrganized(hash: string, episodeId: number): boolean {
|
||||
return !!loadOrganized()[hash]?.[String(episodeId)]
|
||||
return !!readOrganized()[hash]?.[String(episodeId)]
|
||||
}
|
||||
|
||||
// ─── Lookup torrent dans les seriesData ───────────────────────
|
||||
// ─── 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) {
|
||||
@@ -100,6 +86,47 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
|
||||
return null
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function buildFileMap(
|
||||
serieData : any,
|
||||
hash : string,
|
||||
@@ -113,13 +140,15 @@ function buildFileMap(
|
||||
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 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, '/')
|
||||
matchedPath = p.path.replace(/\\/g, '/')
|
||||
matchedPathObj = p
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -127,18 +156,21 @@ function buildFileMap(
|
||||
if (!matchedPath) continue
|
||||
const filename = matchedPath.split('/').pop() ?? matchedPath
|
||||
|
||||
const fmtName = ep.formatted_name?.trim()
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
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 manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback nfo_filename`)
|
||||
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
|
||||
? (ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
? (rawNfo ?? rawOriginal ?? filename)
|
||||
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
|
||||
const destName = swapExtension(resolvedName, srcExt)
|
||||
|
||||
map.set(filename, {
|
||||
@@ -171,7 +203,7 @@ function isInExcludedFolder(filePath: string[]): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── 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'
|
||||
|
||||
@@ -192,7 +224,7 @@ async function fetchBinary(url: string): Promise<Buffer> {
|
||||
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
const folderPath = `pack/${gitlabTitle}`
|
||||
log(`Téléchargement NFO/images depuis GitLab pour "${gitlabTitle}"`)
|
||||
log(`Téléchargement des NFO et images depuis GitLab pour « ${gitlabTitle} »`)
|
||||
|
||||
let files: any[] = []
|
||||
try {
|
||||
@@ -207,14 +239,14 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
|
||||
page++
|
||||
}
|
||||
} catch (err) {
|
||||
warn(`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 (files.length === 0) { warn(`Aucun fichier NFO trouvé 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(`${fileEntries.length} fichiers NFO/images à télécharger pour "${gitlabTitle}"`)
|
||||
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) {
|
||||
@@ -228,14 +260,14 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
|
||||
fs.writeFileSync(destPath, data)
|
||||
downloaded++
|
||||
} catch (err) {
|
||||
warn(`Échec téléchargement 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 "${gitlabTitle}" — ${downloaded} téléchargés, ${skipped} déjà présents${failed > 0 ? `, ${failed} échecs` : ''}`)
|
||||
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)
|
||||
@@ -244,13 +276,31 @@ function swapExtension(filename: string, ext: string): string {
|
||||
}
|
||||
|
||||
function seasonFolder(n: number): string {
|
||||
return n === 0 ? 'Specials' : `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) {
|
||||
debug(`Hardlink échoué (${err instanceof Error ? err.message : err}), fallback copie`)
|
||||
debug(`Hardlink impossible (${err instanceof Error ? err.message : err}), copie du fichier`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -263,14 +313,20 @@ function sanitizeDirName(name: string): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
// ─── Organise un torrent ──────────────────────────────────────
|
||||
async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) {
|
||||
// ─── 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 }[] }
|
||||
|
||||
const found = findTorrentByHash(hash, seriesData)
|
||||
if (!found) {
|
||||
error(`Hash ${hash} introuvable dans les données série`)
|
||||
error(`Hash ${hash} introuvable dans le catalogue`)
|
||||
throw new Error(`Torrent introuvable : ${hash}`)
|
||||
}
|
||||
|
||||
@@ -278,31 +334,39 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
const rawTitle = serieData.title ?? serieData.show_title ?? name
|
||||
const serieTitle = sanitizeDirName(rawTitle)
|
||||
|
||||
debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
|
||||
debug(`Série identifiée : « ${serieTitle} » (mode : ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
|
||||
|
||||
if (nfoSupport) {
|
||||
await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
|
||||
}
|
||||
|
||||
// ── Cas torrent épisode unique ────────────────────────────
|
||||
// ── 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 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, '/'); break }
|
||||
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) {
|
||||
filePath = p.path.replace(/\\/g, '/')
|
||||
matchedPathObj = p
|
||||
break
|
||||
}
|
||||
}
|
||||
const filename = filePath?.split('/').pop() ?? name
|
||||
const fmtName = ep.formatted_name?.trim()
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
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
|
||||
|
||||
const srcExt = path.extname(filename)
|
||||
const resolvedName = nfoSupport
|
||||
? (ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||
? (rawNfo ?? rawOriginal ?? filename)
|
||||
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
|
||||
const destName = swapExtension(resolvedName, srcExt)
|
||||
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
||||
@@ -310,6 +374,11 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
|
||||
if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 }
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
const candidates = filePath ? [
|
||||
path.join(savePath, filePath),
|
||||
path.join(completePath, filePath),
|
||||
@@ -324,8 +393,8 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
|
||||
|
||||
if (!src) {
|
||||
error(`Fichier source introuvable : ${filename}`)
|
||||
return { ...result, total: 1, errors: [{ 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)) {
|
||||
@@ -339,10 +408,10 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
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,
|
||||
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir,
|
||||
})
|
||||
await fsp.unlink(src)
|
||||
log(`${destName} → Saison ${season.season_number}`)
|
||||
await unlinkWithRetry(src)
|
||||
log(`${destName} importé dans ${path.basename(destDir)}`)
|
||||
return { ...result, total: 1, done: 1 }
|
||||
}
|
||||
}
|
||||
@@ -350,18 +419,18 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
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,
|
||||
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir,
|
||||
})
|
||||
log(`${destName} → Saison ${season.season_number}`)
|
||||
log(`${destName} importé dans ${path.basename(destDir)}`)
|
||||
return { ...result, total: 1, done: 1 }
|
||||
}
|
||||
|
||||
// ── Pack saison / intégrale ───────────────────────────────
|
||||
// ── 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 mappé pour "${name}"`)
|
||||
warn(`Aucun fichier de « ${name} » ne correspond au catalogue`)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -370,6 +439,12 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
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 candidates = [
|
||||
path.join(savePath, fullPath),
|
||||
path.join(completePath, fullPath),
|
||||
@@ -380,9 +455,8 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
|
||||
|
||||
if (!src) {
|
||||
// Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0).
|
||||
// On skip silencieusement — pas une erreur.
|
||||
debug(`Fichier non disponible (skip) : ${filename}`)
|
||||
// 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
|
||||
}
|
||||
@@ -394,7 +468,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
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,
|
||||
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
|
||||
})
|
||||
result.skipped++
|
||||
continue
|
||||
@@ -403,28 +477,33 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (organizeMode === 'copy') {
|
||||
await fsp.copyFile(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 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,
|
||||
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
|
||||
})
|
||||
} else {
|
||||
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,
|
||||
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
|
||||
})
|
||||
await fsp.unlink(src)
|
||||
await unlinkWithRetry(src)
|
||||
}
|
||||
result.done++
|
||||
debug(`${nfo_filename} → Saison ${season_number}`)
|
||||
debug(`${nfo_filename} importé dans ${path.basename(destDir)}`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
error(`Échec import "${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 })
|
||||
}
|
||||
}
|
||||
@@ -438,36 +517,72 @@ parentPort?.on('message', async (msg: any) => {
|
||||
|
||||
const torrents: any[] = msg.torrents
|
||||
const seriesData: any[] = msg.seriesData ?? []
|
||||
const organized = loadOrganized()
|
||||
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 found = findTorrentByHash(t.hash, seriesData)
|
||||
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)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
|
||||
debug(`Torrent « ${t.name} » (${t.hash.slice(0, 8)}…) absent du catalogue (pas encore à jour ?)`)
|
||||
continue
|
||||
}
|
||||
|
||||
const { torrent, serieData } = found
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
|
||||
const orgHash = organized[t.hash] ?? {}
|
||||
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) {
|
||||
debug(`"${t.name}" déjà entièrement importé — skip`)
|
||||
debug(`« ${t.name} » déjà entièrement importé`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData)
|
||||
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(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
|
||||
error(`Échec de l'import de « ${t.name} » :${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
/**
|
||||
* organize.ts
|
||||
* ===========
|
||||
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { logger } from './logger.js'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { readSettings } from './settings.js'
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from './lib/organized-store.js'
|
||||
|
||||
export let workerRunning = false
|
||||
const _prevStates = new Map<string, string>()
|
||||
@@ -24,25 +17,63 @@ function resolveWorkerPath(): string {
|
||||
}
|
||||
|
||||
export interface OrganizeResult {
|
||||
serieId?: number | null
|
||||
total : number
|
||||
skipped : number
|
||||
done : number
|
||||
errors : { file: string; error: string }[]
|
||||
}
|
||||
|
||||
interface OrgEntry {
|
||||
at : string
|
||||
season : number
|
||||
episode : number
|
||||
episode_id : number
|
||||
src_filename : string
|
||||
dest_filename: string
|
||||
dest_path : string
|
||||
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
|
||||
}
|
||||
|
||||
type Organized = Record<string, Record<string, OrgEntry>>
|
||||
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()
|
||||
}
|
||||
|
||||
// ── Scan initial ──────────────────────────────────────────────
|
||||
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)
|
||||
@@ -50,6 +81,8 @@ function swapExt(filename: string, ext: string): string {
|
||||
return filename.slice(0, -cur.length) + ext
|
||||
}
|
||||
|
||||
export let lastScan: { at: string; found: number; added: number } | null = null
|
||||
|
||||
export async function scanMediaPath(
|
||||
mediaPath : string,
|
||||
organizedPath: string,
|
||||
@@ -58,16 +91,14 @@ export async function scanMediaPath(
|
||||
const result = { found: 0, added: 0 }
|
||||
|
||||
if (!fs.existsSync(mediaPath)) {
|
||||
logger.warn('organize', `Dossier médiathèque introuvable : ${mediaPath}`)
|
||||
logger.warn('organize', `Dossier de la médiathèque introuvable : ${mediaPath}`)
|
||||
return result
|
||||
}
|
||||
|
||||
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
|
||||
logger.info('organize', `Analyse de la médiathèque : ${mediaPath}`)
|
||||
|
||||
// Map : nom_sans_extension → { hash, srcFilename, episodeId, season, episode }
|
||||
// On indexe par nom sans extension pour matcher quelle que soit l'extension sur le disque
|
||||
const filenameIndex = new Map<string, {
|
||||
hash : string
|
||||
hashes : string[]
|
||||
srcFilename : string
|
||||
episodeId : number
|
||||
season : number
|
||||
@@ -79,12 +110,16 @@ export async function scanMediaPath(
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
|
||||
// Helper : indexer par nom sans extension
|
||||
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, {
|
||||
hash,
|
||||
hashes : [hash],
|
||||
srcFilename : filename,
|
||||
episodeId : ep.id,
|
||||
season : season.season_number,
|
||||
@@ -93,17 +128,21 @@ export async function scanMediaPath(
|
||||
})
|
||||
}
|
||||
|
||||
// 1. Noms depuis les paths (avec vrai infohash)
|
||||
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)
|
||||
if (ep.nfo_filename) idx(ep.nfo_filename, hash, ep.nfo_filename)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. original_filename, nfo_filename et formatted_name — indexés même si paths est vide
|
||||
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)
|
||||
@@ -115,16 +154,26 @@ export async function scanMediaPath(
|
||||
}
|
||||
}
|
||||
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch {}
|
||||
let organized: Organized
|
||||
try { organized = readOrganized(organizedPath) }
|
||||
catch (err) {
|
||||
logger.error('organize', `Analyse annulée : ${err instanceof Error ? err.message : err}`)
|
||||
return result
|
||||
}
|
||||
|
||||
// Track les paires hash:episodeId présentes
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
|
||||
@@ -135,11 +184,18 @@ export async function scanMediaPath(
|
||||
walk(full)
|
||||
} else if (entry.isFile() && /\.(mkv|mp4|avi|m4v|mov|wmv)$/i.test(entry.name)) {
|
||||
result.found++
|
||||
|
||||
// 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)
|
||||
|
||||
// Fallback : chercher si un candidat de l'index est un préfixe du nom du fichier
|
||||
// ex: index a "Wind Breaker.S01E01.MULTI.1080p" et le fichier est "Wind Breaker.S01E01.MULTI.1080p.x264-FANKAI"
|
||||
// À 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()) {
|
||||
@@ -154,12 +210,13 @@ export async function scanMediaPath(
|
||||
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', `Scan no match : "${nameWithoutExt}"${closeStr}`)
|
||||
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 { hash, srcFilename, episodeId, season, episode } = match
|
||||
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
|
||||
@@ -171,22 +228,28 @@ export async function scanMediaPath(
|
||||
episode,
|
||||
episode_id : episodeId,
|
||||
src_filename : srcFilename,
|
||||
dest_filename: entry.name, // vrai nom sur disque
|
||||
dest_filename: entry.name,
|
||||
dest_path : full,
|
||||
}
|
||||
result.added++
|
||||
logger.debug('organize', `Scan match : "${entry.name}" → ep ${episodeId} (S${season}E${episode})`)
|
||||
logger.debug('organize', `Analyse : « ${entry.name} » associé à l'épisode ${episodeId} (S${season}E${episode})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(mediaPath)
|
||||
|
||||
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()].map(v => `${v.hash}:${v.episodeId}`))
|
||||
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]
|
||||
@@ -196,17 +259,16 @@ export async function scanMediaPath(
|
||||
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
||||
}
|
||||
|
||||
// Désimport automatique des fichiers manquants sur le disque
|
||||
const { autoUnimportMissing } = readSettings() as any
|
||||
const { autoUnimportMissing } = readSettings()
|
||||
let autoRemoved = 0
|
||||
if (autoUnimportMissing) {
|
||||
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', `Désimport auto : fichier introuvable "${destPath}"`)
|
||||
logger.info('organize', `Retrait automatique : fichier introuvable « ${destPath} »`)
|
||||
}
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
||||
@@ -214,29 +276,34 @@ export async function scanMediaPath(
|
||||
}
|
||||
|
||||
if (result.added > 0 || removed > 0 || autoRemoved > 0) {
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
writeOrganized(organized, organizedPath)
|
||||
}
|
||||
|
||||
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
|
||||
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
|
||||
}
|
||||
|
||||
// ── Auto-import ───────────────────────────────────────────────
|
||||
// ── Import automatique ────────────────────────────────────────
|
||||
|
||||
export async function autoOrganizeAll(
|
||||
listFn : () => Promise<any[]>,
|
||||
seriesData : any[],
|
||||
onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
|
||||
onResult ?: (r: { hash: string; name: string; serieId?: number | null; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
|
||||
): Promise<void> {
|
||||
const { autoImport } = readSettings()
|
||||
const { autoImport, mediaPath } = readSettings()
|
||||
if (!autoImport) {
|
||||
logger.debug('organize', 'Import automatique désactivé — skip')
|
||||
logger.debug('organize', 'Import automatique désactivé')
|
||||
return
|
||||
}
|
||||
if (!mediaPath?.trim()) {
|
||||
logger.debug('organize', 'Médiathèque non configurée')
|
||||
return
|
||||
}
|
||||
|
||||
if (workerRunning) {
|
||||
logger.debug('organize', 'Worker déjà en cours — skip')
|
||||
if (_queued > 0) {
|
||||
logger.debug('organize', 'Import déjà en cours')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -260,11 +327,12 @@ export async function autoOrganizeAll(
|
||||
|
||||
for (const t of torrents) _prevStates.set(t.hash, t.state)
|
||||
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(ORGANIZED_PATH))
|
||||
organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
} catch {}
|
||||
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()]
|
||||
@@ -274,71 +342,183 @@ export async function autoOrganizeAll(
|
||||
if (seedingCount === 0) return
|
||||
|
||||
if (newlySeeding.length > 0 && !isFirstRun) {
|
||||
logger.info('organize', `${newlySeeding.length} torrent(s) terminé(s) → lancement de l'import automatique`)
|
||||
logger.info('organize', `${newlySeeding.length} torrent(s) terminé(s) : lancement de l'import automatique`)
|
||||
for (const t of newlySeeding) {
|
||||
logger.debug('organize', `Nouveau seeding : "${t.name}"`)
|
||||
logger.debug('organize', `Torrent terminé : « ${t.name} »`)
|
||||
}
|
||||
} else {
|
||||
logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
|
||||
logger.debug('organize', `Import automatique : vérification de ${seedingCount} torrent(s) terminé(s)`)
|
||||
}
|
||||
|
||||
workerRunning = true
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
|
||||
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 === 'result') {
|
||||
try {
|
||||
await enqueue(() => runWorker(torrents, seriesData, msg => {
|
||||
if (msg.errors.length > 0) {
|
||||
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
|
||||
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}`)
|
||||
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} skippé(s)`)
|
||||
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} skippé(s))`)
|
||||
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,
|
||||
})
|
||||
} else if (msg.type === 'done') {
|
||||
logger.debug('organize', 'Worker terminé')
|
||||
workerRunning = false
|
||||
worker.terminate()
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => {
|
||||
logger.error('organize', `Worker erreur : ${err.message}`)
|
||||
workerRunning = false
|
||||
})
|
||||
worker.on('exit', (code) => {
|
||||
if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`)
|
||||
else logger.debug('organize', `Worker terminé (code ${code})`)
|
||||
workerRunning = false
|
||||
})
|
||||
|
||||
worker.postMessage({ type: 'run', torrents, seriesData })
|
||||
}))
|
||||
logger.debug('organize', 'Worker d\'import terminé')
|
||||
} catch (err) {
|
||||
logger.error('organize', `Erreur du worker : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sync des noms de fichiers après mise à jour du scraper ────
|
||||
// ── 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 {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch { return { renamed: 0, errors: 0 } }
|
||||
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
|
||||
@@ -362,9 +542,14 @@ export async function syncFilenameChanges(
|
||||
? 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
|
||||
? (ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName)
|
||||
: (ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
|
||||
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
|
||||
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
|
||||
|
||||
if (expectedName === currentName) continue
|
||||
|
||||
@@ -374,7 +559,7 @@ export async function syncFilenameChanges(
|
||||
const newPath = path.join(path.dirname(oldPath), expectedName)
|
||||
try {
|
||||
if (fs.existsSync(newPath)) {
|
||||
logger.warn('organize', `Sync rename : "${expectedName}" existe déjà — skip`)
|
||||
logger.warn('organize', `Renommage ignoré : « ${expectedName} » existe déjà`)
|
||||
continue
|
||||
}
|
||||
fs.renameSync(oldPath, newPath)
|
||||
@@ -384,22 +569,23 @@ export async function syncFilenameChanges(
|
||||
dest_path : newPath,
|
||||
at : new Date().toISOString(),
|
||||
}
|
||||
retargetEntries(organized, new Map([[oldPath, newPath]]))
|
||||
renamed++
|
||||
changed = true
|
||||
logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`)
|
||||
logger.info('organize', `Renommage : « ${currentName} » en « ${expectedName} »`)
|
||||
} catch (err) {
|
||||
errors++
|
||||
logger.error('organize', `Sync rename échoué pour ep ${ep.id} : ${err instanceof Error ? err.message : err}`)
|
||||
logger.error('organize', `Échec du renommage de l'épisode ${ep.id} :${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
writeOrganized(organized, organizedPath)
|
||||
|
||||
if (renamed > 0 || errors > 0)
|
||||
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
|
||||
logger.info('organize', `Noms de fichiers mis à jour : ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
|
||||
|
||||
return { renamed, errors }
|
||||
}
|
||||
@@ -410,46 +596,45 @@ export async function organizeTorrent(
|
||||
hash : string,
|
||||
name : string,
|
||||
savePath : string,
|
||||
seriesData: any[]
|
||||
seriesData: any[],
|
||||
files? : { name?: string; progress: number; priority?: number }[],
|
||||
): Promise<OrganizeResult> {
|
||||
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
|
||||
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})`)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
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') {
|
||||
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', 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
|
||||
if (msg.errors.length > 0) {
|
||||
logger.warn('organize', `Import "${name}" — ${msg.done} OK, ${msg.errors.length} erreur(s)`)
|
||||
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}`)
|
||||
logger.error('organize', `Erreur sur « ${e.file} » : ${e.error}`)
|
||||
}
|
||||
} else {
|
||||
logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
|
||||
logger.info('organize', `Import de « ${name} » terminé : ${msg.done} fichier(s) importé(s), ${msg.skipped} ignoré(s)`)
|
||||
}
|
||||
} else if (msg.type === 'done') {
|
||||
worker.terminate()
|
||||
resolve(result)
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => {
|
||||
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err.message}`)
|
||||
worker.terminate()
|
||||
reject(err)
|
||||
})
|
||||
worker.on('exit', (code) => {
|
||||
if (code > 1) reject(new Error(`Worker exit inattendu: code ${code}`))
|
||||
})
|
||||
|
||||
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
|
||||
})
|
||||
} 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
|
||||
}
|
||||
@@ -17,7 +17,7 @@ router.post('/download', requireAuth, async (req, res) => {
|
||||
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: 'torrent_url ou magnet requis' }); return }
|
||||
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
|
||||
@@ -29,13 +29,13 @@ router.post('/download', requireAuth, async (req, res) => {
|
||||
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 requis' }); return }
|
||||
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', `Suppression torrent échouée : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -49,24 +49,35 @@ router.get('/downloads', requireAuth, async (_req, res) => {
|
||||
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) { totalFiles = buildResolvedEpisodes(sd, match.infohash, match.season_number).length || 1; break }
|
||||
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)
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [] }
|
||||
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', `GET /api/downloads échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,40 +1,120 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { requireAuth, requireAdmin } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { getGitlabTitle } from '../gitlab-map.js'
|
||||
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.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 ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
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'
|
||||
|
||||
function loadOrganizedJson(): Record<string, Record<string, any>> {
|
||||
try { if (fs.existsSync(ORGANIZED_PATH)) return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {}
|
||||
return {}
|
||||
// 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
|
||||
}
|
||||
|
||||
function saveOrganizedJson(data: Record<string, Record<string, any>>) {
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
// 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: 'serie_id et items requis' }); return
|
||||
res.status(400).json({ error: 'Série ou fichiers à importer manquants' }); return
|
||||
}
|
||||
const { mediaPath, organizeMode, nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serie_id))
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return }
|
||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
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 ?? []) {
|
||||
@@ -43,34 +123,68 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
}
|
||||
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) { errors.push({ file: file_path ?? '?', error: 'Paramètres manquants' }); continue }
|
||||
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) { errors.push({ file: file_path, error: `Épisode ${episode_id} introuvable` }); continue }
|
||||
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 fmtName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null
|
||||
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
|
||||
? (ep.nfo_filename ?? ep.original_filename ?? srcFilename)
|
||||
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? srcFilename)
|
||||
? (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 seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}`
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder)
|
||||
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)
|
||||
logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`)
|
||||
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) }
|
||||
@@ -78,29 +192,34 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
} 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}" → "${destPath}"`)
|
||||
logger.info('api', `Import manuel : « ${srcFilename} » importé vers « ${destPath} »`)
|
||||
}
|
||||
} else {
|
||||
logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`)
|
||||
logger.debug('api', `Import manuel : « ${srcFilename} » déjà en place`)
|
||||
}
|
||||
const torrentHash = String(hash || '').toLowerCase() || 'manual'
|
||||
if (!organized[torrentHash]) organized[torrentHash] = {}
|
||||
organized[torrentHash][String(episode_id)] = {
|
||||
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) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
errors.push({ file: srcFilename, error: msg })
|
||||
logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
|
||||
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
|
||||
}
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
|
||||
if (nfoSupport && done.length > 0) {
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
@@ -121,9 +240,9 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
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)`)
|
||||
logger.info('api', `NFO téléchargés pour « ${gitlabTitle} » (import manuel)`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Échec téléchargement NFO pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
|
||||
logger.warn('api', `Échec du téléchargement des NFO de « ${gitlabTitle} » :${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -131,41 +250,54 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
res.json({ ok: true, done: done.length, errors })
|
||||
})
|
||||
|
||||
// ── Récap organisés d'une série ────────────────────────────────
|
||||
// ── É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 readSerieData(serieId)
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
const organized = loadOrganizedJson()
|
||||
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 hash = p.infohash.toLowerCase()
|
||||
const entry = (organized[hash] ?? {})[String(ep.id)]
|
||||
if (entry) { result[String(ep.id)] = entry; break }
|
||||
const h = p.infohash.toLowerCase()
|
||||
const e = (organized[h] ?? {})[String(ep.id)]
|
||||
if (e) { entry = e; entryHash = h; break }
|
||||
}
|
||||
if (!result[String(ep.id)] && organized['manual']?.[String(ep.id)]) {
|
||||
result[String(ep.id)] = organized['manual'][String(ep.id)]
|
||||
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 : 'Erreur inconnue' })
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Rename épisode ─────────────────────────────────────────────
|
||||
// ── 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: 'serie_id et episode_id requis' }); return }
|
||||
if (!serie_id || !episode_id) { res.status(400).json({ error: 'Série ou épisode manquant' }); return }
|
||||
const { nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serie_id))
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
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 ?? []) {
|
||||
@@ -179,44 +311,39 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
|
||||
let orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)]
|
||||
let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
|
||||
if (!orgEntry) {
|
||||
for (const [h, eps] of Object.entries(organized)) {
|
||||
if (eps[String(episode_id)]) { orgEntry = eps[String(episode_id)]; entryHash = h; break }
|
||||
}
|
||||
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 = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
|
||||
const srcExt = path.extname(currentName)
|
||||
let newName: string
|
||||
if (nfoSupport) {
|
||||
newName = ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName
|
||||
} else {
|
||||
newName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
|
||||
}
|
||||
if (newName === currentName) { res.json({ ok: true, renamed: false, message: 'Nom déjà correct' }); 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 source introuvable sur le disque')
|
||||
if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
|
||||
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() }
|
||||
saveOrganizedJson(organized)
|
||||
logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
|
||||
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 : 'Erreur inconnue'
|
||||
logger.error('api', `Rename échoué pour ep ${episode_id} : ${msg}`)
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Désimport série complète ───────────────────────────────────
|
||||
// ── 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 organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serieId))
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
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))
|
||||
@@ -224,10 +351,15 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
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) }
|
||||
}
|
||||
@@ -236,25 +368,42 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info('api', `Désimport série ${serieId} — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
|
||||
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 })
|
||||
})
|
||||
|
||||
// ── Désimport saison ───────────────────────────────────────────
|
||||
// ── 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 organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serieId))
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
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)) ?? [])
|
||||
@@ -273,79 +422,94 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
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', `Désimport saison ${seasonId} (série ${serieId}) — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
|
||||
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 })
|
||||
})
|
||||
|
||||
// ── Désimport épisode ──────────────────────────────────────────
|
||||
// ── 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'
|
||||
const organized = loadOrganizedJson()
|
||||
let foundHash: string | null = null
|
||||
let foundEntry: any = null
|
||||
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]) { foundHash = hash; foundEntry = episodes[episodeId]; break }
|
||||
if (episodes[episodeId]) found.push({ hash, entry: episodes[episodeId] })
|
||||
}
|
||||
if (!foundHash || !foundEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
|
||||
if (found.length === 0) { res.status(404).json({ error: 'Épisode non importé' }); return }
|
||||
try {
|
||||
if (deleteFile && foundEntry.dest_path && fs.existsSync(foundEntry.dest_path)) {
|
||||
fs.unlinkSync(foundEntry.dest_path)
|
||||
logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`)
|
||||
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} »`)
|
||||
}
|
||||
}
|
||||
delete organized[foundHash][episodeId]
|
||||
const torrentEmpty = Object.keys(organized[foundHash]).length === 0
|
||||
if (torrentEmpty) delete organized[foundHash]
|
||||
saveOrganizedJson(organized)
|
||||
// Si plus aucun épisode de ce torrent n'est importé → retirer le torrent du client
|
||||
if (deleteFile && torrentEmpty && foundHash !== 'manual') {
|
||||
dispatchRemove(foundHash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${foundHash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
|
||||
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) }
|
||||
}
|
||||
logger.info('api', `Désimport ep ${episodeId}${deleteFile && torrentEmpty ? ` + torrent ${foundHash.slice(0, 8)}… retiré du client` : ''}`)
|
||||
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 : 'Erreur inconnue'
|
||||
logger.error('api', `Désimport échoué : ${msg}`)
|
||||
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écap global des imports ───────────────────────────────────
|
||||
// ── Récapitulatif des imports ──────────────────────────────────
|
||||
router.get('/organized-summary', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
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 = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
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
|
||||
for (const [hash, eps] of Object.entries(organized)) {
|
||||
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
|
||||
// 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 = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
|
||||
const srcExt = path.extname(currentName)
|
||||
let expectedName: string
|
||||
if (nfoSupport) {
|
||||
expectedName = ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName
|
||||
} else {
|
||||
expectedName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
|
||||
}
|
||||
const needsRename = expectedName !== currentName
|
||||
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -353,23 +517,152 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
res.json({ series: result, nfo_support: nfoSupport })
|
||||
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', `organized-summary échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Rename en masse ────────────────────────────────────────────
|
||||
router.post('/rename-all', requireAuth, async (req, res) => {
|
||||
const { serie_id } = req.body
|
||||
// ── 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 organized = loadOrganizedJson()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
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 (serie_id && sd.id !== Number(serie_id)) continue
|
||||
if (onlyIds && !onlyIds.has(sd.id)) continue
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
let orgEntry: any = null
|
||||
@@ -378,49 +671,43 @@ router.post('/rename-all', requireAuth, async (req, res) => {
|
||||
if (eps[String(ep.id)]) { orgEntry = eps[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)
|
||||
let expectedName: string
|
||||
if (nfoSupport) {
|
||||
expectedName = ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName
|
||||
} else {
|
||||
expectedName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
|
||||
}
|
||||
if (expectedName === currentName) 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', `Rename masse ep ${ep.id} — ${msg}`)
|
||||
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 = `Fichier existant : "${expectedName}"`
|
||||
logger.warn('api', `Rename masse ep ${ep.id} — ${msg}`)
|
||||
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', `Rename masse : "${currentName}" → "${expectedName}"`)
|
||||
logger.info('api', `Renommage : « ${currentName} » en « ${expectedName} »`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('api', `Rename masse ep ${ep.id} — exception : ${msg}`)
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
writeOrganized(organized)
|
||||
res.json({ ok: true, done: done.length, errors })
|
||||
})
|
||||
|
||||
// ── Purge NFO / images des dossiers série ─────────────────────
|
||||
// ── 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 médiathèque non configuré ou introuvable' }); return
|
||||
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'])
|
||||
@@ -438,10 +725,10 @@ router.post('/purge-nfo', requireAuth, (req, res) => {
|
||||
try {
|
||||
fs.unlinkSync(full)
|
||||
deleted++
|
||||
logger.debug('api', `Purge NFO : supprimé "${full}"`)
|
||||
logger.debug('api', `Purge NFO : « ${full} » supprimé`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('api', `Purge NFO : impossible de supprimer "${full}" : ${msg}`)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -449,7 +736,7 @@ router.post('/purge-nfo', requireAuth, (req, res) => {
|
||||
}
|
||||
|
||||
walk(mediaPath)
|
||||
logger.info('api', `Purge NFO — ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`)
|
||||
logger.info('api', `Purge NFO : ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`)
|
||||
res.json({ ok: true, deleted, errors })
|
||||
})
|
||||
|
||||
|
||||
@@ -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,135 @@
|
||||
import { Router } from 'express'
|
||||
import { readSettings, writeSettings, type Settings } from '../settings.js'
|
||||
import { readUsers, importJellyfinUser } from '../users.js'
|
||||
import { testJellyfinConnection, fetchJellyfinUsers, getJellyfinServerId, normalizeJellyfinId, type JellyfinUser } from '../lib/jellyfin.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function settingsView(s: Settings) {
|
||||
return {
|
||||
jellyfinUrl : s.jellyfinUrl,
|
||||
hasToken : !!s.jellyfinAdminToken,
|
||||
jellyfinLogin : s.jellyfinLogin,
|
||||
jellyfinNewUserLogin: s.jellyfinNewUserLogin,
|
||||
jellyfinAutoImport : s.jellyfinAutoImport,
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/jellyfin/settings', (_req, res) => {
|
||||
res.json(settingsView(readSettings()))
|
||||
})
|
||||
|
||||
router.post('/jellyfin/settings', (req, res) => {
|
||||
const { jellyfinUrl, jellyfinAdminToken } = req.body ?? {}
|
||||
const current = readSettings()
|
||||
const update: Partial<Settings> = {}
|
||||
if (jellyfinUrl !== undefined) update.jellyfinUrl = String(jellyfinUrl).trim()
|
||||
if (jellyfinAdminToken !== undefined) update.jellyfinAdminToken = String(jellyfinAdminToken).trim()
|
||||
for (const key of ['jellyfinLogin', 'jellyfinNewUserLogin', 'jellyfinAutoImport'] as const) {
|
||||
if (typeof req.body?.[key] === 'boolean') update[key] = req.body[key]
|
||||
}
|
||||
if ((update.jellyfinUrl !== undefined && update.jellyfinUrl !== current.jellyfinUrl) ||
|
||||
(update.jellyfinAdminToken !== undefined && update.jellyfinAdminToken !== current.jellyfinAdminToken)) {
|
||||
update.jellyfinServerId = ''
|
||||
}
|
||||
res.json(settingsView(writeSettings(update)))
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// ── Import des utilisateurs ───────────────────────────────────
|
||||
|
||||
export type JellyfinImportResult = { created: number; linked: number; skipped: number; users: string[] }
|
||||
|
||||
function importJellyfinUsers(jellyfinUsers: JellyfinUser[]): JellyfinImportResult {
|
||||
const results: JellyfinImportResult = { created: 0, linked: 0, skipped: 0, users: [] }
|
||||
|
||||
for (const jUser of jellyfinUsers) {
|
||||
if (jUser.Policy?.IsDisabled) { results.skipped++; continue }
|
||||
const imported = importJellyfinUser(jUser)
|
||||
if (!imported || imported.action === 'existing') { results.skipped++; continue }
|
||||
results[imported.action]++
|
||||
results.users.push(imported.user.username)
|
||||
}
|
||||
|
||||
logger.info('jellyfin', `Import Jellyfin : ${results.created} compte(s) créé(s), ${results.linked} lié(s), ${results.skipped} ignoré(s) (déjà importé, désactivé ou nom déjà pris)`)
|
||||
return results
|
||||
}
|
||||
|
||||
export async function runJellyfinImport(): Promise<JellyfinImportResult> {
|
||||
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
|
||||
if (!jellyfinUrl || !jellyfinAdminToken) return { created: 0, linked: 0, skipped: 0, users: [] }
|
||||
|
||||
await getJellyfinServerId()
|
||||
return importJellyfinUsers(await fetchJellyfinUsers())
|
||||
}
|
||||
|
||||
router.get('/jellyfin/users', async (_req, res) => {
|
||||
try {
|
||||
const jellyfinUsers = await fetchJellyfinUsers()
|
||||
const users = readUsers()
|
||||
res.json(jellyfinUsers.map(j => {
|
||||
const id = normalizeJellyfinId(j.Id)
|
||||
const linked = users.find(u => u.jellyfinId === id)
|
||||
const sameName = users.find(u => u.username.toLowerCase() === j.Name.toLowerCase())
|
||||
const status = linked ? 'imported'
|
||||
: j.Policy?.IsDisabled ? 'disabled'
|
||||
: sameName?.jellyfinId ? 'conflict'
|
||||
: sameName ? 'match'
|
||||
: 'new'
|
||||
return {
|
||||
id,
|
||||
name : j.Name,
|
||||
status,
|
||||
fankarrUsername: (linked ?? sameName)?.username ?? null,
|
||||
}
|
||||
}))
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err instanceof Error ? err.message : 'Impossible de récupérer les utilisateurs Jellyfin' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/jellyfin/import', async (req, res) => {
|
||||
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map((id: unknown) => normalizeJellyfinId(String(id))) : []
|
||||
if (ids.length === 0) {
|
||||
res.status(400).json({ error: 'Aucun utilisateur sélectionné' }); return
|
||||
}
|
||||
try {
|
||||
await getJellyfinServerId()
|
||||
const selected = (await fetchJellyfinUsers()).filter(j => ids.includes(normalizeJellyfinId(j.Id)))
|
||||
res.json(importJellyfinUsers(selected))
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err instanceof Error ? err.message : 'Échec de l\'import des utilisateurs Jellyfin' })
|
||||
}
|
||||
})
|
||||
|
||||
// Import de tous les comptes actifs (assistant d'installation)
|
||||
router.post('/jellyfin/sync', async (_req, res) => {
|
||||
try {
|
||||
res.json(await runJellyfinImport())
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Échec de l\'import des utilisateurs Jellyfin' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -10,8 +10,8 @@ router.get('/nfo-updates/recent', requireAuth, (_req, res) => {
|
||||
})
|
||||
|
||||
router.post('/nfo-updates/check', requireAuth, async (_req, res) => {
|
||||
logger.info('nfo-update', 'Vérification manuelle des MAJ NFO lancée')
|
||||
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif manuelle échouée : ${err instanceof Error ? err.message : err}`))
|
||||
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' })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,35 +1,60 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { organizeTorrent } from '../organize.js'
|
||||
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
|
||||
import { recentOrganized, pushNotif } from '../lib/notifs.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)
|
||||
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) => {
|
||||
recentOrganized.length = 0
|
||||
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, name et save_path requis' }); return }
|
||||
if (!hash || !name || !save_path) { res.status(400).json({ error: 'Hash, nom et dossier du torrent requis' }); return }
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result = await organizeTorrent(hash, name, save_path, seriesData)
|
||||
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, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
||||
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', `Import manuel de "${name}" échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const router = Router()
|
||||
|
||||
const PLEX_TV_API = 'https://plex.tv/api/v2'
|
||||
|
||||
// ── Fetch avec bypass SSL pour les serveurs locaux ────────────────────────────
|
||||
// ── Serveur Plex local : certificat non vérifié ───────────────────────────────
|
||||
function getServerFetch(serverUrl: string) {
|
||||
return (url: string, opts: any = {}) =>
|
||||
undiciFetch(url, {
|
||||
@@ -16,7 +16,6 @@ function getServerFetch(serverUrl: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Fetch vers plex.tv (certificat valide, pas besoin de bypass) ──────────────
|
||||
async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
@@ -28,7 +27,10 @@ async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) throw new Error(`Plex ${res.status}: ${await res.text()}`)
|
||||
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) : {}
|
||||
}
|
||||
@@ -43,7 +45,6 @@ function mapServers(resources: any[]) {
|
||||
}))
|
||||
}
|
||||
|
||||
// POST /api/plex/connect — auth plex.tv + liste serveurs
|
||||
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 }
|
||||
@@ -56,23 +57,22 @@ router.post('/plex/connect', requireAuth, async (req, res) => {
|
||||
body : params.toString(),
|
||||
})
|
||||
const token = authData.authToken ?? authData.user?.authToken
|
||||
if (!token) throw new Error('Token non reçu')
|
||||
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', `Auth réussie pour ${username} — ${servers.length} serveur(s)`)
|
||||
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 inconnue'
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
if (msg.includes('401')) {
|
||||
res.status(401).json({ error: '2FA requis ou identifiants incorrects', requires2FA: true })
|
||||
res.status(401).json({ error: 'Identifiants incorrects, ou code de validation en deux étapes requis', requires2FA: true })
|
||||
} else {
|
||||
logger.error('plex', `Auth échouée : ${msg}`)
|
||||
res.status(401).json({ error: 'Authentification échouée — vérifiez vos identifiants' })
|
||||
logger.error('plex', `Échec de l'authentification Plex : ${msg}`)
|
||||
res.status(401).json({ error: 'Échec de l\'authentification : vérifiez vos identifiants' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/plex/oauth/start — démarre le flow OAuth
|
||||
router.post('/plex/oauth/start', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const data = await plexFetch(`${PLEX_TV_API}/pins`, {
|
||||
@@ -82,18 +82,17 @@ router.post('/plex/oauth/start', requireAuth, async (_req, res) => {
|
||||
})
|
||||
const pinId = data.id
|
||||
const pinCode = data.code
|
||||
if (!pinId || !pinCode) throw new Error('Pin non reçu')
|
||||
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', `OAuth démarré — pinId ${pinId}`)
|
||||
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'
|
||||
logger.error('plex', `OAuth start échoué : ${msg}`)
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/plex/oauth/poll/:pinId — vérifie si l'OAuth est complété
|
||||
router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
|
||||
const pinId = String(req.params.pinId)
|
||||
try {
|
||||
@@ -102,22 +101,20 @@ router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
|
||||
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', `OAuth complété — ${servers.length} serveur(s)`)
|
||||
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'
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
|
||||
res.status(500).json({ error: msg })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/plex/setup — enregistre l'agent Fankai + crée la bibliothèque
|
||||
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: 'token, serverUrl, libraryName, libraryPath requis' }); return
|
||||
res.status(400).json({ error: 'Jeton Plex, serveur, nom et dossier de la bibliothèque requis' }); return
|
||||
}
|
||||
|
||||
// Fetch avec SSL bypass pour les appels vers le serveur Plex local
|
||||
const serverFetch = getServerFetch(serverUrl)
|
||||
|
||||
const headers: Record<string, string> = { 'X-Plex-Token': token, 'Accept': 'application/json' }
|
||||
@@ -127,7 +124,7 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
|
||||
let groupId: string | null = null
|
||||
let agentSetupOk = false
|
||||
|
||||
// ── Étape 1 : Enregistrer le provider Fankai ─────────────
|
||||
// ── Étape 1 : enregistrer l'agent Fankai ─────────────────
|
||||
try {
|
||||
const providersRes = await serverFetch(`${serverUrl}/media/providers/metadata`, { headers })
|
||||
if (providersRes.ok) {
|
||||
@@ -140,7 +137,7 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
|
||||
}
|
||||
if (provider) {
|
||||
agentIdentifier = provider.identifier ?? agentIdentifier
|
||||
steps.push({ step: 'provider', ok: true, message: `Provider enregistré (${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) {
|
||||
@@ -156,22 +153,22 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
|
||||
}
|
||||
if (group) {
|
||||
groupId = String(group.id ?? '')
|
||||
steps.push({ step: 'group', ok: true, message: `Groupe d'agents créé (ID: ${groupId})` })
|
||||
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 non configuré (HTTP ${status} — Plex < 1.43 ?) — setup manuel requis` })
|
||||
logger.warn('plex', `Setup agent Fankai — HTTP ${status} depuis ${serverUrl}`)
|
||||
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'
|
||||
steps.push({ step: 'agent', ok: false, message: `Agent non configuré — setup manuel requis` })
|
||||
logger.warn('plex', `Setup agent Fankai échoué : ${msg}`)
|
||||
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 ──────────────────────
|
||||
// ── Étape 2 : créer la bibliothèque ──────────────────────
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
type : 'show',
|
||||
@@ -183,14 +180,17 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
|
||||
})
|
||||
if (groupId) params.set('metadataAgentProviderGroupId', groupId)
|
||||
const libRes = await serverFetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers })
|
||||
if (!libRes.ok) throw new Error(`HTTP ${libRes.status}: ${await libRes.text()}`)
|
||||
steps.push({ step: 'library', ok: true, message: `Bibliothèque "${libraryName}" créée` })
|
||||
logger.info('plex', `Bibliothèque "${libraryName}" créée sur ${serverUrl}`)
|
||||
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'
|
||||
steps.push({ step: 'library', ok: false, message: `Création bibliothèque échouée : ${msg}` })
|
||||
logger.error('plex', `Création bibliothèque échouée : ${msg}`)
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
|
||||
import { Router } from 'express'
|
||||
import { findByApiToken, findByJellyfinId, importJellyfinUser, safeUser } from '../users.js'
|
||||
import { validateJellyfinToken } from '../lib/jellyfin.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
|
||||
}
|
||||
|
||||
let fanUser = findByJellyfinId(info.userId!)
|
||||
if (!fanUser && readSettings().jellyfinNewUserLogin) {
|
||||
fanUser = importJellyfinUser({ Id: info.userId!, Name: info.username! })?.user
|
||||
}
|
||||
if (!fanUser) {
|
||||
logger.warn('public-api', `Connexion via Jellyfin : « ${info.username} » n'est pas encore importé dans FanKarr`)
|
||||
res.status(404).json({
|
||||
error: `Aucun compte FanKarr pour « ${info.username} ». Demandez à l'administrateur de l'importer depuis Paramètres › Jellyfin et API.`,
|
||||
}); 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
|
||||
@@ -7,13 +7,21 @@ 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, normalizeSerie, normalizeSeason, normalizeEpisode,
|
||||
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
|
||||
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()])
|
||||
@@ -21,32 +29,39 @@ router.get('/series', requireAuth, async (_req, res) => {
|
||||
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>()
|
||||
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
|
||||
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 hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false
|
||||
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) }
|
||||
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', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
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 [serieRaw, seasonsData, serieData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`), readSerieData(id)])
|
||||
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(normalizeEpisode)
|
||||
return { ...normalizeSeason(season), episodes }
|
||||
}))
|
||||
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 {}
|
||||
|
||||
@@ -56,16 +71,15 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
const integraleTorrents : any[] = []
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
|
||||
const manualOrg = organized['manual'] ?? {}
|
||||
if (serieData) {
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
// 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) {
|
||||
// Lookup rapide hash → torrent pack (intégrale ou saison)
|
||||
// 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 })
|
||||
@@ -99,7 +113,7 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
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 })
|
||||
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
|
||||
@@ -111,9 +125,6 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
// Fallback : si l'épisode n'a pas de torrent individuel,
|
||||
// on crée un torrent synthétique depuis ep.paths (fichier dans un pack).
|
||||
// On ajoute TOUTES les entrées valides (un pack par path unique).
|
||||
if (!episodeTorrentMap[ep.id] || episodeTorrentMap[ep.id].length === 0) {
|
||||
const seenPackHashes = new Set<string>()
|
||||
for (const pathEntry of (ep.paths ?? [])) {
|
||||
@@ -124,7 +135,7 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
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 })
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -133,7 +144,7 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
|
||||
const eps = season.episodes.map((ep: 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
|
||||
@@ -160,6 +171,7 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
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
|
||||
@@ -181,10 +193,10 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ serie, seasons: enrichedSeasons, 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 })) })
|
||||
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', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,25 +2,40 @@ 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 } = req.body
|
||||
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 (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)
|
||||
|
||||
@@ -5,16 +5,27 @@ 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 } from '../system.js'
|
||||
import { scanMediaPath, syncFilenameChanges } from '../organize.js'
|
||||
import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js'
|
||||
import { cacheSize } from '../lib/github-cache.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 } from '../organize.js'
|
||||
import { workerRunning, lastScan } from '../organize.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ── Logs ───────────────────────────────────────────────────────
|
||||
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'
|
||||
@@ -24,7 +35,7 @@ router.get('/logs', requireAuth, (req, res) => {
|
||||
|
||||
router.post('/logs/clear', requireAuth, (_req, res) => {
|
||||
clearLogs()
|
||||
logger.info('api', 'Logs effacés')
|
||||
logger.info('api', 'Journaux effacés')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
@@ -35,6 +46,14 @@ 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')
|
||||
@@ -45,7 +64,7 @@ router.get('/version', (_req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── Parcours fichiers ──────────────────────────────────────────
|
||||
// ── Parcours des dossiers ──────────────────────────────────────
|
||||
router.get('/browse', requireAuth, (req, res) => {
|
||||
const isWindows = process.platform === 'win32'
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
@@ -65,7 +84,7 @@ router.get('/browse', requireAuth, (req, res) => {
|
||||
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))
|
||||
|
||||
// Sur Windows, remonter à '/' quand on est à la racine d'un lecteur (ex: C:\)
|
||||
// Sous Windows, remonter à '/' depuis la racine d'un lecteur (ex. C:\)
|
||||
let parent: string | null
|
||||
if (dirPath === '/') {
|
||||
parent = null
|
||||
@@ -73,13 +92,13 @@ router.get('/browse', requireAuth, (req, res) => {
|
||||
parent = '/'
|
||||
} else {
|
||||
const up = path.dirname(dirPath)
|
||||
parent = up === dirPath ? null : up // dirname de C:\ retourne C:\ → null
|
||||
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 dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
|
||||
logger.warn('api', `Lecture du dossier « ${dirPath} » impossible : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(400).json({ error: readDirError(dirPath, err) })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -87,10 +106,8 @@ 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 {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
logger.info('api', `Dossier créé : ${dirPath}`)
|
||||
}
|
||||
// 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[]
|
||||
@@ -111,10 +128,10 @@ router.get('/browse-files', requireAuth, (req, res) => {
|
||||
}
|
||||
walk(dirPath)
|
||||
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||
res.json({ path: dirPath, files })
|
||||
res.json({ path: dirPath, files, exists: true })
|
||||
} catch (err) {
|
||||
logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
|
||||
logger.warn('api', `Lecture du dossier « ${dirPath} » impossible : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(400).json({ error: readDirError(dirPath, err) })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -122,51 +139,69 @@ router.get('/browse-files', requireAuth, (req, res) => {
|
||||
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 })
|
||||
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) => {
|
||||
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', 'Cache GitHub vidé — rechargement forcé')
|
||||
const availableIds = await readAvailable()
|
||||
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
|
||||
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
|
||||
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 loadEnrichedSeriesData()
|
||||
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json'))
|
||||
const seriesData = await loadCatalog()
|
||||
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
|
||||
if (renamed > 0)
|
||||
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
|
||||
logger.info('api', `Renommage après synchronisation : ${renamed} fichier(s) renommé(s)`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Sync noms après mise à jour échoué : ${err instanceof Error ? err.message : err}`)
|
||||
logger.warn('api', `Échec du renommage après synchronisation : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
})()
|
||||
} catch (err) {
|
||||
logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
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', 'Scan médiathèque manuel lancé')
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
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', `Scan médiathèque échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Debug ──────────────────────────────────────────────────────
|
||||
// ── Diagnostic ─────────────────────────────────────────────────
|
||||
router.get('/debug/stats', requireAuth, (req, res) => {
|
||||
const { devMode } = readSettings()
|
||||
if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
|
||||
const mem = process.memoryUsage()
|
||||
const uptimeS = Math.floor(process.uptime())
|
||||
const h = Math.floor(uptimeS / 3600)
|
||||
@@ -182,7 +217,7 @@ router.get('/debug/stats', requireAuth, (req, res) => {
|
||||
} 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: 6 },
|
||||
cache : { entries: cacheSize(), ttlHours: 1 },
|
||||
uptime : `${h}h ${m}m ${s}s`,
|
||||
uptimeSeconds: uptimeS,
|
||||
worker : { running: workerRunning },
|
||||
|
||||
@@ -18,8 +18,8 @@ router.get('/torrent-clients', requireAuth, (_req, res) => {
|
||||
|
||||
router.post('/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 }
|
||||
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)))
|
||||
})
|
||||
|
||||
@@ -31,8 +31,8 @@ router.delete('/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
|
||||
router.put('/torrent-clients/:uuid', 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 }
|
||||
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)
|
||||
@@ -51,9 +51,9 @@ router.put('/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
|
||||
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 config requis' }); return }
|
||||
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 inconnu : ${type}` }); return }
|
||||
if (!driver) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
|
||||
if (uuid) {
|
||||
const oldClient = getClient(uuid)
|
||||
if (oldClient) {
|
||||
@@ -71,7 +71,7 @@ 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: 'Driver introuvable' }); return }
|
||||
if (!driver) { res.status(400).json({ error: 'Type de client non pris en charge' }); return }
|
||||
res.json(await driver.test(client.config))
|
||||
})
|
||||
|
||||
@@ -79,7 +79,7 @@ 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: 'Driver introuvable' }); return }
|
||||
if (!driver) { res.status(400).json({ error: 'Type de client non pris en charge' }); return }
|
||||
res.json(await driver.healthcheck(client.config))
|
||||
})
|
||||
|
||||
|
||||
@@ -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,10 +1,3 @@
|
||||
/**
|
||||
* 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'
|
||||
@@ -18,25 +11,22 @@ function generateSecret(): string {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -7,23 +7,45 @@ const DATA_PATH = path.join(DATA_DIR, 'settings.json')
|
||||
export interface Settings {
|
||||
mediaPath : string
|
||||
completePath : string
|
||||
organizeMode : 'hardlink' | 'copy' | 'move' // ← ajouter 'copy'
|
||||
organizeMode : 'hardlink' | 'copy' | 'move'
|
||||
category : string
|
||||
nfoSupport : boolean
|
||||
autoImport : boolean
|
||||
devMode : boolean
|
||||
deleteTorrentOnMove : boolean // ← ajouter
|
||||
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 l'import des utilisateurs
|
||||
jellyfinLogin : boolean // Connexion à l'interface web avec un compte Jellyfin importé
|
||||
jellyfinNewUserLogin: boolean // Connexion des comptes Jellyfin pas encore importés (compte créé à la première connexion)
|
||||
jellyfinAutoImport : boolean // Import horaire de tous les utilisateurs Jellyfin actifs
|
||||
jellyfinServerId : string // Identifiant du serveur Jellyfin, comparé à chaque connexion
|
||||
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,
|
||||
autoImport : true,
|
||||
devMode : false,
|
||||
mediaPath : '',
|
||||
completePath : '',
|
||||
organizeMode : 'hardlink',
|
||||
category : 'fankai',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
devMode : false,
|
||||
deleteTorrentOnMove: false,
|
||||
requestAutoDownloadUsers: [],
|
||||
jellyfinUrl : '',
|
||||
jellyfinAdminToken : '',
|
||||
jellyfinLogin : true,
|
||||
jellyfinNewUserLogin: false,
|
||||
jellyfinAutoImport : true,
|
||||
jellyfinServerId : '',
|
||||
englishDirectory : false,
|
||||
autoUnimportMissing: false,
|
||||
onboardingStep : null,
|
||||
onboardingCompletedAt: null,
|
||||
}
|
||||
|
||||
export function readSettings(): Settings {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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
|
||||
@@ -12,4 +14,142 @@ export function systemInfo(_req: Request, res: Response): void {
|
||||
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,42 +1,45 @@
|
||||
/**
|
||||
* Torrent Client Registry
|
||||
* =======================
|
||||
* Interface commune + registry de tous les clients disponibles.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface TorrentClientDefinition {
|
||||
id : string
|
||||
label : string
|
||||
fields : FieldDef[]
|
||||
id : string
|
||||
label : string
|
||||
fields : FieldDef[]
|
||||
/** Clients sans catégories : dispatchList ne garde que les torrents ajoutés par FanKarr. */
|
||||
filterByManagedHashes?: boolean
|
||||
}
|
||||
|
||||
export interface SavedClient {
|
||||
uuid : string
|
||||
name : string
|
||||
type : string
|
||||
config: Record<string, string | number>
|
||||
config: ClientConfig
|
||||
}
|
||||
|
||||
export interface TorrentFileProgress {
|
||||
index : number
|
||||
progress: number // 0–100
|
||||
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
|
||||
}
|
||||
|
||||
export interface TorrentInfo {
|
||||
@@ -59,26 +62,62 @@ export interface TorrentInfo {
|
||||
export interface DownloadOptions {
|
||||
file_index?: number | null
|
||||
file_path ?: string | null
|
||||
infohash ?: string | null // hash direct depuis les données API (plus fiable que parser le magnet)
|
||||
magnet ?: string | null // fallback pour extraire le hash si infohash absent
|
||||
infohash ?: string | null // hash issu du catalogue (plus fiable que le magnet)
|
||||
magnet ?: string | null
|
||||
}
|
||||
|
||||
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, options?: DownloadOptions) => Promise<void>
|
||||
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
|
||||
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
|
||||
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', `Driver enregistré : ${driver.definition.label}`)
|
||||
logger.debug('torrent-clients', `Type de client enregistré : ${driver.definition.label}`)
|
||||
}
|
||||
|
||||
export function getDriver(type: string): TorrentClientDriver | undefined {
|
||||
@@ -89,9 +128,59 @@ export function getAvailableClients(): TorrentClientDefinition[] {
|
||||
return [...drivers.values()].map(d => d.definition)
|
||||
}
|
||||
|
||||
// ─── Persistence ──────────────────────────────────────────────────────────────
|
||||
// ─── Stockage ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const CLIENTS_PATH = path.join(DATA_DIR, '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 {
|
||||
@@ -111,12 +200,12 @@ 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})`)
|
||||
logger.info('torrent-clients', `Client ajouté : « ${name} » (${type})`)
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -126,7 +215,8 @@ export function removeClient(uuid: string): boolean {
|
||||
const filtered = clients.filter(c => c.uuid !== uuid)
|
||||
if (filtered.length === clients.length) return false
|
||||
saveClients(filtered)
|
||||
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
|
||||
clearManagedHashes(uuid)
|
||||
logger.info('torrent-clients', `Client supprimé : « ${target?.name} » (${target?.type})`)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -134,13 +224,13 @@ export function getClient(uuid: string): SavedClient | undefined {
|
||||
return loadClients().find(c => c.uuid === uuid)
|
||||
}
|
||||
|
||||
export function updateClient(uuid: string, name: string, type: string, config: Record<string, string | number>): SavedClient | null {
|
||||
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})`)
|
||||
logger.info('torrent-clients', `Client modifié : « ${name} » (${type})`)
|
||||
return clients[index]
|
||||
}
|
||||
|
||||
@@ -156,8 +246,8 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe
|
||||
return { ...client, config }
|
||||
}
|
||||
|
||||
// ─── Backoff par client ───────────────────────────────────────────────────────
|
||||
// Si un client échoue à l'auth, on le met en cooldown pour éviter le ban IP
|
||||
// ─── Pause après échecs de connexion ──────────────────────────────────────────
|
||||
// Client refusant l'authentification mis en pause pour éviter un bannissement d'IP
|
||||
|
||||
interface BackoffEntry {
|
||||
failCount : number
|
||||
@@ -166,7 +256,6 @@ interface BackoffEntry {
|
||||
|
||||
const _backoff = new Map<string, BackoffEntry>()
|
||||
|
||||
// Délais progressifs : 1min, 5min, 15min, 30min, 60min (plafonné)
|
||||
const BACKOFF_DELAYS = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
|
||||
|
||||
function isInCooldown(uuid: string): boolean {
|
||||
@@ -180,7 +269,7 @@ function cooldownRemaining(uuid: string): string {
|
||||
if (!entry) return ''
|
||||
const remaining = Math.max(0, entry.nextRetry - Date.now())
|
||||
const m = Math.ceil(remaining / 60_000)
|
||||
return m > 0 ? `${m}min` : ''
|
||||
return m > 0 ? `${m} min` : ''
|
||||
}
|
||||
|
||||
function registerFailure(uuid: string, clientName: string) {
|
||||
@@ -190,14 +279,14 @@ function registerFailure(uuid: string, clientName: string) {
|
||||
entry.nextRetry = Date.now() + delay
|
||||
_backoff.set(uuid, entry)
|
||||
const min = Math.round(delay / 60_000)
|
||||
logger.warn('torrent-clients', `Client "${clientName}" en cooldown ${min}min après ${entry.failCount} échec(s)`)
|
||||
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 de status
|
||||
// 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()) {
|
||||
@@ -206,11 +295,9 @@ export function getClientCooldowns(): Record<string, { failCount: number; nextRe
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Remote path remapping ────────────────────────────────────────────────────
|
||||
// ─── Correspondance des chemins ───────────────────────────────────────────────
|
||||
|
||||
// FIX : remplace le préfixe remotePath par localPath dans save_path
|
||||
// ex: remotePath=/downloads, localPath=/mnt/nas/downloads
|
||||
// /downloads/fankai/Serie → /mnt/nas/downloads/fankai/Serie
|
||||
// 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(/\/+$/, '')
|
||||
@@ -219,13 +306,13 @@ function remapSavePath(savePath: string, remotePath: string, localPath: string):
|
||||
return local + savePath.slice(remote.length)
|
||||
}
|
||||
|
||||
// ─── Dispatch download ─────────────────────────────────────────────────────────
|
||||
// ─── 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 configuré — téléchargement impossible')
|
||||
logger.warn('torrent-clients', 'Aucun client torrent configuré : téléchargement impossible')
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -234,17 +321,21 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
|
||||
for (const client of clients) {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) {
|
||||
logger.error('torrent-clients', `Driver "${client.type}" introuvable pour le client "${client.name}"`)
|
||||
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, options)
|
||||
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
|
||||
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) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('torrent-clients', `Échec envoi vers "${client.name}" : ${msg}`)
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -252,7 +343,7 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
|
||||
return results
|
||||
}
|
||||
|
||||
// ─── Dispatch remove ───────────────────────────────────────────────────────────
|
||||
// ─── Suppression ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchRemove(hash: string, deleteFiles = false): Promise<{ ok: boolean; error?: string }> {
|
||||
const clients = loadClients()
|
||||
@@ -262,17 +353,18 @@ export async function dispatchRemove(hash: string, deleteFiles = false): Promise
|
||||
if (!driver) continue
|
||||
try {
|
||||
await driver.remove(client.config, hash, deleteFiles)
|
||||
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
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 inconnue'
|
||||
logger.warn('torrent-clients', `Impossible de supprimer le torrent de "${client.name}" : ${msg}`)
|
||||
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' }
|
||||
return found ? { ok: true } : { ok: false, error: 'Torrent introuvable dans les clients torrent' }
|
||||
}
|
||||
|
||||
// Map inversée : titre normalisé → infohash (pour matcher les torrents Synology)
|
||||
// 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)) {
|
||||
@@ -295,7 +387,7 @@ export async function dispatchList(
|
||||
|
||||
if (isInCooldown(client.uuid)) {
|
||||
const remaining = cooldownRemaining(client.uuid)
|
||||
logger.debug('torrent-clients', `Client "${client.name}" en cooldown — retry dans ${remaining}`)
|
||||
logger.debug('torrent-clients', `Client « ${client.name} » en pause, nouvel essai dans ${remaining}`)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -314,31 +406,53 @@ export async function dispatchList(
|
||||
? remapSavePath(t.save_path, remotePath, localPath)
|
||||
: t.save_path
|
||||
|
||||
// Pour Synology (hash = dbid_xxx), on essaie de résoudre le vrai infohash
|
||||
// en matchant le titre du torrent dans la infohashMap
|
||||
// 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', `Synology hash résolu : "${t.name}" → ${resolved.slice(0, 8)}…`)
|
||||
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) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('torrent-clients', `Impossible de récupérer la liste depuis "${client.name}" : ${msg}`)
|
||||
const isAuthError = msg.toLowerCase().includes('login') ||
|
||||
msg.toLowerCase().includes('auth') ||
|
||||
msg.toLowerCase().includes('ban') ||
|
||||
msg.toLowerCase().includes('forbidden') ||
|
||||
msg.toLowerCase().includes('401') ||
|
||||
msg.toLowerCase().includes('403')
|
||||
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,10 +1,19 @@
|
||||
/**
|
||||
* qBittorrent Driver
|
||||
*/
|
||||
/** qBittorrent 4.x et 5.x (clé API à partir de 5.2.0). */
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } 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
|
||||
}
|
||||
|
||||
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'
|
||||
@@ -14,30 +23,38 @@ function mapState(state: string): TorrentInfo['state'] {
|
||||
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: Record<string, string | number>, sid: string, hash: string): Promise<boolean> {
|
||||
async function qbTorrentExists(config: ClientConfig, authHeaders: Record<string, string>, hash: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/info?hashes=${hash}`, {
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
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()
|
||||
@@ -47,99 +64,65 @@ async function qbTorrentExists(config: Record<string, string | number>, sid: str
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
|
||||
* applique les priorités, puis reprend le torrent.
|
||||
*/
|
||||
async function qbApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
resume : boolean,
|
||||
): Promise<void> {
|
||||
// 60 tentatives × 500 ms = 30 s max
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
|
||||
try {
|
||||
const sid = await qbLogin(config)
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
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(`Index fichier ${fileIndex} hors limites (torrent a ${files.length} fichiers)`)
|
||||
throw new Error(`Fichier n° ${fileIndex} hors limites : le torrent contient ${files.length} fichiers`)
|
||||
}
|
||||
|
||||
// Si tous les fichiers sont à priorité normale (état initial du torrent),
|
||||
// on applique le filtrage complet : on désactive tout sauf la cible.
|
||||
// Si certains sont déjà à 0 (filtrage déjà effectué pour un épisode précédent),
|
||||
// on se contente d'activer la nouvelle cible sans toucher aux autres.
|
||||
// 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 = { Cookie: `SID=${sid}`, 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
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 fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body, headers })
|
||||
if (!r.ok) { logger.warn('qbittorrent', `filePrio désactivation échouée (${r.status}) — retry`); continue }
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
// Activer le fichier cible (dans tous les cas)
|
||||
const r2 = await fetch(`${config.url}/api/v2/torrents/filePrio`, {
|
||||
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', `filePrio activation échouée (${r2.status}) — retry`); continue }
|
||||
if (!r2.ok) { logger.warn('qbittorrent', `Priorité du fichier cible non appliquée (HTTP ${r2.status}), nouvel essai`); continue }
|
||||
|
||||
// Vérification : le fichier cible est bien à priorité > 0
|
||||
const verif = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: { Cookie: `SID=${sid}` } })
|
||||
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 ${fileIndex} toujours à priorité 0 après application — retry`)
|
||||
logger.warn('qbittorrent', `Fichier n° ${fileIndex} toujours à priorité 0 après application, nouvel essai`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Reprendre (torrent mis en pause via paused/stopped à l'ajout)
|
||||
// On appelle les deux endpoints : resume (qBit ≤ 4.x) et start (qBit ≥ 5.0)
|
||||
if (resume) {
|
||||
const resumeBody = new URLSearchParams({ hashes: hash })
|
||||
await Promise.allSettled([
|
||||
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeBody, headers }),
|
||||
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body: resumeBody, headers }),
|
||||
])
|
||||
}
|
||||
|
||||
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné${resume ? ' + reprise' : ''} pour ${hash.slice(0, 8)}…`)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
|
||||
if (resume) {
|
||||
try {
|
||||
const sid = await qbLogin(config)
|
||||
const headers = { Cookie: `SID=${sid}`, 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
const body = new URLSearchParams({ hashes: hash })
|
||||
await Promise.allSettled([
|
||||
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body, headers }),
|
||||
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body, headers }),
|
||||
])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
|
||||
throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
|
||||
}
|
||||
|
||||
const QB: TorrentClientDriver = {
|
||||
@@ -147,60 +130,64 @@ 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: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ 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) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.warn('qbittorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||
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 }
|
||||
const version = (await res.text()).trim()
|
||||
logger.debug('qbittorrent', `Healthcheck OK — version ${version}`)
|
||||
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', `Healthcheck échoué : ${err instanceof Error ? err.message : 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()
|
||||
|
||||
// Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
|
||||
const fileMap = new Map<string, any[]>()
|
||||
await Promise.all(data.map(async (t: any) => {
|
||||
try {
|
||||
const r = await fetch(`${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
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 {}
|
||||
@@ -223,16 +210,35 @@ const QB: TorrentClientDriver = {
|
||||
save_path : t.save_path,
|
||||
category : t.category ?? '',
|
||||
files : fileInfo.length > 0
|
||||
? fileInfo.map((f: any, i: number) => ({ index: i, progress: Math.round((f.progress ?? 0) * 100) }))
|
||||
? 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, options?: DownloadOptions) {
|
||||
const sid = await qbLogin(config)
|
||||
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)
|
||||
|
||||
// Priorité : infohash direct (depuis les données API) > parsing du magnet/URL
|
||||
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()
|
||||
@@ -240,96 +246,104 @@ const QB: TorrentClientDriver = {
|
||||
|
||||
if (options?.file_index != null) {
|
||||
if (hash) {
|
||||
const exists = await qbTorrentExists(config, sid, hash)
|
||||
const exists = await qbTorrentExists(config, authH, hash)
|
||||
if (exists) {
|
||||
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
|
||||
qbApplyFilePriority(config, hash, options.file_index, false).catch(err =>
|
||||
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot des hashes avant ajout (pour retrouver le nouveau si hash inconnu)
|
||||
// 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 fetch(`${config.url}/api/v2/torrents/info`, { headers: { Cookie: `SID=${sid}` } })
|
||||
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 {}
|
||||
}
|
||||
|
||||
// Ajout du torrent en pause immédiate (toutes versions qBit)
|
||||
// paused=true → qBit < 5.0
|
||||
// stopped=true → qBit ≥ 5.0
|
||||
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))
|
||||
form.append('paused', 'true')
|
||||
form.append('stopped', 'true')
|
||||
// .torrent récupéré par FanKarr : le client n'a pas forcément accès à Internet
|
||||
const isMagnet = url.startsWith('magnet:')
|
||||
let res: Response
|
||||
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
|
||||
})
|
||||
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 (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||
if (!isQbAddSuccess(text)) throw new Error(`qBittorrent a refusé le torrent : ${text}`)
|
||||
|
||||
if (hash) {
|
||||
logger.info('qbittorrent', `Torrent ajouté (en pause, sélection fichier ${options.file_index} en attente)`)
|
||||
qbApplyFilePriority(config, hash, options.file_index, true).catch(err =>
|
||||
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
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 {
|
||||
// Hash inconnu (pas d'infohash, pas de magnet) → retrouve le nouveau torrent par diff de liste
|
||||
logger.info('qbittorrent', `Torrent ajouté sans hash connu — recherche dans la liste (sélection fichier ${options.file_index})`)
|
||||
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 s = await qbLogin(config)
|
||||
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: { Cookie: `SID=${s}` } })
|
||||
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)}… — application priorité fichier ${fileIndex}`)
|
||||
await qbApplyFilePriority(config, resolvedHash, fileIndex, true)
|
||||
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', `Impossible de résoudre le hash pour appliquer la priorité fichier ${fileIndex}`)
|
||||
logger.warn('qbittorrent', `Hash introuvable : impossible de sélectionner le fichier n° ${fileIndex}`)
|
||||
})().catch(() => {})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Pas de sélection de fichier → ajout normal
|
||||
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 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 fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
|
||||
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}`)
|
||||
logger.info('qbittorrent', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
|
||||
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 sid = await qbLogin(config)
|
||||
const form = new FormData()
|
||||
form.append('hashes', hash)
|
||||
form.append('deleteFiles', deleteFiles ? 'true' : 'false')
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
|
||||
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 : form,
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
body : params,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
|
||||
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)' : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1,14 +1,10 @@
|
||||
/**
|
||||
* rTorrent Driver
|
||||
* ===============
|
||||
* Communique via XML-RPC (HTTP POST).
|
||||
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
|
||||
*/
|
||||
/** rTorrent via XML-RPC (ruTorrent ou rTorrent seul, sur /RPC2 ou /XMLRPC). */
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// ─── XML-RPC helpers ──────────────────────────────────────────
|
||||
// ─── XML-RPC ──────────────────────────────────────────────────
|
||||
|
||||
function xmlValue(val: any): string {
|
||||
if (typeof val === 'string') return `<value><string>${val}</string></value>`
|
||||
@@ -55,7 +51,7 @@ function parseXmlValue(node: Element): any {
|
||||
}
|
||||
|
||||
async function rpcCall(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
method : string,
|
||||
params : any[] = [],
|
||||
): Promise<any> {
|
||||
@@ -69,22 +65,21 @@ async function rpcCall(
|
||||
const rpcPath = String(config.rpcPath || '/RPC2')
|
||||
const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}`
|
||||
|
||||
const res = await fetch(url, { method: 'POST', headers, body })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
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()
|
||||
|
||||
// Parse XML — Node.js n'a pas DOMParser natif, on fait un parse minimaliste
|
||||
// Pas de DOMParser sous Node : analyse XML minimale
|
||||
const faultMatch = text.match(/<name>faultString<\/name>\s*<value><string>([^<]*)<\/string>/)
|
||||
if (faultMatch) throw new Error(`rTorrent fault : ${faultMatch[1]}`)
|
||||
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
|
||||
|
||||
// Parse simplifié pour les types courants
|
||||
// Types courants uniquement
|
||||
const inner = valueMatch[1].trim()
|
||||
|
||||
// Array
|
||||
if (inner.startsWith('<array>')) {
|
||||
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
|
||||
return items.map(m => parseInnerValue(m[1].trim()))
|
||||
@@ -107,9 +102,8 @@ function parseInnerValue(inner: string): any {
|
||||
return inner
|
||||
}
|
||||
|
||||
// multicall pour récupérer plusieurs champs en une requête
|
||||
async function d_multicall(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
view : string,
|
||||
methods: string[],
|
||||
): Promise<any[][]> {
|
||||
@@ -118,7 +112,7 @@ async function d_multicall(
|
||||
return Array.isArray(result) ? result : []
|
||||
}
|
||||
|
||||
async function rtTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
|
||||
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())
|
||||
@@ -127,28 +121,20 @@ async function rtTorrentExists(config: Record<string, string | number>, hash: st
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend les métadonnées via polling rapide (300 ms), applique les priorités.
|
||||
* Le torrent est ajouté avec load.start — il fetch les métadonnées activement via DHT/peers.
|
||||
* Pendant le fetch (état "métadonnées en cours"), aucun byte de fichier n'est téléchargé.
|
||||
* On set les priorités dès que les fichiers sont disponibles, avant que le download réel commence.
|
||||
*/
|
||||
/** Attend les métadonnées puis applique les priorités, avant le début du téléchargement. */
|
||||
async function rtApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
// 100 tentatives × 300 ms = 30 s max
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
try {
|
||||
// Récupérer taille ET priorité courante de chaque fichier
|
||||
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
|
||||
|
||||
// Si tous les fichiers sont à priorité normale (état initial),
|
||||
// on filtre tout sauf la cible. Sinon on active juste la cible.
|
||||
// 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++) {
|
||||
@@ -157,20 +143,17 @@ async function rtApplyFilePriority(
|
||||
} else if (isInitialState) {
|
||||
await rpcCall(config, 'f.priority.set', [hash, i, 0])
|
||||
}
|
||||
// Si pas initial state : on ne touche pas aux autres fichiers
|
||||
}
|
||||
|
||||
logger.info('rtorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
logger.info('rtorrent', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
|
||||
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
|
||||
throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
|
||||
}
|
||||
|
||||
// ─── State mapping ────────────────────────────────────────────
|
||||
// rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1)
|
||||
// + is_open (0/1)
|
||||
// ─── États ────────────────────────────────────────────────────
|
||||
function mapState(isOpen: number, isActive: number, isChecking: number, isComplete: number): TorrentInfo['state'] {
|
||||
if (isChecking) return 'checking'
|
||||
if (!isOpen) return 'paused'
|
||||
@@ -186,23 +169,24 @@ const RT: TorrentClientDriver = {
|
||||
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: 'Identifiant', type: 'text', placeholder: 'admin', 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 (label)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ 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}`)
|
||||
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 inconnue'
|
||||
logger.warn('rtorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||
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 }
|
||||
}
|
||||
},
|
||||
@@ -210,10 +194,10 @@ const RT: TorrentClientDriver = {
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const version = await rpcCall(config, 'system.client_version')
|
||||
logger.debug('rtorrent', `Healthcheck OK — version ${version}`)
|
||||
logger.debug('rtorrent', `Client en ligne (version ${version})`)
|
||||
return { online: true, version: String(version) }
|
||||
} catch (err) {
|
||||
logger.debug('rtorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
|
||||
logger.debug('rtorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
@@ -228,8 +212,8 @@ const RT: TorrentClientDriver = {
|
||||
'd.complete',
|
||||
'd.size_bytes',
|
||||
'd.bytes_done',
|
||||
'd.up.total', // total uploadé
|
||||
'd.ratio', // ratio x1000
|
||||
'd.up.total',
|
||||
'd.ratio', // ratio × 1000
|
||||
'd.down.rate',
|
||||
'd.up.rate',
|
||||
'd.left_bytes',
|
||||
@@ -239,12 +223,11 @@ const RT: TorrentClientDriver = {
|
||||
|
||||
const rows = await d_multicall(config, 'main', methods)
|
||||
|
||||
// Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
|
||||
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='])
|
||||
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 {}
|
||||
}))
|
||||
@@ -277,7 +260,9 @@ const RT: TorrentClientDriver = {
|
||||
files : fileInfo.length > 0
|
||||
? fileInfo.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
progress: Number(f[1]) > 0 ? Math.round((Number(f[0]) / Number(f[1])) * 100) : 0,
|
||||
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
|
||||
@@ -297,42 +282,37 @@ const RT: TorrentClientDriver = {
|
||||
const exists = await rtTorrentExists(config, hash)
|
||||
|
||||
if (exists) {
|
||||
// Torrent déjà présent → mettre à jour la priorité directement (async OK)
|
||||
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
|
||||
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é fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
logger.warn('rtorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Nouveau torrent → load.start (fetch métadonnées via DHT/peers actif)
|
||||
// Pendant le fetch des métadonnées, aucun byte de fichier n'est téléchargé.
|
||||
// On poll à 300 ms et on set les priorités dès que les fichiers sont disponibles.
|
||||
// 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é (sélection fichier ${options.file_index} en attente de métadonnées)`)
|
||||
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é fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
logger.warn('rtorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Pas de sélection de fichier → ajout et démarrage normal
|
||||
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é avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
|
||||
logger.info('rtorrent', `Torrent ajouté${config.savePath ? ` (dossier : ${config.savePath})` : ''}`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
const h = hash.toUpperCase()
|
||||
if (deleteFiles) {
|
||||
// Supprimer les fichiers puis effacer
|
||||
await rpcCall(config, 'd.delete_tied', [h])
|
||||
await rpcCall(config, 'd.erase', [h])
|
||||
} else {
|
||||
|
||||
@@ -1,23 +1,106 @@
|
||||
/**
|
||||
* Synology Download Station Driver
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Synology Download Station task status codes
|
||||
function mapState(status: string): TorrentInfo['state'] {
|
||||
if (status === 'downloading') return 'downloading'
|
||||
if (status === 'seeding' || status === 'finished') 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'
|
||||
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 DSM — retourne un SID
|
||||
async function dsLogin(config: Record<string, string | number>): Promise<string> {
|
||||
// ─── 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',
|
||||
@@ -27,16 +110,15 @@ async function dsLogin(config: Record<string, string | number>): Promise<string>
|
||||
session: 'DownloadStation',
|
||||
format : 'sid',
|
||||
})
|
||||
|
||||
const res = await fetch(`${config.url}/webapi/auth.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
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(`Auth échouée : ${JSON.stringify(data.error)}`)
|
||||
if (!data.success) throw new Error(`Connexion refusée par Synology : ${dsErrorText(data.error, AUTH_ERRORS)}`)
|
||||
return data.data.sid
|
||||
}
|
||||
|
||||
async function dsRequest(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
api : string,
|
||||
method : string,
|
||||
version: string,
|
||||
@@ -44,25 +126,29 @@ async function dsRequest(
|
||||
sid : string,
|
||||
): Promise<any> {
|
||||
const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra })
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
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(`DS erreur : ${JSON.stringify(data.error)}`)
|
||||
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',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', 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: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -72,8 +158,8 @@ const DS: TorrentClientDriver = {
|
||||
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 inconnue'
|
||||
logger.warn('synology-ds', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||
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 }
|
||||
}
|
||||
},
|
||||
@@ -87,15 +173,13 @@ const DS: TorrentClientDriver = {
|
||||
method : 'getinfo',
|
||||
_sid : sid,
|
||||
})
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/info.cgi?${params}`)
|
||||
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'
|
||||
logger.debug('synology-ds', `Healthcheck OK — version ${version}`)
|
||||
return { online: true, version }
|
||||
} catch (err) {
|
||||
logger.debug('synology-ds', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
|
||||
} catch {
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
@@ -103,76 +187,191 @@ const DS: TorrentClientDriver = {
|
||||
async list(config, category) {
|
||||
const sid = await dsLogin(config)
|
||||
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
|
||||
{ additional: 'transfer,detail' }, sid)
|
||||
{ additional: 'transfer,detail,file' }, sid)
|
||||
|
||||
const tasks: any[] = data?.tasks ?? []
|
||||
|
||||
return tasks
|
||||
.map(t => {
|
||||
const transfer = t.additional?.transfer ?? {}
|
||||
const size = t.size ?? 0
|
||||
const dl = transfer.size_downloaded ?? 0
|
||||
return {
|
||||
hash : t.id,
|
||||
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 : t.additional?.detail?.destination ?? '',
|
||||
category : category ?? '',
|
||||
} satisfies TorrentInfo
|
||||
// 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', 'Sélection de fichier non supportée — téléchargement complet')
|
||||
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)
|
||||
|
||||
const extra: Record<string, string> = { uri: url }
|
||||
if (config.savePath) extra.destination = String(config.savePath)
|
||||
// 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 params = new URLSearchParams({
|
||||
api : 'SYNO.DownloadStation.Task',
|
||||
version: '1',
|
||||
method : 'create',
|
||||
_sid : sid,
|
||||
...extra,
|
||||
})
|
||||
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()
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
|
||||
let data = await doAdd(true)
|
||||
|
||||
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
|
||||
// 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 remove(config, hash, deleteFiles = false) {
|
||||
const sid = await dsLogin(config)
|
||||
// Synology utilise son ID interne — on cherche via la liste
|
||||
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 ?? []
|
||||
// Matcher par hash si résolu, sinon par id
|
||||
const found = tasks.find(t => t.id?.toLowerCase() === hash.toLowerCase())
|
||||
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: deleteFiles ? 'false' : 'false',
|
||||
_sid : sid,
|
||||
|
||||
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)
|
||||
})
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
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
|
||||
export default DS
|
||||
@@ -1,14 +1,10 @@
|
||||
/**
|
||||
* Transmission Driver
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
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'] {
|
||||
// Transmission status codes :
|
||||
// 0 = stopped, 1 = check queued, 2 = checking, 3 = download queued,
|
||||
// 4 = downloading, 5 = seed queued, 6 = seeding
|
||||
// 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'
|
||||
@@ -17,7 +13,7 @@ function mapState(status: number): TorrentInfo['state'] {
|
||||
}
|
||||
|
||||
async function trRequest(
|
||||
config: Record<string, string | number>,
|
||||
config: ClientConfig,
|
||||
method: string,
|
||||
args: Record<string, unknown> = {},
|
||||
sessionId = ''
|
||||
@@ -31,39 +27,35 @@ async function trRequest(
|
||||
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.url}/transmission/rpc`, {
|
||||
const res = await clientFetch(config, `${config.url}/transmission/rpc`, {
|
||||
method : 'POST',
|
||||
headers,
|
||||
body : JSON.stringify({ method, arguments: args }),
|
||||
})
|
||||
|
||||
// Transmission renvoie 409 avec le session ID à la première requête
|
||||
// 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('Session ID introuvable dans la réponse 409')
|
||||
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 HTTP ${res.status}`)
|
||||
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(`Transmission erreur : ${data.result}`)
|
||||
if (data.result !== 'success') throw new Error(`Erreur Transmission : ${data.result}`)
|
||||
|
||||
return { ...data, sessionId }
|
||||
}
|
||||
|
||||
async function trGetIdByHash(config: Record<string, string | number>, hash: string): Promise<number | null> {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend que les métadonnées soient disponibles (polling 500 ms),
|
||||
* puis applique les priorités : fichier cible voulu, tous les autres ignorés.
|
||||
*/
|
||||
async function trApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
@@ -83,11 +75,11 @@ async function trApplyFilePriority(
|
||||
'files-wanted' : [fileIndex],
|
||||
'files-unwanted' : unwanted,
|
||||
})
|
||||
logger.info('transmission', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
logger.info('transmission', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
|
||||
throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
|
||||
}
|
||||
|
||||
const TR: TorrentClientDriver = {
|
||||
@@ -96,12 +88,13 @@ const TR: TorrentClientDriver = {
|
||||
label : 'Transmission',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'transmission', required: false },
|
||||
{ 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 cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -111,8 +104,8 @@ const TR: TorrentClientDriver = {
|
||||
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 inconnue'
|
||||
logger.warn('transmission', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||
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 }
|
||||
}
|
||||
},
|
||||
@@ -121,10 +114,10 @@ const TR: TorrentClientDriver = {
|
||||
try {
|
||||
const data = await trRequest(config, 'session-get')
|
||||
const version = data.arguments?.version ?? 'inconnue'
|
||||
logger.debug('transmission', `Healthcheck OK — version ${version}`)
|
||||
logger.debug('transmission', `Client en ligne (version ${version})`)
|
||||
return { online: true, version }
|
||||
} catch (err) {
|
||||
logger.debug('transmission', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
|
||||
logger.debug('transmission', `Client injoignable : ${err instanceof Error ? err.message : err}`)
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
@@ -134,7 +127,7 @@ const TR: TorrentClientDriver = {
|
||||
'hashString', 'name', 'status', 'percentDone', 'totalSize',
|
||||
'downloadedEver', 'uploadedEver', 'uploadRatio',
|
||||
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
|
||||
'files', // bytesCompleted + length par fichier (fileStats n'a pas 'length')
|
||||
'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 ?? []
|
||||
@@ -161,7 +154,8 @@ const TR: TorrentClientDriver = {
|
||||
files : Array.isArray(t.files) && t.files.length > 0
|
||||
? t.files.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
progress: f.length > 0 ? Math.round((f.bytesCompleted / f.length) * 100) : 0,
|
||||
name : String(f.name ?? ''),
|
||||
progress: f.length > 0 ? f.bytesCompleted / f.length : 0,
|
||||
}))
|
||||
: undefined,
|
||||
} satisfies TorrentInfo))
|
||||
@@ -181,21 +175,21 @@ const TR: TorrentClientDriver = {
|
||||
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, mise à jour priorité fichier ${options.file_index}`)
|
||||
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é (sélection fichier ${options.file_index} en attente de métadonnées)`)
|
||||
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é fichier non appliquée : ${err instanceof Error ? err.message : 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}` : ''})`)
|
||||
logger.info('transmission', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
// Transmission identifie les torrents par ID numérique — on cherche via la liste
|
||||
// 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())
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
/**
|
||||
* uTorrent Driver
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
|
||||
import { clientFetch } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// uTorrent status flags (bitmask) :
|
||||
// 1 = Started, 2 = Checking, 4 = Start after check, 8 = Checked,
|
||||
// 16 = Error, 32 = Paused, 64 = Queued, 128 = Loaded
|
||||
// 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é + chargé + vérifié → on regarde le ratio de progression
|
||||
// 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' // queued
|
||||
if (status & 64) return 'downloading'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
// uTorrent nécessite un token CSRF + cookie à chaque session
|
||||
interface UTSession { token: string; cookie: string }
|
||||
|
||||
async function utTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
|
||||
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)
|
||||
@@ -30,19 +25,15 @@ async function utTorrentExists(config: Record<string, string | number>, hash: st
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend que les métadonnées soient disponibles (polling), puis applique les priorités :
|
||||
* tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal).
|
||||
*/
|
||||
/** Attend la liste des fichiers puis les passe à 0, sauf la cible à 2 (normale). */
|
||||
async function utApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
const HASH = hash.toUpperCase()
|
||||
|
||||
// 100 tentatives × 500 ms = 50 s max
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
@@ -51,38 +42,37 @@ async function utApplyFilePriority(
|
||||
const files = data?.files?.[1] as any[][] | undefined
|
||||
if (!Array.isArray(files) || files.length === 0) continue
|
||||
|
||||
// Passer tous les fichiers à priorité 0 (skip)
|
||||
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 fetch(`${config.url}/gui/?${skipQs}`, {
|
||||
await clientFetch(config, `${config.url}/gui/?${skipQs}`, {
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
|
||||
// Activer le fichier cible (priorité 2 = normal)
|
||||
const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) })
|
||||
await fetch(`${config.url}/gui/?${selectQs}`, {
|
||||
await clientFetch(config, `${config.url}/gui/?${selectQs}`, {
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
|
||||
logger.info('utorrent', `Fichier ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`)
|
||||
logger.info('utorrent', `Fichier n° ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(`Timeout : fichiers non disponibles pour ${hash.slice(0, 8)}…`)
|
||||
throw new Error(`Délai dépassé : fichiers du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
|
||||
}
|
||||
|
||||
async function utGetSession(config: Record<string, string | number>): Promise<UTSession> {
|
||||
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 fetch(`${config.url}/gui/token.html`, { headers })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} — token introuvable`)
|
||||
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('Token CSRF introuvable dans la réponse')
|
||||
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') ?? ''
|
||||
|
||||
@@ -90,28 +80,25 @@ async function utGetSession(config: Record<string, string | number>): Promise<UT
|
||||
}
|
||||
|
||||
async function utRequest(
|
||||
config : Record<string, string | number>,
|
||||
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 fetch(`${config.url}/gui/?${qs}`, {
|
||||
const res = await clientFetch(config, `${config.url}/gui/?${qs}`, {
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Cookie' : session.cookie,
|
||||
},
|
||||
})
|
||||
if (!res.ok) throw new Error(`uTorrent HTTP ${res.status}`)
|
||||
if (!res.ok) throw new Error(`uTorrent a répondu HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function mapTorrent(t: any[]): TorrentInfo {
|
||||
// uTorrent retourne un tableau par torrent :
|
||||
// [hash, status, name, size, progress, downloaded, uploaded, ratio,
|
||||
// ul_speed, dl_speed, eta, label, peers_connected, peers_swarm,
|
||||
// seeds_connected, seeds_swarm, availability, queue_order, remaining]
|
||||
// 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(),
|
||||
@@ -121,7 +108,7 @@ function mapTorrent(t: any[]): TorrentInfo {
|
||||
size,
|
||||
downloaded,
|
||||
uploaded : uploaded ?? 0,
|
||||
ratio : Math.round(((ratio ?? 0) / 1000) * 100) / 100, // uTorrent ratio est x1000
|
||||
ratio : Math.round(((ratio ?? 0) / 1000) * 100) / 100, // ratio × 1000
|
||||
speed : dlSpeed ?? 0,
|
||||
upspeed : ulSpeed ?? 0,
|
||||
eta : eta ?? -1,
|
||||
@@ -135,13 +122,14 @@ const UT: TorrentClientDriver = {
|
||||
id : 'utorrent',
|
||||
label : 'uTorrent',
|
||||
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: '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 cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -151,8 +139,8 @@ const UT: TorrentClientDriver = {
|
||||
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 inconnue'
|
||||
logger.warn('utorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||
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 }
|
||||
}
|
||||
},
|
||||
@@ -162,10 +150,10 @@ const UT: TorrentClientDriver = {
|
||||
const session = await utGetSession(config)
|
||||
const data = await utRequest(config, { action: 'getsettings' }, session)
|
||||
const version = data?.['build']?.toString() ?? 'inconnue'
|
||||
logger.debug('utorrent', `Healthcheck OK — build ${version}`)
|
||||
logger.debug('utorrent', `Client en ligne (build ${version})`)
|
||||
return { online: true, version }
|
||||
} catch (err) {
|
||||
logger.debug('utorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
|
||||
logger.debug('utorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
@@ -183,7 +171,6 @@ const UT: TorrentClientDriver = {
|
||||
.map(t => {
|
||||
const info = mapTorrent(t)
|
||||
|
||||
// Récupérer le save_path depuis les properties si disponible
|
||||
const props = data?.props?.find((p: any) => p[0]?.toLowerCase() === info.hash)
|
||||
if (props) info.save_path = props[1] ?? ''
|
||||
|
||||
@@ -199,9 +186,9 @@ const UT: TorrentClientDriver = {
|
||||
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, mise à jour priorité fichier ${options.file_index}`)
|
||||
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é fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
logger.warn('utorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -213,22 +200,21 @@ const UT: TorrentClientDriver = {
|
||||
const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url })
|
||||
if (config.savePath) qs.set('path', String(config.savePath))
|
||||
|
||||
const res = await fetch(`${config.url}/gui/?${qs}`, {
|
||||
const res = await clientFetch(config, `${config.url}/gui/?${qs}`, {
|
||||
method : 'GET',
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`)
|
||||
if (!res.ok) throw new Error(`Échec de l'ajout (HTTP ${res.status})`)
|
||||
|
||||
if (options?.file_index != null && hash) {
|
||||
logger.info('utorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
|
||||
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é fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
logger.warn('utorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
} else {
|
||||
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
|
||||
logger.info('utorrent', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
|
||||
}
|
||||
|
||||
// Appliquer le label/catégorie si défini
|
||||
if (config.category) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
|
||||
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'
|
||||
import { normalizeJellyfinId } from './lib/jellyfin.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
|
||||
jellyfinId? : 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 findByJellyfinId(id: string): User | undefined {
|
||||
const jellyfinId = normalizeJellyfinId(id)
|
||||
return readUsers().find(u => !!u.jellyfinId && u.jellyfinId === jellyfinId)
|
||||
}
|
||||
|
||||
export function safeUser(user: User) {
|
||||
const { passwordHash, ...safe } = user
|
||||
return { ...safe, hasPassword: !!passwordHash }
|
||||
}
|
||||
|
||||
// ── Gestion des comptes ───────────────────────────────────────
|
||||
|
||||
// Mot de passe vide : compte sans mot de passe FanKarr (import Jellyfin)
|
||||
export function createUser(username: string, password: string, role: UserRole = 'user', jellyfinId: string | null = null): 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: password ? bcrypt.hashSync(password, SALT_ROUNDS) : '',
|
||||
role,
|
||||
apiToken : crypto.randomBytes(32).toString('hex'),
|
||||
createdAt : new Date().toISOString(),
|
||||
tourSeenAt : null,
|
||||
jellyfinId : jellyfinId ? normalizeJellyfinId(jellyfinId) : null,
|
||||
}
|
||||
writeUsers([...users, user])
|
||||
logger.info('users', `Utilisateur « ${username} » créé (rôle : ${role})`)
|
||||
return user
|
||||
}
|
||||
|
||||
export type JellyfinImportAction = 'existing' | 'linked' | 'created'
|
||||
|
||||
// null : nom déjà pris par un compte lié à un autre utilisateur Jellyfin
|
||||
export function importJellyfinUser(jUser: { Id: string; Name: string }): { user: User; action: JellyfinImportAction } | null {
|
||||
const users = readUsers()
|
||||
const jellyfinId = normalizeJellyfinId(jUser.Id)
|
||||
|
||||
const linked = users.find(u => u.jellyfinId === jellyfinId)
|
||||
if (linked) return { user: linked, action: 'existing' }
|
||||
|
||||
const sameName = users.find(u => u.username.toLowerCase() === jUser.Name.toLowerCase())
|
||||
if (sameName?.jellyfinId) {
|
||||
logger.warn('users', `Import Jellyfin de « ${jUser.Name} » impossible : le compte FanKarr de ce nom est lié à un autre utilisateur Jellyfin`)
|
||||
return null
|
||||
}
|
||||
if (sameName) {
|
||||
sameName.jellyfinId = jellyfinId
|
||||
writeUsers(users)
|
||||
logger.info('users', `Compte « ${sameName.username} » lié à l'utilisateur Jellyfin « ${jUser.Name} »`)
|
||||
return { user: sameName, action: 'linked' }
|
||||
}
|
||||
|
||||
return { user: createUser(jUser.Name, '', 'user', jellyfinId), action: 'created' }
|
||||
}
|
||||
|
||||
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 (!user.passwordHash) {
|
||||
throw new Error(user.jellyfinId
|
||||
? 'Ce compte se connecte avec Jellyfin : il n\'a pas de mot de passe FanKarr'
|
||||
: 'Ce compte n\'a pas de mot de passe FanKarr')
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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,18 +1,19 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─── Tokens custom consommables via Tailwind v4 ─── */
|
||||
/* ─── 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-dim: var(--accent-dim);
|
||||
--color-accent-muted: var(--accent-muted);
|
||||
--color-on-accent: var(--on-accent);
|
||||
|
||||
--color-shell: var(--bg-shell);
|
||||
--color-sidebar: var(--bg-sidebar);
|
||||
--color-main: var(--bg-main);
|
||||
--color-card: var(--bg-card);
|
||||
--color-hover: var(--bg-hover);
|
||||
--color-active: var(--bg-active);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-border-light: var(--border-light);
|
||||
@@ -21,85 +22,55 @@
|
||||
--color-secondary: var(--text-secondary);
|
||||
--color-muted: var(--text-muted);
|
||||
|
||||
--color-gold: var(--gold);
|
||||
--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;
|
||||
}
|
||||
|
||||
/* ─── Thème Indigo Électrique (défaut) ─── */
|
||||
:root,
|
||||
[data-theme="indigo"] {
|
||||
--accent: #7B7EF8;
|
||||
--accent-hover: #9496fa;
|
||||
--accent-dim: #1c1c38;
|
||||
--accent-muted: rgba(123, 126, 248, 0.15);
|
||||
/* ─── Palette ─── */
|
||||
:root {
|
||||
--bg-sidebar: #0e0a08;
|
||||
--bg-main: #13100c;
|
||||
--bg-card: #1d1814;
|
||||
--bg-hover: #27221d;
|
||||
|
||||
--bg-shell: #111116;
|
||||
--bg-sidebar: #0d0d14;
|
||||
--bg-main: #16161e;
|
||||
--bg-card: #111116;
|
||||
--bg-hover: #1c1c28;
|
||||
--bg-active: #22223a;
|
||||
--border: #352f2a;
|
||||
--border-light: #27221d;
|
||||
|
||||
--border: #2a2a3a;
|
||||
--border-light: #1e1e2e;
|
||||
--text-primary: #efe9dd;
|
||||
--text-secondary: #b8b0a3;
|
||||
--text-muted: #948b80;
|
||||
|
||||
--text-primary: #e8e8f8;
|
||||
--text-secondary: #9898b8;
|
||||
--text-muted: #55556a;
|
||||
--gold: #f2c44a;
|
||||
--ok: #89d298;
|
||||
--err: #ea8470;
|
||||
--on-accent: #0e0a08;
|
||||
|
||||
--gold: #f5c842;
|
||||
--accent: #f2c44a;
|
||||
--accent-hover: color-mix(in srgb, var(--accent) 80%, white);
|
||||
--accent-muted: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Thème Crimson Night ─── */
|
||||
[data-theme="crimson"] {
|
||||
--accent: #E74C3C;
|
||||
--accent-hover: #f06050;
|
||||
--accent-dim: #2a0e0e;
|
||||
--accent-muted: rgba(231, 76, 60, 0.15);
|
||||
|
||||
--bg-shell: #110e0e;
|
||||
--bg-sidebar: #0d0a0a;
|
||||
--bg-main: #161010;
|
||||
--bg-card: #110e0e;
|
||||
--bg-hover: #1e1414;
|
||||
--bg-active: #2a1616;
|
||||
|
||||
--border: #2e2020;
|
||||
--border-light: #201616;
|
||||
|
||||
--text-primary: #f0e8e8;
|
||||
--text-secondary: #b89898;
|
||||
--text-muted: #6a5555;
|
||||
|
||||
--gold: #f5c842;
|
||||
}
|
||||
|
||||
/* ─── Thème Teal Froid ─── */
|
||||
[data-theme="teal"] {
|
||||
--accent: #00CDBF;
|
||||
--accent-hover: #1de8da;
|
||||
--accent-dim: #001e1c;
|
||||
--accent-muted: rgba(0, 205, 191, 0.15);
|
||||
|
||||
--bg-shell: #0a0f0f;
|
||||
--bg-sidebar: #060c0c;
|
||||
--bg-main: #0e1616;
|
||||
--bg-card: #0a0f0f;
|
||||
--bg-hover: #101e1e;
|
||||
--bg-active: #142828;
|
||||
|
||||
--border: #1a2e2e;
|
||||
--border-light: #121e1e;
|
||||
|
||||
--text-primary: #e0f0f0;
|
||||
--text-secondary: #88b0b0;
|
||||
--text-muted: #4a6868;
|
||||
|
||||
--gold: #f5c842;
|
||||
}
|
||||
/* ─── 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;
|
||||
@@ -109,41 +80,211 @@ html, body, #app {
|
||||
body {
|
||||
background: var(--bg-main);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
: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 bg-accent text-white px-4 py-2 text-sm rounded-lg
|
||||
hover:bg-accent-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer;
|
||||
@apply px-5 bg-accent text-on-accent font-bold hover:bg-accent-hover;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply border border-border text-secondary px-4 py-2 text-sm rounded-lg
|
||||
hover:border-accent hover:text-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer;
|
||||
@apply border border-border text-primary font-semibold hover:bg-hover;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply text-muted px-2 py-1 text-sm rounded-md
|
||||
hover:text-primary hover:bg-hover transition-colors cursor-pointer;
|
||||
@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;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
@apply bg-card border border-border rounded-xl p-4;
|
||||
/* Cartes et champs */
|
||||
.card {
|
||||
@apply bg-card rounded-card px-5 py-4;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
@apply text-xs text-muted block;
|
||||
.field-label {
|
||||
@apply block text-meta text-secondary;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
@apply w-full bg-shell border border-border text-primary text-sm px-3 py-2.5 rounded-lg
|
||||
outline-none focus:border-accent transition-colors;
|
||||
.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%));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// ── Chemin de base (reverse proxy en sous-dossier) ─────────────
|
||||
// Injecté par le serveur dans index.html (cf. server/index.ts) : l'application
|
||||
// suit le préfixe du reverse proxy sans rebuild. Vide en développement, où Vite
|
||||
// sert l'application à la racine.
|
||||
|
||||
declare global {
|
||||
interface Window { __BASE_URL__?: string }
|
||||
}
|
||||
|
||||
export const BASE_URL: string = window.__BASE_URL__ ?? ''
|
||||
|
||||
// Préfixe un chemin absolu de l'application ('/api/...', '/invite/...')
|
||||
export function withBase(path: string): string {
|
||||
return path.startsWith('/') ? `${BASE_URL}${path}` : path
|
||||
}
|
||||
|
||||
// URL absolue affichable (lien d'invitation, adresse de l'API publique)
|
||||
export function absoluteUrl(path: string): string {
|
||||
return `${window.location.origin}${withBase(path)}`
|
||||
}
|
||||
|
||||
// Les quelque 140 appels `fetch('/api/…')` du code applicatif sont préfixés ici
|
||||
// plutôt qu'un par un : un seul endroit à maintenir, et les nouveaux appels en
|
||||
// bénéficient automatiquement. Les URL absolues (api.github.com, clients
|
||||
// torrent distants) et les chemins ne visant pas l'API ne sont pas touchés.
|
||||
export function installFetchBasePrefix(): void {
|
||||
if (!BASE_URL) return
|
||||
|
||||
const nativeFetch = window.fetch.bind(window)
|
||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (typeof input === 'string' && input.startsWith('/api/')) {
|
||||
return nativeFetch(BASE_URL + input, init)
|
||||
}
|
||||
return nativeFetch(input, init)
|
||||
}
|
||||
}
|
||||
@@ -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,74 +1,62 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="$emit('cancel')">
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-lg flex flex-col" style="max-height: 70vh">
|
||||
<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-border shrink-0">
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-0.5">Choisir un dossier</p>
|
||||
<p class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</p>
|
||||
<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="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
|
||||
<button @click="$emit('cancel')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<!-- Chemin éditable -->
|
||||
<div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
|
||||
<button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" :title="drivesRoot ? 'Lecteurs' : 'Retour à la racine'">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
<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="settings-input flex-1 font-mono text-xs"
|
||||
class="field flex-1"
|
||||
placeholder="/"
|
||||
aria-label="Chemin"
|
||||
/>
|
||||
<button @click="navigateTo(inputPath)" class="btn-secondary px-3 text-xs">
|
||||
OK
|
||||
<button @click="navigateTo(inputPath)" class="btn-secondary h-[42px]">
|
||||
Aller
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Retour parent -->
|
||||
<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-muted hover:text-primary transition-colors border-b border-border"
|
||||
class="flex items-center gap-2 w-full py-2.5 text-meta text-secondary hover:text-primary transition-colors border-b border-hover"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||
</svg>
|
||||
..
|
||||
<ArrowLeft :size="14" />
|
||||
Dossier parent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste dossiers -->
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div v-if="loading" class="flex items-center justify-center py-10 text-muted text-xs gap-2">
|
||||
<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...
|
||||
Chargement…
|
||||
</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-muted">Aucun sous-dossier</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 py-2.5 text-xs text-primary hover:bg-hover transition-colors border-b border-border/50 text-left"
|
||||
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"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" fill="none">
|
||||
<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>
|
||||
<Folder :size="15" :stroke-width="1.75" class="text-muted shrink-0" />
|
||||
{{ dir }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-t border-border shrink-0">
|
||||
<span class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</span>
|
||||
<!-- FIX : regex sortie dans selectCurrent() pour éviter l'erreur de parsing Vue -->
|
||||
<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>
|
||||
@@ -81,6 +69,7 @@
|
||||
|
||||
<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: [] }>()
|
||||
@@ -93,13 +82,12 @@ const loading = ref(false)
|
||||
const error = ref('')
|
||||
const drivesRoot = ref(false) // true quand la liste affiche les lecteurs Windows
|
||||
|
||||
// FIX : la regex /\/+$/ dans un @click inline fait planter le parser Vue
|
||||
function selectCurrent() {
|
||||
emit('select', current.value.replace(/\/+$/, '') || '/')
|
||||
}
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
// Lecteur Windows complet (ex: "C:\") → chemin absolu, pas de jointure
|
||||
// Lecteur Windows (ex. "C:\") : déjà absolu
|
||||
if (/^[A-Za-z]:\\$/.test(name)) return name
|
||||
if (base === '/') return `/${name}`
|
||||
const cleanBase = base.replace(/[\\/]+$/, '')
|
||||
@@ -113,7 +101,7 @@ async function navigateTo(p: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
error.value = (await res.json()).error ?? 'Erreur'
|
||||
error.value = (await res.json()).error ?? "Impossible d'ouvrir ce dossier."
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
@@ -123,7 +111,7 @@ async function navigateTo(p: string) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,87 +1,81 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="$emit('close')">
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-3xl flex flex-col" style="max-height: 85vh">
|
||||
<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">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-primary">Import manuel — {{ serieName }}</h3>
|
||||
<p class="text-xs text-muted font-mono mt-0.5 truncate">{{ serieFolder }}</p>
|
||||
<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="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
|
||||
<button @click="$emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<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-sm">Scan du dossier…</p>
|
||||
<p class="text-body">Analyse du dossier…</p>
|
||||
</div>
|
||||
|
||||
<!-- Dossier vide / introuvable -->
|
||||
<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-sm text-muted">Aucun fichier vidéo trouvé dans ce dossier.</p>
|
||||
<p class="text-xs text-muted">Placez vos fichiers dans <span class="font-mono text-primary">{{ serieFolder }}</span> puis réessayez.</p>
|
||||
<button @click="scan" class="btn-secondary mt-2">Rescanner</button>
|
||||
<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>
|
||||
|
||||
<!-- Erreur -->
|
||||
<div v-else-if="scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
|
||||
<p class="text-sm text-red-400">{{ scanError }}</p>
|
||||
<button @click="scan" class="btn-secondary">Réessayer</button>
|
||||
<p class="text-body text-err">{{ scanError }}</p>
|
||||
<button @click="scan" class="btn-secondary btn-sm">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste fichiers -->
|
||||
<template v-else-if="items.length > 0">
|
||||
<template v-else-if="items.length > 0 && step !== 'done'">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between px-6 py-3 border-b border-border shrink-0 gap-3">
|
||||
<p class="text-xs text-muted">
|
||||
<span class="text-primary font-medium">{{ items.length }}</span> fichier{{ items.length > 1 ? 's' : '' }}
|
||||
<span v-if="alreadyImportedCount > 0"> · <span class="text-blue-400">{{ alreadyImportedCount }} déjà importé{{ alreadyImportedCount > 1 ? 's' : '' }}</span></span>
|
||||
· <span class="text-green-400">{{ newMatchedCount }} nouveau{{ newMatchedCount > 1 ? 'x' : '' }}</span>
|
||||
<span v-if="unmatchedCount > 0"> · <span class="text-yellow-500">{{ unmatchedCount }} non associé{{ unmatchedCount > 1 ? 's' : '' }}</span></span>
|
||||
<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-xs text-muted hover:text-primary transition-colors">↺ Rescanner</button>
|
||||
<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>
|
||||
|
||||
<!-- Fichiers -->
|
||||
<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 p-3 rounded-lg border transition-colors"
|
||||
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-blue-500/20 bg-blue-500/5'
|
||||
? 'border-ok/25 bg-ok/5'
|
||||
: item.episode_id
|
||||
? 'border-green-500/20 bg-green-500/5'
|
||||
: 'border-border bg-shell'"
|
||||
? 'border-accent/30 bg-accent/5'
|
||||
: 'border-border-light bg-main'"
|
||||
>
|
||||
<!-- Icône -->
|
||||
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0"
|
||||
:class="item.alreadyImported ? 'bg-blue-500/15 text-blue-400' :
|
||||
item.episode_id ? 'bg-green-500/15 text-green-400' : 'bg-border text-muted'">
|
||||
<svg v-if="item.alreadyImported || item.episode_id" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
|
||||
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2" fill="none"><line x1="6" y1="2" x2="6" y2="7"/><circle cx="6" cy="9.5" r="0.5" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Nom + taille -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-primary font-mono truncate">{{ item.file.name }}</p>
|
||||
<p class="text-[11px] text-muted mt-0.5">
|
||||
<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-blue-400 ml-1">— déjà importé</span>
|
||||
<span v-else-if="item.willRename" class="text-accent ml-1">— sera renommé</span>
|
||||
<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>
|
||||
|
||||
<!-- Sélecteur épisode -->
|
||||
<select
|
||||
v-model="items[i].episode_id"
|
||||
class="settings-input text-xs py-1 max-w-[260px] shrink-0"
|
||||
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">— Non associé —</option>
|
||||
<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"
|
||||
@@ -89,56 +83,60 @@
|
||||
: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) ? '✓' : '' }}
|
||||
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>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-t border-border shrink-0">
|
||||
<p v-if="importError" class="text-xs text-red-400">{{ importError }}</p>
|
||||
<p v-else class="text-xs text-muted">
|
||||
Les fichiers dans ce dossier seront <span class="text-accent">renommés sur place</span>.
|
||||
<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">
|
||||
<button @click="$emit('close')" class="btn-secondary">Annuler</button>
|
||||
<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 ${newMatchedCount} fichier${newMatchedCount > 1 ? 's' : ''}` }}
|
||||
{{ importing ? 'Import…' : `Importer ${plural(newMatchedCount, 'fichier')}` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Résultat -->
|
||||
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-primary">Import terminé</p>
|
||||
<p class="text-xs text-muted mt-1">
|
||||
{{ importResult.done }} fichier{{ importResult.done > 1 ? 's' : '' }} importé{{ importResult.done > 1 ? 's' : '' }}
|
||||
<span v-if="importResult.errors.length > 0" class="text-red-400">
|
||||
· {{ importResult.errors.length }} erreur{{ importResult.errors.length > 1 ? 's' : '' }}
|
||||
<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 bg-red-500/5 border border-red-500/20 rounded-lg p-3">
|
||||
<p class="text-xs text-red-400 font-medium mb-1.5">Erreurs</p>
|
||||
<div v-for="e in importResult.errors" :key="e.file" class="text-xs text-muted mb-1">
|
||||
<span class="text-primary font-mono">{{ e.file }}</span> — {{ e.error }}
|
||||
</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>
|
||||
<button @click="$emit('close'); $emit('imported')" class="btn-primary">Fermer</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -149,6 +147,9 @@
|
||||
<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()
|
||||
|
||||
@@ -165,12 +166,13 @@ const emit = defineEmits<{
|
||||
imported: []
|
||||
}>()
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
// ── É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
|
||||
@@ -180,21 +182,22 @@ const serieTitle = computed(() =>
|
||||
const serieFolder = computed(() => `${props.initialPath}/${serieTitle.value}`)
|
||||
|
||||
interface FileItem {
|
||||
file : { name: string; path: string; size: number }
|
||||
episode_id : number | null
|
||||
hash : string | null
|
||||
alreadyImported: boolean
|
||||
willRename : boolean
|
||||
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[]>([])
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
// ── 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)
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
// ── Utilitaires ──
|
||||
function isEpisodeOrganized(epId: number): boolean {
|
||||
return !!props.organized?.[String(epId)]
|
||||
}
|
||||
@@ -224,48 +227,28 @@ function buildOrganizedByPath(): Map<string, number> {
|
||||
}
|
||||
|
||||
function autoMatch(filename: string): { episode_id: number | null; hash: string | null } {
|
||||
const nameNoExt = filename.replace(/\.[^.]+$/, '').toLowerCase()
|
||||
|
||||
for (const season of props.seasons) {
|
||||
for (const ep of season.episodes) {
|
||||
const candidates = [
|
||||
ep.original_filename,
|
||||
ep.nfo_filename?.replace(/\.nfo$/, ''),
|
||||
ep.formatted_name?.replace(/[<>:"/\\|?*]/g, '').trim(),
|
||||
].filter(Boolean).map((s: string) => s.toLowerCase().replace(/\.[^.]+$/, ''))
|
||||
|
||||
if (candidates.some(c => c === nameNoExt || nameNoExt.startsWith(c + '.'))) {
|
||||
return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback numéro d'épisode
|
||||
const numMatch = filename.match(/[.\s_-]0*(\d{1,3})[.\s_-]/i)
|
||||
if (numMatch) {
|
||||
const num = parseInt(numMatch[1], 10)
|
||||
for (const season of props.seasons) {
|
||||
const ep = season.episodes.find((e: any) => e.episode_number === num)
|
||||
if (ep) return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
|
||||
}
|
||||
}
|
||||
|
||||
return { episode_id: null, hash: null }
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
const episodeId = matchEpisodeFile(filename, props.seasons)
|
||||
return { episode_id: episodeId, hash: episodeId === null ? null : getHashForEpisode(episodeId) }
|
||||
}
|
||||
|
||||
function onEpisodeChange(i: number) {
|
||||
items.value[i].hash = getHashForEpisode(Number(items.value[i].episode_id))
|
||||
items.value[i].alreadyImported = false
|
||||
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 ──────────────────────────────────────────────────
|
||||
// ── Actions ──
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
scanError.value = ''
|
||||
@@ -275,26 +258,26 @@ async function scan() {
|
||||
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 ?? 'Erreur lors du scan'
|
||||
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 => {
|
||||
// Vérifier si déjà importé via dest_path
|
||||
const existingEpId = organizedByPath.get(f.path) ?? null
|
||||
if (existingEpId) {
|
||||
return { file: f, episode_id: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
|
||||
return { file: f, episode_id: existingEpId, originalEpisodeId: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
|
||||
}
|
||||
|
||||
const match = autoMatch(f.name)
|
||||
return { file: f, ...match, alreadyImported: false, willRename: match.episode_id !== null }
|
||||
return { file: f, ...match, originalEpisodeId: null, alreadyImported: false, willRename: match.episode_id !== null }
|
||||
})
|
||||
} catch {
|
||||
scanError.value = 'Impossible de contacter le serveur'
|
||||
scanError.value = 'Impossible de contacter le serveur.'
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
@@ -309,7 +292,7 @@ async function doImport() {
|
||||
.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'
|
||||
importError.value = 'Aucun nouveau fichier à importer.'
|
||||
importing.value = false
|
||||
return
|
||||
}
|
||||
@@ -322,15 +305,37 @@ async function doImport() {
|
||||
body : JSON.stringify({ serie_id: props.serieId, items: payload }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { importError.value = data.error ?? 'Erreur inconnue'; return }
|
||||
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'
|
||||
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,38 +1,40 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed bottom-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="flex items-center gap-3 px-4 py-3 rounded-xl border text-sm cursor-pointer min-w-64 max-w-sm"
|
||||
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="{
|
||||
'bg-card border-green-500/30 text-green-400' : t.type === 'success',
|
||||
'bg-card border-red-500/30 text-red-400' : t.type === 'error',
|
||||
'bg-card border-accent/30 text-accent' : t.type === 'info',
|
||||
'border-ok/30' : t.type === 'success',
|
||||
'border-err/35' : t.type === 'error',
|
||||
'border-accent/35' : t.type === 'info',
|
||||
}"
|
||||
>
|
||||
<svg v-if="t.type === 'success'" width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
|
||||
<polyline points="2 7 5.5 10.5 12 3"/>
|
||||
</svg>
|
||||
<svg v-else-if="t.type === 'error'" width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
|
||||
<line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/>
|
||||
</svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
|
||||
<circle cx="7" cy="7" r="5"/><line x1="7" y1="5" x2="7" y2="7.5"/><circle cx="7" cy="9.5" r="0.5" fill="currentColor"/>
|
||||
</svg>
|
||||
<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-xs leading-snug">{{ t.message }}</span>
|
||||
<span class="flex-1 text-meta text-primary leading-snug">{{ t.message }}</span>
|
||||
|
||||
<!-- Badge count si groupé -->
|
||||
<span
|
||||
v-if="t.count && t.count > 1"
|
||||
class="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full"
|
||||
class="shrink-0 pill h-5 px-2 text-[10.5px]"
|
||||
:class="{
|
||||
'bg-green-500/20 text-green-400' : t.type === 'success',
|
||||
'bg-red-500/20 text-red-400' : t.type === 'error',
|
||||
'bg-accent/20 text-accent' : t.type === 'info',
|
||||
'pill-ok' : t.type === 'success',
|
||||
'pill-err' : t.type === 'error',
|
||||
'pill-active' : t.type === 'info',
|
||||
}"
|
||||
>
|
||||
×{{ t.count }}
|
||||
@@ -44,6 +46,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Info, X } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
const { toasts, remove } = useToast()
|
||||
</script>
|
||||
|
||||
@@ -1,122 +1,126 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Barre search + tri + colonnes -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||
<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)"
|
||||
type="text" placeholder="Rechercher..."
|
||||
class="settings-input pl-9 w-full py-1.5 text-sm"
|
||||
@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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tri -->
|
||||
<div class="relative shrink-0" ref="sortRef">
|
||||
<button @click="sortOpen = !sortOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="activeSort !== 'none' ? 'border-accent text-accent' : ''">
|
||||
<ArrowUpDown :size="14" />
|
||||
<span class="hidden md:inline text-xs">Trier</span>
|
||||
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||
<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>
|
||||
<div v-if="sortOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl">
|
||||
<button v-for="s in sortOptions" :key="s.value" @click="handleSort(s.value)"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
|
||||
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'">
|
||||
</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 }}
|
||||
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
|
||||
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Colonnes -->
|
||||
<div class="relative shrink-0" ref="colRef">
|
||||
<button @click="colOpen = !colOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="colOpen ? 'border-accent text-accent' : ''">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<span class="hidden md:inline text-xs">Colonnes</span>
|
||||
</button>
|
||||
<div v-if="colOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-52 flex flex-col gap-0.5 shadow-xl">
|
||||
<p class="text-[10px] text-muted px-3 py-1">Colonnes affichées</p>
|
||||
<label v-for="col in columnOptions" :key="col.key" class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs cursor-pointer hover:bg-hover transition-colors">
|
||||
<input type="checkbox" :checked="columns[col.key]" @change="emit('toggle-col', col.key)" class="accent-accent" />
|
||||
<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>
|
||||
|
||||
<!-- Onglets + filtres -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
|
||||
<button @click="emit('update:activeTab', 'active')" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'active' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
|
||||
En cours
|
||||
<span v-if="activeCount > 0" class="ml-1.5 text-[10px] text-accent">{{ activeCount }}</span>
|
||||
</button>
|
||||
<button @click="emit('update:activeTab', 'done')" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'done' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
|
||||
Terminés
|
||||
<span v-if="doneCount > 0" class="ml-1.5 text-[10px] text-accent">{{ doneCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'done'" class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<span class="text-xs text-muted">Seeds uniquement</span>
|
||||
<button @click="emit('update:seedingOnly', !seedingOnly)" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="seedingOnly ? 'bg-green-500' : 'bg-border'">
|
||||
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="seedingOnly ? 'translate-x-[18px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<span class="text-xs text-muted">Masquer importés</span>
|
||||
<button @click="emit('update:hideImported', !hideImported)" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="hideImported ? 'bg-accent' : 'bg-border'">
|
||||
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Search, ArrowUpDown } from 'lucide-vue-next'
|
||||
import { ArrowDown, ArrowUp, Search, SlidersHorizontal, X } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
const props = defineProps<{
|
||||
search : string
|
||||
activeSort : string
|
||||
sortDir : 'asc' | 'desc'
|
||||
activeTab : 'active' | 'done'
|
||||
seedingOnly : boolean
|
||||
hideImported: boolean
|
||||
columns : Record<string, boolean>
|
||||
activeCount : number
|
||||
doneCount : number
|
||||
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: 'active' | 'done']
|
||||
'update:seedingOnly': [value: boolean]
|
||||
'update:search' : [value: string]
|
||||
'update:activeTab' : [value: string]
|
||||
'update:seedingOnly' : [value: boolean]
|
||||
'update:hideImported': [value: boolean]
|
||||
'sort' : [value: string]
|
||||
'toggle-col' : [key: string]
|
||||
'sort' : [value: string]
|
||||
'toggle-col' : [key: string]
|
||||
}>()
|
||||
|
||||
const sortOpen = ref(false)
|
||||
const colOpen = ref(false)
|
||||
const sortRef = ref<HTMLElement | null>(null)
|
||||
const colRef = ref<HTMLElement | null>(null)
|
||||
const optionsOpen = ref(false)
|
||||
const optionsRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(sortRef, () => { sortOpen.value = false })
|
||||
onClickOutside(colRef, () => { colOpen.value = false })
|
||||
|
||||
function handleSort(val: string) {
|
||||
emit('sort', val)
|
||||
sortOpen.value = false
|
||||
}
|
||||
onClickOutside(optionsRef, () => { optionsOpen.value = false })
|
||||
</script>
|
||||
@@ -1,157 +1,204 @@
|
||||
<template>
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<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">
|
||||
|
||||
<!-- Nom + état -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary font-medium truncate">{{ torrent.name }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(torrent.state).class">{{ stateBadge(torrent.state).label }}</span>
|
||||
<span v-if="columns.client" class="text-xs text-muted">{{ torrent.client_name }}</span>
|
||||
<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>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-sm font-semibold text-primary">{{ clampedProgress }}%</p>
|
||||
<p v-if="columns.size" class="text-xs text-muted mt-0.5">{{ formatSize(torrent.downloaded) }} / {{ formatSize(torrent.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div class="h-1 bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500"
|
||||
:class="torrent.state === 'seeding' || torrent.state === 'unknown' ? 'bg-green-500' : torrent.state === 'error' ? 'bg-red-500' : torrent.state === 'paused' ? 'bg-muted' : 'bg-accent'"
|
||||
:style="{ width: `${clampedProgress}%` }" />
|
||||
</div>
|
||||
|
||||
<!-- Infos + actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 text-xs text-muted">
|
||||
<span v-if="torrent.state === 'downloading'">↓ {{ formatSpeed(torrent.speed) }}</span>
|
||||
<span v-if="torrent.state === 'downloading' && torrent.eta > 0">ETA {{ formatEta(torrent.eta) }}</span>
|
||||
<template v-if="torrent.state === 'seeding'">
|
||||
<span class="text-green-500">Complété</span>
|
||||
<span v-if="columns.ratio" class="font-mono" :class="torrent.ratio >= 1 ? 'text-green-400' : torrent.ratio >= 0.5 ? 'text-yellow-500' : 'text-muted'">R {{ torrent.ratio?.toFixed(2) ?? '0.00' }}</span>
|
||||
<span v-if="columns.uploaded">↑ {{ formatSize(torrent.uploaded ?? 0) }}</span>
|
||||
<span v-if="columns.upspeed && torrent.upspeed > 0">{{ formatSpeed(torrent.upspeed) }}</span>
|
||||
<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>
|
||||
<span v-if="torrent.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<!-- Actions terminés -->
|
||||
<template v-if="torrent.state === 'seeding' || torrent.state === 'unknown'">
|
||||
<!-- Erreurs -->
|
||||
<div v-if="torrent.errorFiles?.length > 0" class="relative group/err">
|
||||
<span class="text-xs px-2 py-0.5 rounded border border-red-500/40 text-red-400 cursor-default">{{ torrent.errorFiles.length }} erreur{{ torrent.errorFiles.length > 1 ? 's' : '' }}</span>
|
||||
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
|
||||
<div class="bg-card border border-red-500/30 rounded-lg p-3 text-xs shadow-xl">
|
||||
<p class="text-red-400 mb-2 font-medium">Fichiers en erreur</p>
|
||||
<div v-for="e in torrent.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
|
||||
<p class="text-primary truncate">{{ e.file }}</p>
|
||||
<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>
|
||||
|
||||
<!-- Badge import -->
|
||||
<span class="text-xs px-2 py-0.5 rounded border"
|
||||
:class="torrent.organizeState === 'done' ? 'border-green-500/40 text-green-500' : torrent.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' : 'border-border text-muted'">
|
||||
{{ torrent.organizeState === 'done'
|
||||
? `Importé (${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total})`
|
||||
: torrent.organizeState === 'partial'
|
||||
? `En cours (${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total})`
|
||||
: 'Non importé' }}
|
||||
</span>
|
||||
|
||||
<button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing" class="btn-secondary text-xs py-1">
|
||||
{{ importing ? '...' : 'Importer' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Bouton supprimer -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="emit('toggle-confirm', torrent.hash)"
|
||||
:disabled="deleting"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg border border-border text-muted hover:border-red-500/40 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showConfirmDelete" class="absolute bottom-full right-0 mb-2 bg-card border border-border rounded-xl p-3 z-10 w-52 shadow-xl">
|
||||
<p class="text-xs text-primary font-medium mb-2">
|
||||
{{ tab === 'active' ? 'Annuler ce téléchargement ?' : 'Supprimer ce torrent ?' }}
|
||||
</p>
|
||||
<label class="flex items-center gap-2 text-xs text-muted mb-3 cursor-pointer">
|
||||
<input type="checkbox" v-model="withFiles" class="accent-red-500" />
|
||||
Supprimer aussi les fichiers
|
||||
</label>
|
||||
<div class="flex gap-1.5">
|
||||
<button @click="emit('delete', torrent, withFiles)" :disabled="deleting" class="flex-1 text-xs py-1 px-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-colors">
|
||||
{{ deleting ? '...' : 'Confirmer' }}
|
||||
</button>
|
||||
<button @click="emit('toggle-confirm', null)" class="flex-1 text-xs py-1 px-2 rounded-lg border border-border text-muted hover:text-primary transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</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
|
||||
tab : 'active' | 'done'
|
||||
columns : Record<string, boolean>
|
||||
importing : boolean
|
||||
deleting : boolean
|
||||
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]
|
||||
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')
|
||||
|
||||
function stateBadge(state: string) {
|
||||
return ({
|
||||
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
|
||||
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
|
||||
paused : { label: 'En pause', class: 'border-border text-muted' },
|
||||
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
|
||||
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
} as Record<string, any>)[state] ?? { label: 'Inconnu', class: 'border-border text-muted' }
|
||||
}
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
}
|
||||
function formatSpeed(bps: number) { return `${formatSize(bps)}/s` }
|
||||
function formatEta(seconds: number): string {
|
||||
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
||||
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
|
||||
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`
|
||||
if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`
|
||||
return `${s}s`
|
||||
}
|
||||
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
|
||||
if (await auth.logout()) return
|
||||
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>
|
||||
@@ -1,138 +1,118 @@
|
||||
<template>
|
||||
<aside class="flex flex-col h-full bg-sidebar border-r border-border">
|
||||
<aside class="flex flex-col h-full bg-sidebar border-r border-card px-3.5 pt-[22px] pb-[18px]">
|
||||
|
||||
<!-- Logo + logout -->
|
||||
<div class="flex items-center gap-2.5 px-4 py-3.5 border-b border-border min-h-[56px]">
|
||||
<svg class="w-7 h-7 shrink-0" viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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>
|
||||
<RouterLink to="/series" class="text-[15px] font-semibold text-primary tracking-wide flex-1 hover:text-accent transition-colors">
|
||||
FanKarr
|
||||
<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>
|
||||
<button
|
||||
@click="logout"
|
||||
title="Se déconnecter"
|
||||
class="text-muted hover:text-red-400 transition-colors p-1 rounded-md hover:bg-hover"
|
||||
>
|
||||
<LogOut :size="15" />
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 flex flex-col gap-0.5">
|
||||
<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">
|
||||
<hr v-if="item.separator" class="border-border my-2 mx-1" />
|
||||
<div v-if="item.separator" class="h-px bg-card mx-2 my-3.5 shrink-0" />
|
||||
<SidebarNavItem v-else :item="item" />
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Nouvelle version disponible -->
|
||||
<a
|
||||
v-if="updateAvailable"
|
||||
v-if="auth.isAdmin && updateAvailable"
|
||||
:href="latestReleaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="mx-2 mb-2 flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-accent/30 bg-accent/5 hover:bg-accent/10 transition-colors group"
|
||||
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"
|
||||
>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium text-accent">Mise à jour disponible</p>
|
||||
<p class="text-[11px] text-muted truncate">{{ latestVersion }} est disponible</p>
|
||||
</div>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
|
||||
class="shrink-0 text-muted group-hover:text-accent transition-colors">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
<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>
|
||||
|
||||
<!-- Version actuelle -->
|
||||
<div class="px-3 pb-1 pt-0.5">
|
||||
<p class="text-[10px] text-muted/50 font-mono">{{ currentVersion }}</p>
|
||||
<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>
|
||||
|
||||
<!-- Thème -->
|
||||
<ThemeSwitcher />
|
||||
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { LogOut } from 'lucide-vue-next'
|
||||
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 ThemeSwitcher from './ThemeSwitcher.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 currentVersion = ref(
|
||||
import.meta.env.DEV ? 'dev' : ''
|
||||
)
|
||||
const latestVersion = ref('')
|
||||
const latestReleaseUrl = ref('https://github.com/masutayunikon/FanKarr/releases/latest')
|
||||
const updateAvailable = ref(false)
|
||||
const initial = computed(() => auth.username?.charAt(0).toUpperCase() ?? '?')
|
||||
|
||||
const GITHUB_REPO = 'masutayunikon/FanKarr'
|
||||
const helpOpen = ref(false)
|
||||
const helpRef = ref<HTMLElement | null>(null)
|
||||
onClickOutside(helpRef, () => { helpOpen.value = false })
|
||||
|
||||
function parseVersion(v: string): number[] {
|
||||
return v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
|
||||
function startTour() {
|
||||
helpOpen.value = false
|
||||
tour.start()
|
||||
}
|
||||
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
if (current === 'dev') return false
|
||||
const l = parseVersion(latest)
|
||||
const c = parseVersion(current)
|
||||
for (let i = 0; i < Math.max(l.length, c.length); i++) {
|
||||
const lv = l[i] ?? 0
|
||||
const cv = c[i] ?? 0
|
||||
if (lv > cv) return true
|
||||
if (lv < cv) return false
|
||||
}
|
||||
return false
|
||||
function openSetup() {
|
||||
helpOpen.value = false
|
||||
router.push('/setup')
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
try {
|
||||
// Version locale
|
||||
const vRes = await fetch('/api/version', { credentials: 'include' })
|
||||
if (vRes.ok) {
|
||||
const { version } = await vRes.json()
|
||||
currentVersion.value = version
|
||||
}
|
||||
|
||||
if (currentVersion.value === 'dev') return
|
||||
|
||||
// Dernière release GitHub
|
||||
const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
})
|
||||
if (!gRes.ok) return
|
||||
const release = await gRes.json()
|
||||
latestVersion.value = release.tag_name ?? ''
|
||||
latestReleaseUrl.value = release.html_url ?? latestReleaseUrl.value
|
||||
|
||||
if (isNewer(latestVersion.value, currentVersion.value)) {
|
||||
updateAvailable.value = true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const { currentVersion, latestVersion, latestReleaseUrl, updateAvailable } = useUpdateCheck()
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
if (await auth.logout()) return
|
||||
router.push('/auth')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkForUpdates()
|
||||
// Revérifier toutes les 6h
|
||||
setInterval(checkForUpdates, 6 * 60 * 60 * 1000)
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
@@ -1,102 +1,42 @@
|
||||
<template>
|
||||
<div>
|
||||
<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.to ? RouterLink : 'button'"
|
||||
:to="item.to ?? undefined"
|
||||
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors duration-100 text-left group"
|
||||
:class="isActive
|
||||
? 'bg-active text-primary'
|
||||
: 'text-secondary hover:bg-hover hover:text-primary'"
|
||||
@click="item.children && !item.to && toggleOpen()"
|
||||
: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"
|
||||
>
|
||||
<component
|
||||
:is="iconComponent"
|
||||
:size="17"
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isActive ? 'text-accent' : 'text-muted group-hover:text-secondary'"
|
||||
/>
|
||||
<span class="flex-1 text-sm truncate">{{ item.label }}</span>
|
||||
|
||||
<!-- Badge animé (ex: downloads actifs) -->
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="min-w-[18px] h-[18px] px-1 rounded-full bg-accent text-white text-[10px] font-medium flex items-center justify-center"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
|
||||
<!-- Chevron (seulement si pas de badge) -->
|
||||
<ChevronRight
|
||||
v-else-if="item.children"
|
||||
:size="13"
|
||||
class="shrink-0 text-muted transition-transform duration-200"
|
||||
:class="isOpen ? 'rotate-90' : ''"
|
||||
/>
|
||||
</component>
|
||||
|
||||
<Transition name="slide">
|
||||
<div v-if="item.children && isOpen" class="ml-[34px] mt-0.5 flex flex-col gap-0.5">
|
||||
<RouterLink
|
||||
v-for="child in item.children"
|
||||
:key="child.to"
|
||||
:to="child.to"
|
||||
class="flex items-center gap-2 px-2.5 py-1.5 rounded-md transition-colors duration-100"
|
||||
:class="route.path === child.to
|
||||
? 'text-accent'
|
||||
: 'text-muted hover:text-secondary'"
|
||||
>
|
||||
<span
|
||||
class="w-1 h-1 rounded-full shrink-0 transition-colors"
|
||||
:class="route.path === child.to ? 'bg-accent' : 'bg-current opacity-40'"
|
||||
/>
|
||||
<span class="text-xs">{{ child.label }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<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 { ref, computed, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import * as Icons from 'lucide-vue-next'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
|
||||
const props = defineProps<{ item: NavItem }>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
if (!props.item.icon) return Icons.Circle
|
||||
return (Icons as Record<string, unknown>)[props.item.icon] ?? Icons.Circle
|
||||
})
|
||||
|
||||
const isActive = computed(() =>
|
||||
props.item.to
|
||||
? route.path.startsWith(props.item.to)
|
||||
: props.item.children?.some(c => route.path.startsWith(c.to))
|
||||
)
|
||||
|
||||
const isOpen = ref(isActive.value)
|
||||
|
||||
watch(isActive, (val) => { if (val) isOpen.value = true })
|
||||
|
||||
function toggleOpen() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
const isActive = computed(() => !!props.item.to && route.path.startsWith(props.item.to))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: max-height 0.2s ease, opacity 0.15s ease;
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<div class="px-3 py-3 border-t border-border">
|
||||
<p class="text-xs text-muted mb-2 uppercase tracking-widest">Thème</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="theme in themes"
|
||||
:key="theme.id"
|
||||
:title="theme.label"
|
||||
@click="setTheme(theme.id)"
|
||||
class="w-6 h-6 rounded-full border-2 transition-all duration-150 flex items-center justify-center"
|
||||
:style="{ backgroundColor: theme.color }"
|
||||
:class="current === theme.id
|
||||
? 'border-primary scale-110'
|
||||
: 'border-transparent opacity-50 hover:opacity-80'"
|
||||
>
|
||||
<span v-if="current === theme.id" class="block w-1.5 h-1.5 rounded-full bg-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'fankarr-theme'
|
||||
|
||||
const themes = [
|
||||
{ id: 'indigo', label: 'Indigo Électrique', color: '#7B7EF8' },
|
||||
{ id: 'crimson', label: 'Crimson Night', color: '#E74C3C' },
|
||||
{ id: 'teal', label: 'Teal Froid', color: '#00CDBF' },
|
||||
]
|
||||
|
||||
const current = ref('indigo')
|
||||
|
||||
function setTheme(id) {
|
||||
current.value = id
|
||||
document.documentElement.setAttribute('data-theme', id)
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) ?? 'indigo'
|
||||
setTheme(saved)
|
||||
})
|
||||
</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>
|
||||
@@ -1,62 +1,110 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div v-if="serie.fanart_image" class="absolute inset-0 h-64 overflow-hidden pointer-events-none">
|
||||
<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
|
||||
:src="serie.fanart_image"
|
||||
class="w-full h-full object-cover opacity-15"
|
||||
@error="(e) => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'"
|
||||
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-gradient-to-b from-transparent to-main" />
|
||||
<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 px-4 md:px-8 pt-6 pb-10 flex gap-6 min-h-[200px]">
|
||||
<!-- Poster -->
|
||||
<div class="shrink-0 w-32 rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
|
||||
<img v-if="serie.poster_image" :src="serie.poster_image" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full aspect-[2/3] bg-card flex items-center justify-center text-muted">
|
||||
<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>
|
||||
|
||||
<!-- Infos -->
|
||||
<div class="flex flex-col gap-3 justify-end min-w-0">
|
||||
<RouterLink to="/series" class="inline-flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-fit">
|
||||
<ArrowLeft :size="13" />
|
||||
Séries
|
||||
</RouterLink>
|
||||
<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="text-2xl font-bold text-primary tracking-tight">{{ serie.title }}</h1>
|
||||
<h1 class="font-display text-[26px] sm:text-[42px] leading-[1.1] font-extrabold text-primary">{{ serie.title }}</h1>
|
||||
|
||||
<!-- Métadonnées -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="serie.year" class="text-sm text-muted">{{ serie.year }}</span>
|
||||
<span
|
||||
v-if="serie.status"
|
||||
class="text-xs px-2 py-0.5 rounded-full border"
|
||||
:class="serie.status.toLowerCase() === 'continuing'
|
||||
? 'bg-green-500/15 text-green-400 border-green-500/20'
|
||||
: 'bg-hover text-muted border-border'"
|
||||
>{{ serie.status }}</span>
|
||||
<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>
|
||||
|
||||
<p
|
||||
v-if="serie.plot"
|
||||
@click="expanded = !expanded"
|
||||
class="text-sm text-secondary leading-relaxed max-w-2xl cursor-pointer select-none transition-all"
|
||||
:class="expanded ? '' : 'line-clamp-3'"
|
||||
>
|
||||
{{ serie.plot }}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ArrowLeft, Tv } from 'lucide-vue-next'
|
||||
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
|
||||
}>()
|
||||
|
||||
defineProps<{ serie: any }>()
|
||||
const expanded = ref(false)
|
||||
</script>
|
||||
@@ -1,292 +1,310 @@
|
||||
<template>
|
||||
<div class="bg-card border border-border rounded-xl">
|
||||
<!-- Header saison -->
|
||||
<div class="flex items-center justify-between px-5 py-3.5 border-b border-border">
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="text-muted hover:text-primary transition-colors" @click="emit('toggle', season.id)">
|
||||
<ChevronUp :size="15" class="transition-transform duration-200" :class="collapsed ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-primary">
|
||||
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted font-normal ml-1">
|
||||
— {{ season.title }}
|
||||
</span>
|
||||
</h2>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
|
||||
<template v-if="availableCount > 0"> · {{ availableCount }}/{{ season.episodes.length }} dispo</template>
|
||||
<span
|
||||
v-if="season.organized_state !== 'none'"
|
||||
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px]"
|
||||
:class="season.organized_state === 'complete' ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-500'"
|
||||
>
|
||||
{{ season.organized_state === 'complete'
|
||||
? `✓ ${season.organized_count} importé${season.organized_count > 1 ? 's' : ''}`
|
||||
: `${season.organized_count}/${availableCount || season.episodes.length} importés` }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Boutons saison -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Corbeille saison -->
|
||||
<button
|
||||
v-if="season.organized_count > 0"
|
||||
@click.stop="openUnimportSeasonModal"
|
||||
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20"
|
||||
title="Désimporter la saison"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
<!-- Pas de pack saison : bouton "Saison" (dropdown si plusieurs packs via intégrales) -->
|
||||
<template v-if="season.torrents.length === 0 && hasDownloadable">
|
||||
<div v-if="uniquePackOptions.length > 1" class="relative" @click.stop>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
|
||||
@click="seasonMenuOpen = !seasonMenuOpen"
|
||||
>
|
||||
<component :is="downloadIcon" />
|
||||
Saison ▾
|
||||
</button>
|
||||
<div v-if="seasonMenuOpen" class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-56 shadow-xl flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="opt in uniquePackOptions"
|
||||
:key="opt.infohash"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
|
||||
@click="emit('downloadSeason', season, opt.infohash); seasonMenuOpen = false"
|
||||
>
|
||||
<component :is="downloadIcon" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</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="emit('downloadSeason', season)"
|
||||
:disabled="downloadingSeason"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border bg-accent-muted text-accent border-accent/20 hover:bg-accent/20 transition"
|
||||
@click.stop="emit('requestSeason', season.season_number)"
|
||||
class="btn-secondary btn-sm"
|
||||
>
|
||||
<component :is="downloadingSeason ? loaderIcon : downloadIcon" />
|
||||
{{ downloadingSeason ? 'Envoi…' : 'Saison' }}
|
||||
<Clock3 :size="14" :stroke-width="2" />
|
||||
{{ missingCount === season.episodes.length ? 'Demander la saison' : missingCount > 1 ? `Demander ${missingCount} épisodes` : "Demander l'épisode manquant" }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Pack saison unique -->
|
||||
<button
|
||||
v-if="season.torrents.length === 1"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
|
||||
:class="canDownloadSeason
|
||||
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
|
||||
: 'text-muted border-border cursor-not-allowed opacity-50'"
|
||||
:disabled="!canDownloadSeason"
|
||||
@click="canDownloadSeason && emit('download', `season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
|
||||
>
|
||||
<component :is="seasonBtnIcon" />
|
||||
{{ seasonBtnLabel }}
|
||||
</button>
|
||||
<!-- ── 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>
|
||||
|
||||
<!-- Dropdown packs saison multiples -->
|
||||
<div v-if="season.torrents.length > 1" class="relative" @click.stop>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
|
||||
@click="seasonMenuOpen = !seasonMenuOpen"
|
||||
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="downloadIcon" />
|
||||
Saison ▾
|
||||
<component :is="seasonBtnIcon" />
|
||||
{{ seasonBtnLabel }}
|
||||
</button>
|
||||
<div v-if="seasonMenuOpen" class="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="(t, i) in season.torrents"
|
||||
:key="i"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
|
||||
:class="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
: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" />
|
||||
{{ groupLabels(season.torrents)[i] }}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progression saison -->
|
||||
<div v-if="seasonProgress && seasonProgress.progress < 100" class="px-5 pb-3 -mt-1">
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${seasonProgress.progress}%` }" />
|
||||
</div>
|
||||
<p class="text-[11px] text-muted mt-1">{{ seasonProgress.progress }}%</p>
|
||||
</div>
|
||||
|
||||
<!-- Épisodes -->
|
||||
<div v-if="!collapsed" class="divide-y divide-border/50">
|
||||
<div
|
||||
v-for="ep in season.episodes"
|
||||
:key="ep.id"
|
||||
class="px-5 py-3 hover:bg-hover/50 transition-colors last:rounded-b-xl"
|
||||
:class="{ 'opacity-40': !ep.available && !ep.organized }"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Thumbnail -->
|
||||
<div class="shrink-0 w-20 aspect-video rounded-md overflow-hidden bg-shell hidden sm:flex items-center justify-center text-muted text-xs font-mono">
|
||||
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
|
||||
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Infos épisode -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted shrink-0 font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2, '0')}` }}
|
||||
</span>
|
||||
<span class="text-sm text-primary truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||||
<span
|
||||
v-if="ep.fankai === false || ep.torrent?.fankai === false"
|
||||
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-purple-500/10 text-purple-400 border-purple-500/20"
|
||||
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
|
||||
>Hors Fankai</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span v-if="ep.aired" class="text-xs text-muted">{{ formatDate(ep.aired) }}</span>
|
||||
<span v-if="ep.duration" class="text-xs text-muted">{{ formatDuration(ep.duration) }}</span>
|
||||
<!-- Badge langue -->
|
||||
<span
|
||||
v-if="epLang(ep) === 'MULTI'"
|
||||
title="Audio japonais + français"
|
||||
class="shrink-0 inline-flex items-center gap-1 text-[10px] font-semibold leading-none px-1.5 py-0.5 rounded border bg-blue-500/10 border-blue-500/20 text-blue-300 select-none tracking-wide"
|
||||
>
|
||||
<!-- JP flag -->
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#fff"/><circle cx="7" cy="5" r="3" fill="#BC002D"/></svg>
|
||||
<!-- FR flag -->
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#ED2939"/><rect width="9.33" height="10" fill="#fff"/><rect width="4.67" height="10" fill="#002395"/></svg>
|
||||
MULTI
|
||||
</span>
|
||||
<span
|
||||
v-else-if="epLang(ep) === 'VOSTFR'"
|
||||
title="Audio japonais · Sous-titres français"
|
||||
class="shrink-0 inline-flex items-center gap-1 text-[10px] font-semibold leading-none px-1.5 py-0.5 rounded border bg-amber-500/10 border-amber-500/20 text-amber-300 select-none tracking-wide"
|
||||
>
|
||||
<!-- JP flag -->
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#fff"/><circle cx="7" cy="5" r="3" fill="#BC002D"/></svg>
|
||||
<!-- FR flag -->
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#ED2939"/><rect width="9.33" height="10" fill="#fff"/><rect width="4.67" height="10" fill="#002395"/></svg>
|
||||
VOSTFR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton téléchargement épisode (uniquement si non organisé) -->
|
||||
<div v-if="!ep.organized" class="relative shrink-0" @click.stop>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
|
||||
:class="epBtnClass(ep)"
|
||||
:disabled="epState(ep) !== 'idle'"
|
||||
@click="handleEpBtnClick(ep)"
|
||||
>
|
||||
<component :is="epStateIcon(ep)" />
|
||||
</button>
|
||||
<!-- Dropdown quand plusieurs options de torrent -->
|
||||
<div v-if="epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1"
|
||||
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
|
||||
<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 ep.torrents"
|
||||
v-for="(t, i) in (season.torrents as any[])"
|
||||
:key="i"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
|
||||
:class="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
: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)"
|
||||
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(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
|
||||
{{ groupLabels(ep.torrents)[i] }}
|
||||
<component :is="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
|
||||
<span class="truncate">{{ groupLabels(season.torrents)[i] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions épisode importé -->
|
||||
<template v-if="ep.organized && organizedByEpisode[String(ep.id)]">
|
||||
<!-- Badge rename cliquable -->
|
||||
<button
|
||||
v-if="epNeedsRename(ep)"
|
||||
@click.stop="emit('renameEpisode', ep, season)"
|
||||
:disabled="epActionLoading[ep.id]"
|
||||
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-yellow-500/10 text-yellow-400 border-yellow-500/20 hover:bg-yellow-500/20 transition-colors"
|
||||
:title="`Actuel : ${organizedByEpisode[String(ep.id)]?.dest_filename}\nAttendu : ${epExpectedName(ep)}`"
|
||||
>Rename</button>
|
||||
<!-- Corbeille épisode -->
|
||||
<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-6 h-6 flex items-center justify-center rounded text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
title="Désimporter l'épisode"
|
||||
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="12" />
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Chevron synopsis (à droite, visible si l'épisode a un plot) -->
|
||||
<button
|
||||
v-if="ep.plot"
|
||||
@click.stop="togglePlot(ep.id)"
|
||||
class="w-6 h-6 flex items-center justify-center rounded transition-colors shrink-0"
|
||||
:class="plotOpen === ep.id ? 'text-accent' : 'text-muted hover:text-primary'"
|
||||
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"
|
||||
>
|
||||
<ChevronUp :size="14" class="transition-transform duration-200" :class="plotOpen === ep.id ? '' : 'rotate-180'" />
|
||||
<ChevronDown :size="14" class="transition-transform duration-200" :class="plotOpen === ep.id ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Barre progression épisode -->
|
||||
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="mt-1.5 sm:pl-[92px]">
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${epProgress(ep)!.progress}%` }" />
|
||||
</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>
|
||||
|
||||
<!-- Synopsis -->
|
||||
<div v-if="plotOpen === ep.id && ep.plot" class="mt-2 sm:pl-[92px]">
|
||||
<p class="text-xs text-muted leading-relaxed" style="white-space: pre-line">{{ ep.plot }}</p>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modal désimport épisode -->
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportModal = false">
|
||||
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
|
||||
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter l'épisode</h3>
|
||||
<p class="text-xs text-muted mb-4">L'épisode sera retiré de la bibliothèque.</p>
|
||||
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteFileOnUnimport" class="accent-red-500" />
|
||||
<span class="text-xs text-muted">Supprimer le fichier du disque</span>
|
||||
<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 justify-end">
|
||||
<button @click="unimportModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
|
||||
<button @click="confirmUnimport" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
|
||||
{{ deleteFileOnUnimport ? 'Supprimer' : 'Désimporter' }}
|
||||
<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>
|
||||
|
||||
<!-- Modal désimport saison -->
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportSeasonModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportSeasonModal = false">
|
||||
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
|
||||
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter la saison</h3>
|
||||
<p class="text-xs text-muted mb-4">Tous les épisodes importés de cette saison seront retirés de la bibliothèque.</p>
|
||||
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteSeasonFiles" class="accent-red-500" />
|
||||
<span class="text-xs text-muted">Supprimer les fichiers du disque</span>
|
||||
<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 justify-end">
|
||||
<button @click="unimportSeasonModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
|
||||
<button @click="confirmUnimportSeason" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
|
||||
{{ deleteSeasonFiles ? 'Supprimer' : 'Désimporter' }}
|
||||
<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>
|
||||
@@ -296,9 +314,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
|
||||
import { ChevronUp, Download, Loader, Check, X, Trash2 } from 'lucide-vue-next'
|
||||
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 }[] }
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number; priority?: number }[] }
|
||||
|
||||
const props = defineProps<{
|
||||
season : any
|
||||
@@ -310,6 +329,10 @@ const props = defineProps<{
|
||||
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<{
|
||||
@@ -319,8 +342,19 @@ const emit = defineEmits<{
|
||||
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)
|
||||
@@ -329,11 +363,10 @@ 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: 12 })
|
||||
const loaderIcon = h(Loader, { size: 12, class: 'animate-spin' })
|
||||
const checkIcon = h(Check, { size: 12 })
|
||||
const downloadIcon = h(Download, { size: 14, class: 'shrink-0' })
|
||||
const checkIcon = h(Check, { size: 14, class: 'shrink-0' })
|
||||
|
||||
// ── Helpers hash / progress ────────────────────────────────────
|
||||
// ── 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()
|
||||
@@ -348,27 +381,15 @@ function torrentProgress(hash: string | null | undefined): ActiveTorrent | null
|
||||
function isDownloading(key: string) { return props.downloading.includes(key) }
|
||||
function isDownloaded(key: string) { return props.downloaded.includes(key) }
|
||||
function isAlreadyQueued(torrent: any): boolean {
|
||||
// Pour un fichier dans un pack (file_index défini), le hash seul ne suffit pas :
|
||||
// le pack peut être actif sans que CE fichier spécifique soit en téléchargement.
|
||||
// On s'appuie uniquement sur downloading/downloaded pour ces cas.
|
||||
// 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())
|
||||
}
|
||||
|
||||
// ── Computed saison ────────────────────────────────────────────
|
||||
// ── État de la saison ──
|
||||
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
|
||||
const seasonProgress = computed(() => {
|
||||
if (props.season.torrents && props.season.torrents.length > 1) {
|
||||
for (const t of props.season.torrents) {
|
||||
const prog = torrentProgress(extractHash(t))
|
||||
if (prog) return prog
|
||||
}
|
||||
return null
|
||||
}
|
||||
return torrentProgress(extractHash(props.season.torrent))
|
||||
})
|
||||
const hasDownloadable = computed(() =>
|
||||
props.season.episodes.some((ep: any) =>
|
||||
ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)
|
||||
@@ -378,22 +399,55 @@ 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é'
|
||||
if (isAlreadyQueued(props.season.torrent)) return 'Déjà ajouté'
|
||||
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyé'
|
||||
return 'Saison entière'
|
||||
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: 12 })
|
||||
if (isDownloading(`season-${props.season.id}`)) return h(Loader, { size: 12, class: 'animate-spin' })
|
||||
if (!props.season.torrent) return h(X, { size: 12 })
|
||||
return h(Download, { size: 12 })
|
||||
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 })
|
||||
})
|
||||
|
||||
// ── État épisode ───────────────────────────────────────────────
|
||||
// ── 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"
|
||||
})
|
||||
|
||||
// Vérifie si n'importe quelle clé de téléchargement pour cet épisode est marquée done/downloading
|
||||
// (couvre ep-{id} pour torrent unique ET ep-{id}-{i} pour multi-torrent)
|
||||
// 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
|
||||
@@ -426,15 +480,12 @@ function epProgress(ep: any): ActiveTorrent | 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)
|
||||
// Avec données par fichier on se fie à la progression réelle du fichier précis.
|
||||
// Si le fichier est à 100% sans session active → déjà téléchargé hors session
|
||||
// (ex : désimporté puis re-consulté) → ne pas afficher le spinner.
|
||||
if (file != null && (sessionDl || (file.progress > 0 && file.progress < 100))) {
|
||||
return { ...active, progress: file.progress }
|
||||
// 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 {
|
||||
// Pas de données par fichier : ignorer si pas de session active
|
||||
if (!sessionDl) continue
|
||||
return { ...active, progress: active.state === 'seeding' ? 100 : active.progress }
|
||||
}
|
||||
@@ -448,42 +499,33 @@ function epProgress(ep: any): ActiveTorrent | null {
|
||||
function handleEpBtnClick(ep: any) {
|
||||
if (ep.organized) return
|
||||
if (ep.torrents && ep.torrents.length > 1) {
|
||||
// Plusieurs options → toggle dropdown
|
||||
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
|
||||
return
|
||||
}
|
||||
// Option unique → téléchargement direct
|
||||
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' {
|
||||
// Vert uniquement quand importé dans la bibliothèque
|
||||
if (ep.organized) return 'done'
|
||||
// Spinner si : envoyé au client, déjà en cours dans le client, ou fichier en cours/terminé
|
||||
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'
|
||||
}
|
||||
function epStateIcon(ep: any) {
|
||||
const state = epState(ep)
|
||||
if (state === 'done') return h(Check, { size: 14 })
|
||||
if (state === 'loading') return h(Loader, { size: 14, class: 'animate-spin' })
|
||||
if (state === 'unavailable') return h(X, { size: 14 })
|
||||
return h(Download, { size: 14 })
|
||||
}
|
||||
function epBtnClass(ep: any): string {
|
||||
const state = epState(ep)
|
||||
if (state === 'done') return 'border-green-500/30 text-green-500 bg-green-500/10 cursor-default'
|
||||
if (state === 'loading') return 'border-accent/30 text-accent bg-accent-muted cursor-default'
|
||||
if (state === 'unavailable') return 'border-border text-muted opacity-40 cursor-not-allowed'
|
||||
return 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'
|
||||
}
|
||||
|
||||
// ── Rename helpers ─────────────────────────────────────────────
|
||||
// ── Renommage ──
|
||||
function epExpectedName(ep: any): string {
|
||||
const entry = props.organizedByEpisode[String(ep.id)]
|
||||
if (!entry) return ''
|
||||
@@ -491,17 +533,80 @@ function epExpectedName(ep: any): string {
|
||||
if (props.nfoSupport) {
|
||||
return ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : entry.dest_filename
|
||||
} else {
|
||||
return ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : entry.dest_filename
|
||||
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
|
||||
const expected = epExpectedName(ep)
|
||||
return !!expected && expected !== entry.dest_filename
|
||||
return entry.needs_rename === true
|
||||
}
|
||||
|
||||
// ── Détection langue ───────────────────────────────────────────
|
||||
// ── É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 ?? '',
|
||||
@@ -517,9 +622,7 @@ function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Labels de groupe ───────────────────────────────────────────
|
||||
// Si tous les torrent_name sont présents ET distincts → on les utilise.
|
||||
// Sinon → titre Nyaa brut (raw), plus informatif quand les noms se ressemblent.
|
||||
// ── 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
|
||||
@@ -530,39 +633,44 @@ function groupLabels(torrents: any[]): string[] {
|
||||
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[]
|
||||
// Fallback : titre Nyaa brut
|
||||
return torrents.map((t: any, i: number) => labelFromRaw(t.raw ?? '', i))
|
||||
}
|
||||
|
||||
// Packs parents distincts couvrant cette saison (quand pas de pack saison explicite)
|
||||
// Permet le dropdown "Saison ▾" quand une saison est couverte par plusieurs intégrales
|
||||
// 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 seen = new Set<string>()
|
||||
const raw: { infohash: string; torrent: any }[] = []
|
||||
|
||||
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 || seen.has(hash)) continue
|
||||
seen.add(hash)
|
||||
raw.push({ infohash: hash, torrent: t })
|
||||
if (!hash) continue
|
||||
hashCount.set(hash, (hashCount.get(hash) ?? 0) + 1)
|
||||
if (!hashTorrent.has(hash)) hashTorrent.set(hash, t)
|
||||
}
|
||||
}
|
||||
if (raw.length <= 1) return []
|
||||
const labels = groupLabels(raw.map(r => r.torrent))
|
||||
return raw.map((r, i) => ({ infohash: r.infohash, label: labels[i] }))
|
||||
|
||||
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 ${m}min` : `${h}h`
|
||||
if (h > 0) return m > 0 ? `${h} h ${String(m).padStart(2, '0')}` : `${h} h`
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,141 +1,140 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Toolbar -->
|
||||
<div class="shrink-0 px-4 md:px-6 py-3 border-b border-border flex flex-col gap-2">
|
||||
<div data-tour="series-toolbar" class="flex flex-col gap-[18px]">
|
||||
|
||||
<!-- Ligne 1 : recherche + filtres -->
|
||||
<div class="flex items-center gap-2">
|
||||
<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="relative flex-1">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||
<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…"
|
||||
class="settings-input pl-9 w-full py-1.5 text-sm"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="relative shrink-0" ref="filterRef">
|
||||
<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
|
||||
@click="filterOpen = !filterOpen"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 relative"
|
||||
:class="hasActiveFilter ? 'border-accent text-accent' : ''"
|
||||
title="Filtres"
|
||||
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"
|
||||
>
|
||||
<SlidersHorizontal :size="15" />
|
||||
<span class="hidden md:inline text-xs">Filtres</span>
|
||||
<ChevronDown class="hidden md:inline transition-transform" :size="12" :class="filterOpen ? 'rotate-180' : ''" />
|
||||
<span v-if="hasActiveFilter" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||
{{ f.label }}<span class="chip-count" :class="{ 'text-accent! font-bold': f.attention && f.count > 0 && activeFilter !== f.value }">{{ f.count }}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="filterOpen"
|
||||
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-3 z-20 w-52 flex flex-col gap-3 shadow-xl"
|
||||
<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"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs text-muted mb-1.5">Disponibilité</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="f in filtersDisponibilite" :key="f.value"
|
||||
@click="emit('update:activeFilter', f.value)"
|
||||
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
|
||||
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
{{ f.label }}
|
||||
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-border pt-3">
|
||||
<p class="text-xs text-muted mb-1.5">État import</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="f in filtersImport" :key="f.value"
|
||||
@click="emit('update:activeFilter', f.value)"
|
||||
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
|
||||
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full shrink-0" :style="{ background: f.color }" />
|
||||
{{ f.label }}
|
||||
</div>
|
||||
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ 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-if="hasActiveFilter"
|
||||
@click="emit('update:activeFilter', 'all')"
|
||||
class="text-xs text-muted hover:text-primary transition-colors text-left pt-1 border-t border-border"
|
||||
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}`"
|
||||
>
|
||||
Effacer les filtres
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 2 : tri + taille + count -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="s in sortOptions" :key="s.value"
|
||||
@click="emit('update:activeSort', s.value)"
|
||||
class="px-2.5 py-1 text-xs rounded-lg border transition-colors"
|
||||
:class="activeSort === s.value
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 border-l border-border pl-2 ml-auto">
|
||||
<button
|
||||
v-for="(_, size) in posterSizes" :key="size"
|
||||
@click="emit('update:posterSize', size)"
|
||||
class="w-7 h-7 text-[10px] rounded-md border transition-colors flex items-center justify-center"
|
||||
:class="posterSize === size
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-xs text-muted">{{ count }} série{{ count > 1 ? 's' : '' }}</span>
|
||||
<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>
|
||||
|
||||
<!-- Légende barres -->
|
||||
<div class="shrink-0 px-4 md:px-6 py-2 flex items-center gap-4 border-b border-border">
|
||||
<div v-for="l in legend" :key="l.label" class="flex items-center gap-1.5">
|
||||
<span class="w-3 h-1.5 rounded-full" :style="{ background: l.color }" />
|
||||
<span class="text-[11px] text-muted">{{ l.label }}</span>
|
||||
<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 } from 'vue'
|
||||
import { Search, SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
|
||||
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'
|
||||
|
||||
defineProps<{
|
||||
search : string
|
||||
activeFilter : string
|
||||
activeSort : string
|
||||
posterSize : string
|
||||
posterSizes : Record<string, string>
|
||||
count : number
|
||||
hasActiveFilter : boolean
|
||||
filtersDisponibilite: { label: string; value: string }[]
|
||||
filtersImport : { label: string; value: string; color: string }[]
|
||||
sortOptions : { label: string; value: string }[]
|
||||
legend : { label: string; color: string }[]
|
||||
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<{
|
||||
@@ -143,10 +142,41 @@ const emit = defineEmits<{
|
||||
'update:activeFilter': [value: string]
|
||||
'update:activeSort' : [value: string]
|
||||
'update:posterSize' : [value: string]
|
||||
'update:selecting' : [value: boolean]
|
||||
}>()
|
||||
|
||||
const filterOpen = ref(false)
|
||||
const filterRef = ref<HTMLElement | null>(null)
|
||||
const activeMore = computed(() => props.moreFilters.find(f => f.value === props.activeFilter) ?? null)
|
||||
|
||||
onClickOutside(filterRef, () => { filterOpen.value = false })
|
||||
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>
|
||||