Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7b3dc5cd | ||
|
|
f909f554ff | ||
|
|
fc6d88be5e | ||
|
|
ed556acd1d | ||
|
|
c6c52a1ae7 | ||
|
|
98ed3c7199 | ||
|
|
ee1c7d579c | ||
|
|
ba06dce34d | ||
|
|
8991524463 | ||
|
|
3fe1006a1e | ||
|
|
63c8f8a841 | ||
|
|
25877685ea | ||
|
|
5b111272d4 | ||
|
|
67365336bb | ||
|
|
3a991c8fcf | ||
|
|
d85708588c | ||
|
|
63f6c1276b | ||
|
|
8b4d5e20a9 | ||
|
|
9b22b1f377 | ||
|
|
d1357e7a79 | ||
|
|
e15e7beb1b | ||
|
|
8b4b198697 | ||
|
|
13700c554a | ||
|
|
1e6506aee6 | ||
|
|
2235541000 | ||
|
|
6f5b9e2ee4 | ||
|
|
045213d363 | ||
|
|
1f0c9a338a | ||
|
|
59b40cfd59 | ||
|
|
fc4598a810 | ||
|
|
7b1863180f | ||
|
|
edec048e30 | ||
|
|
4690874be1 | ||
|
|
bf9b18ec00 | ||
|
|
d08d6e6032 | ||
|
|
8737e515b7 | ||
|
|
4667cd47fe | ||
|
|
161f4f5051 | ||
|
|
858a5b34bf | ||
|
|
4a6510adde | ||
|
|
41addf9e4f | ||
|
|
b7c7e9d33d | ||
|
|
bd5b16e17c | ||
|
|
ec1d1705e7 | ||
|
|
16b3f741e7 | ||
|
|
99c8d86c6e | ||
|
|
1176523d61 | ||
|
|
5478fa6d80 | ||
|
|
0bac6da968 | ||
|
|
911ca86a0d | ||
|
|
e6b0bea455 | ||
|
|
85a218042d | ||
|
|
921f6eb8d2 | ||
|
|
0ac48126aa | ||
|
|
0544a515b6 | ||
|
|
423ba6a777 | ||
|
|
7f4121d9e4 | ||
|
|
62c5b2e622 | ||
|
|
73678be2c8 | ||
|
|
473d97e392 | ||
|
|
f3d535d577 | ||
|
|
5d06a23198 | ||
|
|
dabd1d429e | ||
|
|
dfb515b0f8 | ||
|
|
6ce59b9e04 | ||
|
|
76778930b5 | ||
|
|
92433f33ca | ||
|
|
b8037db423 | ||
|
|
c9040ea68c | ||
|
|
8972679e25 | ||
|
|
ad829c0a99 | ||
|
|
a5e2aa0da1 | ||
|
|
086ca3053f | ||
|
|
f07b608288 | ||
|
|
42c979536b | ||
|
|
4800167979 | ||
|
|
cb28baf25d | ||
|
|
4bd15f89d6 | ||
|
|
16bca596b6 | ||
|
|
ebc902c9e7 | ||
|
|
f81969cdd0 | ||
|
|
950a2abc49 | ||
|
|
04a900e120 | ||
|
|
1c11eb1e89 | ||
|
|
4dd9ba2ff4 | ||
|
|
27a23d76dd | ||
|
|
fcb176d519 | ||
|
|
359be7fd9f | ||
|
|
ece35824d3 | ||
|
|
fe807bded4 | ||
|
|
7cdfe13151 | ||
|
|
d04ca79e00 | ||
|
|
a37b527895 | ||
|
|
1e1a04a465 | ||
|
|
c6555ae47d | ||
|
|
a76328cc16 | ||
|
|
ef527c2072 | ||
|
|
45079dce25 | ||
|
|
5466028ae2 | ||
|
|
ea265cfd9e | ||
|
|
0e3e5fe912 | ||
|
|
6d8ea36260 | ||
|
|
ea1fb49a37 | ||
|
|
244fa4ae43 | ||
|
|
5cfb449c3c | ||
|
|
3c9b9f5344 | ||
|
|
9ebaca92f1 | ||
|
|
494266953c | ||
|
|
583097cd16 | ||
|
|
cb1d1759e3 | ||
|
|
563ffb2bd3 | ||
|
|
25ab373539 | ||
|
|
ed0acdda22 | ||
|
|
a84337c928 | ||
|
|
cface2944e | ||
|
|
1e92934f53 | ||
|
|
bf3f30356e | ||
|
|
bfabf9b793 | ||
|
|
1f50f9ae72 | ||
|
|
7da5a8d4e8 | ||
|
|
2b3fd79a92 | ||
|
|
aa8b500f57 | ||
|
|
702847a5c2 | ||
|
|
5cc1aa29fe | ||
|
|
105cc05bdf | ||
|
|
50b8b2992b | ||
|
|
f4c9713815 | ||
|
|
7154dc87d2 | ||
|
|
2c9bef8883 | ||
|
|
0d5d44a484 | ||
|
|
d5953a948c | ||
|
|
132198a3a9 | ||
|
|
add5ec5a3e | ||
|
|
260fd10bf0 | ||
|
|
95d2b270dc | ||
|
|
1aa6c57e58 | ||
|
|
bde377dd37 | ||
|
|
834dc12b05 | ||
|
|
35655d7784 | ||
|
|
434da54ede | ||
|
|
d299b76bd3 | ||
|
|
9f90b6ab6c | ||
|
|
2a77a38655 | ||
|
|
39463bfbcb | ||
|
|
83437def32 | ||
|
|
7f6bbd6323 | ||
|
|
82a82b1e84 | ||
|
|
85bce309e9 | ||
|
|
8b98b0edf9 | ||
|
|
8c765bbf39 | ||
|
|
0fd10576fe | ||
|
|
744e0336c5 | ||
|
|
040a8d7b33 | ||
|
|
39d2f996ae | ||
|
|
97c573cc1d | ||
|
|
f6ef818e60 | ||
|
|
f5094c68c6 | ||
|
|
9834023522 | ||
|
|
c267562aec | ||
|
|
a68b9cff95 | ||
|
|
a604b0d0cc | ||
|
|
24466228ef | ||
|
|
fcb1b88870 | ||
|
|
3fb4119c79 | ||
|
|
eb4cb30efd | ||
|
|
53c226eba8 | ||
|
|
3b74d93b74 | ||
|
|
ff54ab4ef6 | ||
|
|
a7c90b63df | ||
|
|
872962c435 |
No files matched your search
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(find src:*)",
|
||||
"Bash(find server:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 }}
|
||||
@@ -1,3 +1,6 @@
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
+270
@@ -1,5 +1,275 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **UI/UX:** ajout d'un modal de confirmation lors de l'activation des nfo ([8b98b0e](https://github.com/Masutayunikon/FanKarr/commit/8b98b0edf926ba6b542501afdf524050d72f043b))
|
||||
|
||||
## [3.12.0](https://github.com/Masutayunikon/FanKarr/compare/v3.11.5...v3.12.0) (2026-04-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission ([2446622](https://github.com/Masutayunikon/FanKarr/commit/24466228ef5305c36574f58fb2590aee8ee86e13))
|
||||
* désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission ([fcb1b88](https://github.com/Masutayunikon/FanKarr/commit/fcb1b88870bebb1bf0b4e484b2462e56ab3975fc))
|
||||
* désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission ([3fb4119](https://github.com/Masutayunikon/FanKarr/commit/3fb4119c79908c1023f3cbf9999b524eb1bc267e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* qbitorrent ([9834023](https://github.com/Masutayunikon/FanKarr/commit/98340235226394c4583159f36cce74e316eedf26))
|
||||
* qbitorrent header ([f5094c6](https://github.com/Masutayunikon/FanKarr/commit/f5094c68c6834f816b0b6d019e5d5637400e16ab))
|
||||
* spinner infinie apres supression ([39d2f99](https://github.com/Masutayunikon/FanKarr/commit/39d2f996aecb9f07dcd8c1ec7911090276908a2f))
|
||||
* spinner infinie apres supression ([97c573c](https://github.com/Masutayunikon/FanKarr/commit/97c573cc1d4fb76787ba5d1df4fda30faec5fbf2))
|
||||
* **UI/UX:** chevron pour ouvrir la description, bouton desimporter de la serie passer à supprimer et affichage de toute la description de la serie lors d'un clique ([f6ef818](https://github.com/Masutayunikon/FanKarr/commit/f6ef818e60875a1ac5d859480c1e0e53082c0858))
|
||||
* **UI/UX:** meilleur comprehension des parametres avancés des clients ([040a8d7](https://github.com/Masutayunikon/FanKarr/commit/040a8d7b339090d819c6e3dc049f1ab9d8b221f5))
|
||||
|
||||
## [3.11.5](https://github.com/Masutayunikon/FanKarr/compare/v3.11.4...v3.11.5) (2026-04-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* le folder picker affiche tous les disques depuis le bouton homes sous windows ([7149022](https://github.com/Masutayunikon/FanKarr/commit/7149022c8701f79d194c675da87c6cb2d5793a35))
|
||||
|
||||
## [3.11.4](https://github.com/Masutayunikon/FanKarr/compare/v3.11.3...v3.11.4) (2026-04-26)
|
||||
|
||||
|
||||
|
||||
+3
-1
@@ -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,6 +26,7 @@ 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
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -50,22 +50,37 @@ Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
|
||||
- **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
|
||||
- **RSS Sync** — surveillance automatique des nouvelles sorties toutes les 6 heures pour les séries abonnées, téléchargement sans intervention
|
||||
|
||||
### 🗂️ 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
|
||||
- **Suppression du dossier série** automatique lors d'un désimport complet avec suppression des fichiers
|
||||
|
||||
### 📋 Demandes
|
||||
- **Système de demandes** — les utilisateurs peuvent demander des séries, saisons ou épisodes spécifiques depuis l'interface ou le plugin Jellyfin
|
||||
- **Workflow admin** — approbation / refus avec message / marquage disponible depuis la page dédiée
|
||||
- **Auto-téléchargement** configurable par utilisateur — les demandes sont approuvées et lancées automatiquement pour les utilisateurs autorisés
|
||||
- **Suppression en masse** pour les admins (bouton "Tout supprimer")
|
||||
- **Nettoyage automatique** — les demandes marquées "disponible" sont supprimées lors du désimport de la série concernée
|
||||
|
||||
### 🏷️ 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
|
||||
|
||||
### 👥 Utilisateurs & Jellyfin
|
||||
- **Multi-utilisateurs** avec rôles (`admin` / `user`) et système d'invitations
|
||||
- **SSO Jellyfin** — les utilisateurs se connectent avec leur compte Jellyfin, aucun mot de passe FanKarr requis
|
||||
- **Synchronisation Jellyfin** — création automatique des comptes FanKarr pour les utilisateurs Jellyfin, toutes les heures en arrière-plan ou manuellement depuis les paramètres
|
||||
- **Token API personnel** — chaque utilisateur dispose d'un token pour accéder à l'API publique
|
||||
|
||||
### ⚙️ 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é)
|
||||
- **Multi-client torrent** — qBittorrent, Transmission, Synology Download Station, µTorrent, rTorrent, Real-Debrid, AllDebrid
|
||||
- **Compatible Docker / Runtipi / Binaire autonome**
|
||||
|
||||
---
|
||||
@@ -139,6 +154,13 @@ fankarr-windows-x64/
|
||||
**Linux / macOS** — rendre le binaire exécutable avant le premier lancement :
|
||||
|
||||
```bash
|
||||
wget https://github.com/Masutayunikon/FanKarr/releases/download/v3.14.6/fankarr-linux-arm64.zip
|
||||
unzip fankarr-linux-arm64.zip -d fankarr
|
||||
cd fankarr
|
||||
################
|
||||
# if the "fankarr" files in inside the binaries directory use this command
|
||||
# mv ./binaries/fankarr .
|
||||
##################
|
||||
chmod +x fankarr
|
||||
./fankarr
|
||||
```
|
||||
@@ -215,6 +237,117 @@ Le **scan médiathèque** permet de référencer des fichiers déjà présents s
|
||||
|
||||
---
|
||||
|
||||
## Plugin Jellyfin — FanKarr Search
|
||||
|
||||
Un plugin Jellyfin est disponible pour intégrer la recherche FanKarr directement dans l'interface de votre serveur. Vos utilisateurs peuvent parcourir le catalogue et demander 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)** — consultez le README du plugin pour les instructions d'installation.
|
||||
|
||||
### Synchronisation des utilisateurs
|
||||
|
||||
FanKarr peut créer automatiquement un compte pour chaque utilisateur Jellyfin actif. La synchronisation se fait :
|
||||
- **Automatiquement** toutes les heures en arrière-plan (si Jellyfin est configuré)
|
||||
- **Manuellement** depuis **Paramètres → Jellyfin → Synchroniser**
|
||||
|
||||
Les comptes créés n'ont pas de mot de passe FanKarr — l'authentification se fait uniquement via SSO Jellyfin.
|
||||
|
||||
---
|
||||
|
||||
## API Publique
|
||||
|
||||
FanKarr expose une API publique `v1` utilisée par le plugin Jellyfin et toute intégration tierce.
|
||||
|
||||
**Auth** : `Authorization: Bearer <token>` (token visible dans **Profil → Token API**)
|
||||
|
||||
| Méthode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| `POST` | `/api/v1/auth/jellyfin` | Échange un token Jellyfin contre un token FanKarr |
|
||||
| `GET` | `/api/v1/auth/me` | Informations de l'utilisateur connecté |
|
||||
| `GET` | `/api/v1/series/search?q=` | Recherche dans le catalogue (titre, note, année, description, demande en cours) |
|
||||
| `GET` | `/api/v1/series/:id` | Détail d'une série — saisons et épisodes (proxy FanKai) |
|
||||
| `POST` | `/api/v1/requests` | Créer ou mettre à jour une demande |
|
||||
| `GET` | `/api/v1/requests` | Demandes de l'utilisateur authentifié |
|
||||
|
||||
### SSO Jellyfin
|
||||
|
||||
```http
|
||||
POST /api/v1/auth/jellyfin
|
||||
Content-Type: application/json
|
||||
|
||||
{ "jellyfinUserId": "...", "jellyfinToken": "..." }
|
||||
```
|
||||
|
||||
Retourne `{ token, username, role }`. Le `token` est ensuite utilisé comme Bearer pour tous les autres appels.
|
||||
|
||||
### Recherche
|
||||
|
||||
```http
|
||||
GET /api/v1/series/search?q=dragon+ball
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
```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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Le champ `request` est `null` si aucune demande active n'existe pour cette série.
|
||||
|
||||
### Détail série (saisons & épisodes)
|
||||
|
||||
```http
|
||||
GET /api/v1/series/42
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"title": "Dragon Ball Z Kai",
|
||||
"seasons": [
|
||||
{
|
||||
"season_number": 1,
|
||||
"episodes": [
|
||||
{ "id": 101, "episode_number": 1, "title": "...", "image": "https://..." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Créer une demande
|
||||
|
||||
```http
|
||||
POST /api/v1/requests
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{ "serieId": 42, "serieName": "Dragon Ball Z Kai", "seasons": [1, 2] }
|
||||
```
|
||||
|
||||
- `seasons` : numéros de saison (`season_number`). Tableau vide = toutes les saisons.
|
||||
- `episodes` : IDs d'épisodes (`episode.id`). Prend le dessus sur `seasons` si renseigné.
|
||||
- Si une demande active existe déjà, les saisons/épisodes sont **fusionnés**.
|
||||
|
||||
---
|
||||
|
||||
## Runtipi
|
||||
|
||||
FanKarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
||||
@@ -241,6 +374,7 @@ FanKarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
||||
## Liens
|
||||
|
||||
- [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 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
|
||||
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
|
||||
@@ -20,6 +20,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 +34,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",
|
||||
|
||||
Generated
+28
@@ -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
|
||||
@@ -512,6 +518,9 @@ packages:
|
||||
peerDependencies:
|
||||
'@types/express': '*'
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -758,6 +767,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==}
|
||||
|
||||
@@ -1126,6 +1139,10 @@ 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'}
|
||||
@@ -1751,6 +1768,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/express': 5.0.6
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
@@ -2045,6 +2066,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: {}
|
||||
@@ -2401,6 +2427,8 @@ snapshots:
|
||||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
|
||||
+135
-75
@@ -1,112 +1,101 @@
|
||||
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 } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
import {
|
||||
findByUsername, findById, findByApiToken,
|
||||
createUser, changePassword, regenerateApiToken, safeUser, hasUsers,
|
||||
type User,
|
||||
} from './users.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 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 {
|
||||
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
|
||||
}
|
||||
|
||||
// ── 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 writeAuth(data: AuthData): void {
|
||||
fs.mkdirSync(path.dirname(DATA_PATH), { recursive: true })
|
||||
fs.writeFileSync(DATA_PATH, JSON.stringify(data, null, 2))
|
||||
function setCookieAndRespond(res: Response, user: User): void {
|
||||
const token = signToken(user)
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
res.json({ success: true, user: safeUser(user) })
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/auth/status
|
||||
export function authStatus(req: Request, res: Response): void {
|
||||
const existing = readAuth()
|
||||
const token = req.cookies?.fankarr_token
|
||||
const setup = hasUsers()
|
||||
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é')
|
||||
}
|
||||
if (!token) { res.json({ setup, loggedIn: false }); return }
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as any
|
||||
const user = findById(payload.id)
|
||||
if (!user) { res.json({ setup, loggedIn: false }); return }
|
||||
res.json({ setup, loggedIn: true, role: user.role, username: user.username, userId: user.id })
|
||||
} catch {
|
||||
res.json({ setup, loggedIn: false })
|
||||
}
|
||||
|
||||
res.json({ setup: !!existing, loggedIn })
|
||||
}
|
||||
|
||||
// POST /api/auth/setup
|
||||
// POST /api/auth/setup (premier lancement uniquement)
|
||||
export function authSetup(req: Request, res: Response): void {
|
||||
if (readAuth()) {
|
||||
if (hasUsers()) {
|
||||
logger.warn('auth', 'Tentative de setup alors qu\'un compte existe déjà')
|
||||
res.status(400).json({ error: 'Un compte existe déjà' })
|
||||
return
|
||||
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: 'Username et password requis' }); return
|
||||
}
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
|
||||
writeAuth({ username, passwordHash })
|
||||
logger.info('auth', `Compte créé pour "${username}"`)
|
||||
|
||||
const token = signToken(username)
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
res.json({ success: true })
|
||||
try {
|
||||
const user = createUser(username, password, 'admin')
|
||||
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' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/auth/login
|
||||
export function authLogin(req: Request, res: Response): void {
|
||||
const auth = readAuth()
|
||||
if (!auth) {
|
||||
res.status(400).json({ error: 'Aucun compte configuré' })
|
||||
return
|
||||
if (!hasUsers()) {
|
||||
res.status(400).json({ error: 'Aucun compte configuré' }); return
|
||||
}
|
||||
|
||||
const { username, password } = req.body
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username et password requis' })
|
||||
return
|
||||
res.status(400).json({ error: 'Username et password requis' }); return
|
||||
}
|
||||
|
||||
if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) {
|
||||
const user = findByUsername(username)
|
||||
if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
|
||||
logger.warn('auth', `Échec de connexion pour "${username}"`)
|
||||
res.status(401).json({ error: 'Identifiants incorrects' })
|
||||
return
|
||||
res.status(401).json({ error: 'Identifiants incorrects' }); 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 })
|
||||
logger.info('auth', `Connexion de "${username}" (rôle: ${user.role}, expiry: ${NO_EXPIRY ? 'aucune' : TOKEN_EXPIRY})`)
|
||||
setCookieAndRespond(res, user)
|
||||
}
|
||||
|
||||
// POST /api/auth/logout
|
||||
@@ -114,27 +103,98 @@ 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.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 requis' }); return
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
res.status(400).json({ error: 'Le nouveau mot de passe doit faire 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' })
|
||||
}
|
||||
}
|
||||
|
||||
// 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' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/auth/register (via lien d'invitation)
|
||||
export function authRegister(req: Request, res: Response): void {
|
||||
const { username, password, inviteCode } = req.body
|
||||
if (!username || !password || !inviteCode) {
|
||||
res.status(400).json({ error: 'Username, password et code d\'invitation requis' }); return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return
|
||||
}
|
||||
// La validation du code se fait dans le router invites (middleware)
|
||||
// Ici on reçoit déjà un username/password validés — la création est déléguée au router
|
||||
res.status(400).json({ error: 'Utilisez /api/invites/:code/register' })
|
||||
}
|
||||
|
||||
// ── Middlewares ───────────────────────────────────────────────
|
||||
|
||||
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
|
||||
res.status(401).json({ error: 'Non authentifié' }); 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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
/** Accepte aussi l'authentification par token API (header Authorization: Bearer <token>) */
|
||||
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)
|
||||
}
|
||||
+133
-17
@@ -3,7 +3,8 @@ 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, requireAuth, requireAdmin } from './auth.js'
|
||||
import { migrateIfNeeded } from './users.js'
|
||||
import { readSettings } from './settings.js'
|
||||
import { registerDriver, dispatchList, dispatchRemove } from './torrent-clients/index.js'
|
||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||
@@ -11,13 +12,23 @@ 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 } 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 { pushNotif } from './lib/notifs.js'
|
||||
import { readRequests, completeRequest } from './requests.js'
|
||||
import { checkNfoUpdates } from './lib/nfo.js'
|
||||
import { runJellyfinSync } from './routes/jellyfin.js'
|
||||
import cors from 'cors';
|
||||
|
||||
import usersRouter from './routes/users.js'
|
||||
import invitesRouter from './routes/invites.js'
|
||||
import requestsRouter from './routes/requests.js'
|
||||
import jellyfinRouter from './routes/jellyfin.js'
|
||||
import publicApiRouter from './routes/public-api.js'
|
||||
import settingsRouter from './routes/settings.js'
|
||||
import torrentClientsRouter from './routes/torrent-clients.js'
|
||||
import seriesRouter from './routes/series.js'
|
||||
@@ -27,12 +38,16 @@ 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
|
||||
@@ -40,27 +55,66 @@ const PORT = Number(process.env.PORT) || 9898
|
||||
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))
|
||||
}
|
||||
|
||||
// ── 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 mono-user → multi-user ──────────────────────────
|
||||
migrateIfNeeded()
|
||||
|
||||
// ── Routes ─────────────────────────────────────────────────────
|
||||
// ── 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)
|
||||
app.get ('/api/auth/me', requireAuth, authMe)
|
||||
app.post('/api/auth/change-password', requireAuth, authChangePassword)
|
||||
app.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken)
|
||||
|
||||
// ── API publique v1 (auth par token) ──────────────────────────
|
||||
app.use('/api', publicApiRouter)
|
||||
|
||||
// ── Garde admin : protège les préfixes réservés aux admins ────
|
||||
// NOTE : app.use('/api', requireAdmin, router) s'applique à TOUTES
|
||||
// les requêtes /api/* avant même que le router vérifie ses routes,
|
||||
// ce qui bloquerait les utilisateurs standard. On utilise donc un
|
||||
// middleware ciblé sur les chemins réellement admin-only.
|
||||
const ADMIN_PREFIXES = [
|
||||
'/users', '/jellyfin', '/settings', '/torrent-clients',
|
||||
'/downloads', '/download', '/organize', '/import',
|
||||
'/system', '/nfo-updates', '/plex', '/rss-sync',
|
||||
]
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) {
|
||||
return requireAdmin(req, res, next)
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
// ── Routers (admin + utilisateurs connectés) ───────────────────
|
||||
app.use('/api', usersRouter)
|
||||
app.use('/api', jellyfinRouter)
|
||||
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)
|
||||
app.use('/api', rssSyncRouter)
|
||||
app.use('/api', requireAuth, seriesRouter)
|
||||
app.use('/api', requireAuth, requestsRouter)
|
||||
// Invites : mix public + admin (le router gère ses propres middlewares)
|
||||
app.use('/api', invitesRouter)
|
||||
|
||||
// ── Catch-all SPA ──────────────────────────────────────────────
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
@@ -71,7 +125,9 @@ if (fs.existsSync(PUBLIC_PATH)) {
|
||||
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}`
|
||||
console.log(`\n FanKarr → \x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\\n`)
|
||||
logger.info('api', `Serveur démarré — ${url}`)
|
||||
try {
|
||||
const available = await readAvailable()
|
||||
logger.info('api', `Cache GitHub initialisé — ${available.length} séries disponibles`)
|
||||
@@ -96,8 +152,33 @@ server.listen(PORT, async () => {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Migration organized.json : backfill dest_dir manquant sur les anciennes entrées
|
||||
try {
|
||||
if (fs.existsSync(ORGANIZED_PATH)) {
|
||||
const raw: Record<string, Record<string, any>> = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
let patched = 0
|
||||
for (const episodes of Object.values(raw)) {
|
||||
for (const entry of Object.values(episodes)) {
|
||||
if (!entry.dest_dir && entry.dest_path) {
|
||||
entry.dest_dir = path.dirname(entry.dest_path)
|
||||
patched++
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patched > 0) {
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(raw, null, 2))
|
||||
logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
loadEnrichedSeriesData()
|
||||
.then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData))
|
||||
.then(async seriesData => {
|
||||
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
|
||||
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
|
||||
})
|
||||
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
|
||||
|
||||
const autoOrganize = async () => {
|
||||
@@ -112,13 +193,28 @@ server.listen(PORT, async () => {
|
||||
if (result.done > 0 || result.errors > 0) {
|
||||
pushNotif({ ...result, at: new Date().toISOString() })
|
||||
}
|
||||
// Marquer les demandes actives comme disponibles quand des fichiers sont importés
|
||||
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 compléter les demandes pour 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)
|
||||
const removeResult = await dispatchRemove(result.hash, false)
|
||||
if (removeResult.ok) {
|
||||
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}`)
|
||||
} else {
|
||||
logger.warn('api', `Torrent "${result.name}" — suppression échouée après move : ${removeResult.error ?? 'aucun client n\'a pu supprimer'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,12 +240,32 @@ server.listen(PORT, async () => {
|
||||
// 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 loadEnrichedSeriesData()
|
||||
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
||||
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
||||
if (updated > 0)
|
||||
logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`)
|
||||
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
|
||||
if (renamed > 0)
|
||||
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
|
||||
} catch (err) {
|
||||
logger.error('api', `Sync noms auto échoué : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}, 60 * 60_000)
|
||||
|
||||
// RSS sync — auto-téléchargement des nouveaux épisodes toutes les 6 heures.
|
||||
setTimeout(() => {
|
||||
runRssSync().catch(err => logger.error('rss-sync', `Sync initial échoué : ${err instanceof Error ? err.message : err}`))
|
||||
setInterval(() => {
|
||||
runRssSync().catch(err => logger.error('rss-sync', `Sync périodique échoué : ${err instanceof Error ? err.message : err}`))
|
||||
}, 6 * 60 * 60_000)
|
||||
}, 60_000) // premier cycle 1 min après le démarrage
|
||||
|
||||
// Jellyfin sync — toutes les heures (no-op si Jellyfin non configuré)
|
||||
setTimeout(() => {
|
||||
runJellyfinSync().catch(err => logger.error('jellyfin', `Sync initiale échouée : ${err instanceof Error ? err.message : err}`))
|
||||
setInterval(() => {
|
||||
runJellyfinSync().catch(err => logger.error('jellyfin', `Sync périodique échouée : ${err instanceof Error ? err.message : err}`))
|
||||
}, 60 * 60_000)
|
||||
}, 2 * 60_000) // premier cycle 2 min après le démarrage
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Système d'invitations
|
||||
*/
|
||||
|
||||
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')
|
||||
|
||||
// ── Helpers fichier ───────────────────────────────────────────
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
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 ?? 1,
|
||||
uses : 0,
|
||||
note : opts.note,
|
||||
}
|
||||
writeInvites([...readInvites(), invite])
|
||||
logger.info('invites', `Invitation créée : ${invite.code.slice(0, 8)}… (maxUses: ${invite.maxUses ?? '∞'}, expire: ${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)
|
||||
}
|
||||
|
||||
/** Vérifie si un code est valide (non expiré, non épuisé) */
|
||||
export function validateInvite(code: string): { valid: true; invite: Invite } | { valid: false; reason: string } {
|
||||
const invite = findInvite(code)
|
||||
if (!invite) return { valid: false, reason: 'Code d\'invitation invalide' }
|
||||
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 }
|
||||
}
|
||||
|
||||
/** Incrémente le compteur d'utilisations */
|
||||
export function consumeInvite(code: string): void {
|
||||
const invites = readInvites()
|
||||
const invite = invites.find(i => i.code === code)
|
||||
if (!invite) return
|
||||
invite.uses++
|
||||
writeInvites(invites)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Helpers pour l'API Jellyfin
|
||||
* Utilisé pour la sync d'utilisateurs et la validation SSO
|
||||
*/
|
||||
|
||||
import { readSettings } from '../settings.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const CLIENT_HEADER = 'MediaBrowser Client="FanKarr", Device="Server", DeviceId="fankarr-server", Version="1.0"'
|
||||
|
||||
function adminHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
'X-Emby-Token': token,
|
||||
'Authorization': `${CLIENT_HEADER}, Token="${token}"`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
function userHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
'Authorization': `${CLIENT_HEADER}, Token="${token}"`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
// ── Types Jellyfin ────────────────────────────────────────────
|
||||
|
||||
export interface JellyfinUser {
|
||||
Id : string
|
||||
Name : string
|
||||
HasPassword : boolean
|
||||
Policy : { IsAdministrator: boolean; IsDisabled: boolean }
|
||||
}
|
||||
|
||||
// ── Test de connexion ─────────────────────────────────────────
|
||||
|
||||
export async function testJellyfinConnection(url: string, token: string): Promise<{ ok: boolean; version?: string; error?: string }> {
|
||||
try {
|
||||
const res = await fetch(`${url.replace(/\/$/, '')}/System/Info`, {
|
||||
headers: adminHeaders(token),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
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 token manquant)')
|
||||
|
||||
const base = jellyfinUrl.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/Users`, { headers: adminHeaders(jellyfinAdminToken) })
|
||||
if (!res.ok) throw new Error(`Jellyfin API ${res.status} : impossible de récupérer les utilisateurs`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ── Validation d'un token utilisateur Jellyfin (SSO) ──────────
|
||||
|
||||
export interface JellyfinTokenInfo {
|
||||
valid : boolean
|
||||
userId? : string
|
||||
username?: string
|
||||
error? : string
|
||||
}
|
||||
|
||||
export async function validateJellyfinToken(jellyfinUserId: string, jellyfinToken: string): Promise<JellyfinTokenInfo> {
|
||||
const { jellyfinUrl } = readSettings()
|
||||
if (!jellyfinUrl) return { valid: false, error: 'Jellyfin non configuré sur ce serveur FanKarr' }
|
||||
|
||||
try {
|
||||
const base = jellyfinUrl.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/Users/${jellyfinUserId}`, {
|
||||
headers: userHeaders(jellyfinToken),
|
||||
})
|
||||
if (res.status === 401 || res.status === 403) return { valid: false, error: 'Token Jellyfin invalide ou expiré' }
|
||||
if (!res.ok) return { valid: false, error: `Jellyfin API ${res.status}` }
|
||||
|
||||
const user: JellyfinUser = await res.json()
|
||||
if (user.Policy?.IsDisabled) return { valid: false, error: 'Compte Jellyfin désactivé' }
|
||||
|
||||
logger.debug('jellyfin', `Token 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,241 @@
|
||||
/**
|
||||
* RSS Sync — auto-téléchargement des épisodes sortis après l'activation de la surveillance.
|
||||
*
|
||||
* Toutes les 6 heures, FanKarr cherche les épisodes dont le champ `date_added`
|
||||
* (date d'ajout dans la base metadata) est postérieur à la date d'activation
|
||||
* du sync pour cette série. Si ces épisodes ont un torrent fankai et ne sont
|
||||
* pas encore organisés, ils sont envoyés automatiquement au client torrent.
|
||||
*/
|
||||
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { loadEnrichedSeriesData, readInfohashMap } from './github-cache.js'
|
||||
import { dispatchDownload, dispatchList } from '../torrent-clients/index.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface SyncedSerie {
|
||||
serieId : number
|
||||
serieName: string
|
||||
addedAt : string // ISO — date 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
|
||||
}
|
||||
|
||||
export function isSynced(serieId: number): boolean {
|
||||
return !!loadSynced()[serieId]
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
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>()
|
||||
|
||||
const manual = organized['manual'] ?? {}
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? [])
|
||||
if (manual[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
|
||||
}
|
||||
|
||||
// ── Logique de sync ───────────────────────────────────────────
|
||||
|
||||
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> {
|
||||
const synced = loadSynced()
|
||||
const ids = Object.keys(synced).map(Number)
|
||||
if (ids.length === 0) return { sent: 0, skipped: 0, errors: 0 }
|
||||
|
||||
logger.info('rss-sync', `Démarrage sync — ${ids.length} série(s) surveillée(s)`)
|
||||
|
||||
const organized = readOrganized()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
|
||||
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} introuvable dans le cache`)
|
||||
continue
|
||||
}
|
||||
|
||||
const title = sd.title ?? sd.show_title ?? String(serieId)
|
||||
const activatedAt = new Date(syncedEntry.addedAt)
|
||||
const organizedIds = getOrganizedEpisodeIds(sd, organized)
|
||||
|
||||
logger.info('rss-sync', `Vérification : ${title} (sync activé le ${activatedAt.toLocaleDateString('fr-FR')})`)
|
||||
|
||||
// Épisodes candidats : date_added > date d'activation ET pas encore organisés
|
||||
const candidateIds = new Set<number>()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (!ep.date_added) continue
|
||||
const epDate = new Date(ep.date_added)
|
||||
if (epDate <= activatedAt) continue
|
||||
if (organizedIds.has(ep.id)) continue
|
||||
// Doit avoir au moins un torrent fankai
|
||||
const hasTorrent = (ep.torrents ?? []).some((t: any) => t.fankai && (t.torrent_url || t.magnet))
|
||||
if (!hasTorrent) continue
|
||||
candidateIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateIds.size === 0) {
|
||||
logger.info('rss-sync', `${title} — aucun nouvel épisode depuis l'activation`)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.info('rss-sync', `${title} — ${candidateIds.size} nouvel(s) épisode(s) à télécharger`)
|
||||
|
||||
// Construire les téléchargements : pack saison si couvre des candidats, sinon épisode par épisode
|
||||
const toDownload: { url: string; file_index?: number | null; file_path?: string | null; infohash?: string | null; label: string }[] = []
|
||||
const coveredIds = new Set<number>()
|
||||
|
||||
// 1. Packs saison couvrant au moins un candidat
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
if (!t.fankai) continue
|
||||
const url = t.torrent_url ?? t.magnet
|
||||
if (!url) continue
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (hash && activeHashes.has(hash)) { skipped++; continue }
|
||||
|
||||
const coversSomeCandidate = (season.episodes ?? []).some((ep: any) =>
|
||||
candidateIds.has(ep.id) && !coveredIds.has(ep.id)
|
||||
)
|
||||
if (!coversSomeCandidate) { skipped++; continue }
|
||||
|
||||
toDownload.push({ url, infohash: hash ?? null, label: `S${season.season_number} ${title}` })
|
||||
for (const ep of season.episodes ?? []) coveredIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Épisodes individuels candidats non couverts par un pack
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (!candidateIds.has(ep.id) || coveredIds.has(ep.id)) continue
|
||||
|
||||
for (const t of ep.torrents ?? []) {
|
||||
if (!t.fankai) continue
|
||||
const url = t.torrent_url ?? t.magnet
|
||||
if (!url) continue
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (hash && activeHashes.has(hash)) { skipped++; continue }
|
||||
|
||||
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === hash)
|
||||
toDownload.push({
|
||||
url,
|
||||
infohash : hash ?? null,
|
||||
file_index: pathEntry?.file_index ?? null,
|
||||
file_path : pathEntry?.path ?? null,
|
||||
label : `S${season.season_number}E${ep.episode_number} ${title}`,
|
||||
})
|
||||
coveredIds.add(ep.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dl of toDownload) {
|
||||
try {
|
||||
const results = await dispatchDownload(dl.url, {
|
||||
file_index: dl.file_index,
|
||||
file_path : dl.file_path,
|
||||
infohash : dl.infohash,
|
||||
})
|
||||
if (results.some((r: any) => r.ok)) {
|
||||
logger.info('rss-sync', `Envoyé : ${dl.label}`)
|
||||
sent++
|
||||
} else {
|
||||
logger.warn('rss-sync', `Échec envoi : ${dl.label}`)
|
||||
errors++
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('rss-sync', `Erreur dispatch ${dl.label} : ${err instanceof Error ? err.message : err}`)
|
||||
errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('rss-sync', `Sync terminé — ${sent} envoyé(s), ${skipped} ignoré(s), ${errors} erreur(s)`)
|
||||
return { sent, skipped, errors }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import path from 'path'
|
||||
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
|
||||
export async function fankaiGet(endpoint: string): Promise<any> {
|
||||
@@ -43,6 +45,77 @@ export function extractTorrentsFromSerieData(sd: any): any[] {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout les noms d'un épisode en préférant les valeurs du path entry
|
||||
* correspondant au hash donné (spécifique au torrent/encoding),
|
||||
* avec fallback sur les champs de niveau épisode.
|
||||
*
|
||||
* Utilisation :
|
||||
* const { formatted_name, nfo_filename, original_filename } = resolveEpNaming(ep, hash)
|
||||
*/
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le nom attendu d'un fichier épisode selon le mode (NFO ou formaté).
|
||||
* Source unique de vérité partagée entre organized-summary, organized/:id, rename-episode et rename-all.
|
||||
*/
|
||||
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 une liste d'épisodes par episode_number.
|
||||
* Quand plusieurs variantes existent (ex. x264 + x265), on garde la première
|
||||
* comme épisode principal et on fusionne les torrents des autres dans sa liste.
|
||||
*/
|
||||
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()
|
||||
@@ -69,14 +142,19 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
|
||||
|
||||
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
|
||||
|
||||
const allEpisodeIds = new Set<number>()
|
||||
// Dédoublonnage : épisodes uniques par (season_number, episode_number)
|
||||
// pour éviter de compter x264 + x265 comme deux épisodes distincts
|
||||
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) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
@@ -85,18 +163,24 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
|
||||
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)
|
||||
if (manualOrg[String(ep.id)]) {
|
||||
const key = epIdToKey.get(ep.id)
|
||||
if (key) organizedEpKeys.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allEpisodeIds.size === 0 || organizedEpisodeIds.size === 0) return 'none'
|
||||
if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete'
|
||||
if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none'
|
||||
if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete'
|
||||
return 'partial'
|
||||
}
|
||||
+109
-27
@@ -28,6 +28,7 @@ export interface OrgEntry {
|
||||
src_filename : string
|
||||
dest_filename: string
|
||||
dest_path : string
|
||||
dest_dir : string
|
||||
}
|
||||
|
||||
// organized.json : Record<hash, Record<episode_id_string, OrgEntry>>
|
||||
@@ -113,13 +114,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,8 +130,12 @@ function buildFileMap(
|
||||
if (!matchedPath) continue
|
||||
const filename = matchedPath.split('/').pop() ?? matchedPath
|
||||
|
||||
const fmtName = ep.formatted_name?.trim()
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
// Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
|
||||
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) {
|
||||
@@ -137,8 +144,8 @@ function buildFileMap(
|
||||
|
||||
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, {
|
||||
@@ -247,6 +254,26 @@ function seasonFolder(n: number): string {
|
||||
return n === 0 ? 'Specials' : `Saison ${n}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un fichier avec retry en cas d'EBUSY (Windows : fichier encore ouvert par qBit/autre).
|
||||
* 5 tentatives × 500 ms = 2,5 s max avant de remonter l'erreur.
|
||||
*/
|
||||
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(`EBUSY sur "${path.basename(filePath)}" — retry ${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) {
|
||||
@@ -264,7 +291,13 @@ function sanitizeDirName(name: string): string {
|
||||
}
|
||||
|
||||
// ─── Organise un torrent ──────────────────────────────────────
|
||||
async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) {
|
||||
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 }[] }
|
||||
|
||||
@@ -289,20 +322,29 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
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'
|
||||
// Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
|
||||
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 +352,12 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
|
||||
if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 }
|
||||
|
||||
// Vérification de complétion pour épisode unique
|
||||
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
|
||||
warn(`Skip (incomplet / priorité 0) : ${filename}`)
|
||||
return { ...result, total: 1, skipped: 1 }
|
||||
}
|
||||
|
||||
const candidates = filePath ? [
|
||||
path.join(savePath, filePath),
|
||||
path.join(completePath, filePath),
|
||||
@@ -324,8 +372,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: `Source introuvable (chemins essayés : ${candidates.join(', ')})` }] }
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dest)) {
|
||||
@@ -339,9 +387,9 @@ 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)
|
||||
await unlinkWithRetry(src)
|
||||
log(`${destName} → Saison ${season.season_number}`)
|
||||
return { ...result, total: 1, done: 1 }
|
||||
}
|
||||
@@ -350,7 +398,7 @@ 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}`)
|
||||
return { ...result, total: 1, done: 1 }
|
||||
@@ -370,6 +418,14 @@ 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 }
|
||||
|
||||
// Vérification de complétion : si on a les infos de progression par fichier,
|
||||
// on skip les fichiers pas encore à 100% (ex: priorité 0 ou en cours de téléchargement).
|
||||
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
|
||||
warn(`Skip (incomplet / priorité 0) : ${filename}`)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
path.join(savePath, fullPath),
|
||||
path.join(completePath, fullPath),
|
||||
@@ -381,8 +437,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
|
||||
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}`)
|
||||
warn(`Skip (fichier absent) : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
@@ -394,7 +449,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,22 +458,27 @@ 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}`)
|
||||
@@ -464,8 +524,30 @@ parentPort?.on('message', async (msg: any) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData)
|
||||
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
|
||||
// Construire l'ensemble des fichiers 100% complets à partir des infos de progression.
|
||||
// On ne filtre QUE si le client fournit des noms de fichiers (qBittorrent, Transmission).
|
||||
// Si aucun fichier n'a de nom (rtorrent…), on passe null → pas de filtrage.
|
||||
let completedFileNames: Set<string> | null = null
|
||||
if (Array.isArray(t.files) && t.files.length > 0) {
|
||||
// Vérifier qu'au moins un fichier a un nom non vide
|
||||
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) => {
|
||||
// f.name est un chemin relatif ex: "Pack S1/Episode1.mkv"
|
||||
// on ne garde que le basename pour comparer au filename du fileMap
|
||||
const parts = String(f.name).replace(/\\/g, '/').split('/')
|
||||
return (parts[parts.length - 1] ?? '').toLowerCase()
|
||||
})
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames)
|
||||
parentPort?.postMessage({ type: 'result', hash: t.hash, 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}`)
|
||||
}
|
||||
|
||||
+115
-5
@@ -38,6 +38,7 @@ interface OrgEntry {
|
||||
src_filename : string
|
||||
dest_filename: string
|
||||
dest_path : string
|
||||
dest_dir? : string // absent dans les anciennes entrées — backfillé au démarrage
|
||||
}
|
||||
|
||||
type Organized = Record<string, Record<string, OrgEntry>>
|
||||
@@ -100,10 +101,17 @@ export async function scanMediaPath(
|
||||
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)
|
||||
// nfo_filename et formatted_name du path en priorité sur ceux de l'épisode
|
||||
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
|
||||
// 2. original_filename, nfo_filename et formatted_name épisode — fallback
|
||||
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)
|
||||
@@ -227,7 +235,7 @@ export async function scanMediaPath(
|
||||
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()
|
||||
if (!autoImport) {
|
||||
@@ -302,6 +310,7 @@ export async function autoOrganizeAll(
|
||||
onResult?.({
|
||||
hash : msg.hash,
|
||||
name : msg.name,
|
||||
serieId : msg.serieId ?? null,
|
||||
done : msg.done,
|
||||
skipped : msg.skipped,
|
||||
errors : msg.errors.length,
|
||||
@@ -327,6 +336,101 @@ export async function autoOrganizeAll(
|
||||
worker.postMessage({ type: 'run', torrents, seriesData })
|
||||
}
|
||||
|
||||
// ── Migration des IDs épisodes ────────────────────────────────
|
||||
//
|
||||
// Les IDs d'épisodes, saisons et séries peuvent changer entre deux passages
|
||||
// du scraper. On utilise (infohash, season_number, episode_number) comme clé
|
||||
// stable pour retrouver le nouvel ID et mettre à jour organized.json.
|
||||
|
||||
export async function migrateOrganizedEpisodeIds(
|
||||
organizedPath: string,
|
||||
seriesData : any[]
|
||||
): Promise<{ updated: number; orphaned: number }> {
|
||||
|
||||
// 1. Construire les tables de lookup depuis le catalogue actuel
|
||||
// hashEpMap : infohash → Map<"S:E", new_episode_id>
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Charger organized.json
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch { 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)) {
|
||||
// ID encore valide, aucun changement
|
||||
newEpisodes[oldIdStr] = entry
|
||||
continue
|
||||
}
|
||||
|
||||
// ID orphelin — chercher le nouvel ID via (hash, saison, épisode)
|
||||
const key = `${entry.season}:${entry.episode}`
|
||||
const newId = epMap?.get(key)
|
||||
|
||||
if (newId && newId !== oldId) {
|
||||
// Vérifier qu'on n'écrase pas une entrée déjà présente avec le nouvel ID
|
||||
if (newEpisodes[String(newId)] || (episodes as any)[String(newId)]) {
|
||||
// Le nouvel ID est déjà présent → l'ancien est un orphelin inutile, on le supprime simplement
|
||||
changed = true
|
||||
logger.info('organize', `Migration ID : ep ${oldId} orphelin supprimé (${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 ID : ep ${oldId} → ${newId} (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
|
||||
}
|
||||
} else {
|
||||
// Introuvable — on conserve pour ne pas perdre l'entrée
|
||||
orphaned++
|
||||
newEpisodes[oldIdStr] = entry
|
||||
logger.warn('organize', `Migration ID : ep ${oldId} introuvable dans le catalogue (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
|
||||
}
|
||||
}
|
||||
|
||||
organized[hash] = newEpisodes
|
||||
// Nettoyer les hash vides
|
||||
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`)
|
||||
} else {
|
||||
logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`)
|
||||
}
|
||||
|
||||
return { updated, orphaned }
|
||||
}
|
||||
|
||||
// ── Sync des noms de fichiers après mise à jour du scraper ────
|
||||
|
||||
export async function syncFilenameChanges(
|
||||
@@ -362,9 +466,15 @@ export async function syncFilenameChanges(
|
||||
? path.basename(orgEntry.dest_path)
|
||||
: orgEntry.dest_filename
|
||||
const srcExt = path.extname(currentName)
|
||||
// Priorité aux valeurs du path entry correspondant au hash organisé
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Système de demandes utilisateurs
|
||||
*/
|
||||
|
||||
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 dispo
|
||||
episodes : number[] // IDs d'épisodes spécifiques ; [] = pas d'épisode ciblé
|
||||
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')
|
||||
|
||||
// ── Helpers fichier ───────────────────────────────────────────
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
/** Union des saisons demandées sur tous les requesters ([] = 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)
|
||||
}
|
||||
|
||||
/** Union des IDs d'épisodes spécifiques sur tous les requesters */
|
||||
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)
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Créer ou fusionner une demande.
|
||||
* - Si une demande active (non rejected/completed) existe pour la série → fusionne les saisons de cet user
|
||||
* - Sinon → crée une nouvelle demande
|
||||
*/
|
||||
export function upsertRequest(
|
||||
userId : string,
|
||||
serieId : number,
|
||||
serieName: string,
|
||||
seasons : number[], // [] = toutes les saisons
|
||||
episodes : number[] = [], // IDs d'épisodes spécifiques
|
||||
): SerieRequest {
|
||||
const user = findById(userId)
|
||||
if (!user) throw new Error('Utilisateur introuvable')
|
||||
|
||||
const requests = readRequests()
|
||||
|
||||
// Chercher une demande active (pas rejected ni completed) pour cette série
|
||||
const existing = requests.find(r =>
|
||||
r.serieId === serieId &&
|
||||
r.status !== 'rejected' &&
|
||||
r.status !== 'completed'
|
||||
)
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
if (existing) {
|
||||
// Mettre à jour ou ajouter ce requester dans la demande existante
|
||||
const idx = existing.requesters.findIndex(r => r.userId === userId)
|
||||
if (idx >= 0) {
|
||||
const r = existing.requesters[idx]
|
||||
// "Tout" = seasons=[] ET episodes=[] (aucune granularité)
|
||||
const newIsAll = seasons.length === 0 && episodes.length === 0
|
||||
const curIsAll = r.seasons.length === 0 && (r.episodes ?? []).length === 0
|
||||
if (newIsAll || curIsAll) {
|
||||
// Dès que l'une des deux est "toutes les saisons" → effacer la granularité
|
||||
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 mise à jour pour "${serieName}" par "${user.username}"`)
|
||||
return existing
|
||||
}
|
||||
|
||||
// Nouvelle demande
|
||||
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 pour "${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))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -19,9 +19,10 @@ router.post('/download', requireAuth, async (req, res) => {
|
||||
const url = torrent_url ?? magnet
|
||||
if (!url) { res.status(400).json({ error: 'torrent_url 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
|
||||
logger.info('api', `Téléchargement demandé via ${torrent_url ? '.torrent' : 'magnet'}${file_index != null ? ` (fichier #${file_index})` : ''}`)
|
||||
const results = await dispatchDownload(url, { file_index, file_path, magnet })
|
||||
const file_path : string | null = req.body.file_path ?? null
|
||||
const infohash : string | null = req.body.infohash ?? null
|
||||
logger.info('api', `Téléchargement demandé via ${torrent_url ? '.torrent' : 'magnet'}${file_path ? ` (${file_path})` : ''}`)
|
||||
const results = await dispatchDownload(url, { file_index, file_path, infohash, magnet })
|
||||
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
|
||||
})
|
||||
|
||||
@@ -48,19 +49,27 @@ 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 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
|
||||
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 ?? [] }
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [], serieName, episodes }
|
||||
}))
|
||||
res.json(enriched)
|
||||
} catch (err) {
|
||||
|
||||
+147
-44
@@ -7,7 +7,10 @@ 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 } 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'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -30,7 +33,13 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
}
|
||||
const { mediaPath, organizeMode, nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serie_id))
|
||||
let sd = await readSerieData(Number(serie_id))
|
||||
if (!sd) {
|
||||
// readSerieData ne couvre que le cache local — fallback sur le catalogue complet
|
||||
// (utile pour les séries sans torrent officiel dont les données sont peut-être absentes du cache individuel)
|
||||
const all = await loadEnrichedSeriesData()
|
||||
sd = all.find((s: any) => s.id === Number(serie_id)) ?? null
|
||||
}
|
||||
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()
|
||||
@@ -50,10 +59,11 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
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
|
||||
@@ -63,10 +73,39 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
const destPath = path.join(destDir, destFilename)
|
||||
try {
|
||||
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
|
||||
|
||||
// Nettoyer toute entrée organized qui référence ce même fichier physique
|
||||
// sous un épisode différent (réassignation → l'ancien enregistrement devient invalide)
|
||||
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)) {
|
||||
delete (organized[h] as any)[epId]
|
||||
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (fichier réassigné)`)
|
||||
}
|
||||
}
|
||||
if (Object.keys(organized[h]).length === 0) delete organized[h]
|
||||
}
|
||||
|
||||
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 + '/')
|
||||
// Si une ancienne version de cet épisode existe à la destination (réassignation),
|
||||
// la supprimer avant le rename pour éviter un état incohérent.
|
||||
if (isInSeriePath && fs.existsSync(destPath) && destPath !== file_path) {
|
||||
fs.unlinkSync(destPath)
|
||||
logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`)
|
||||
// Nettoyer toute entrée organized qui pointait sur ce fichier supprimé
|
||||
for (const [h, eps] of Object.entries(organized)) {
|
||||
for (const [epId, entry] of Object.entries(eps)) {
|
||||
if (entry?.dest_path === destPath) {
|
||||
delete (organized[h] as any)[epId]
|
||||
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (ancien fichier écrasé)`)
|
||||
}
|
||||
}
|
||||
if (Object.keys(organized[h]).length === 0) delete organized[h]
|
||||
}
|
||||
}
|
||||
if (isInSeriePath && !fs.existsSync(destPath)) {
|
||||
fs.renameSync(file_path, destPath)
|
||||
logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`)
|
||||
@@ -136,19 +175,26 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sd = await readSerieData(serieId)
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
const organized = loadOrganizedJson()
|
||||
const organized = loadOrganizedJson()
|
||||
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) continue
|
||||
|
||||
const { needsRename } = computeExpectedName(ep, entry, entryHash, nfoSupport)
|
||||
result[String(ep.id)] = { ...entry, hash: entryHash, needs_rename: needsRename }
|
||||
}
|
||||
}
|
||||
res.json(result)
|
||||
@@ -183,15 +229,8 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -222,6 +261,66 @@ 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) }
|
||||
}
|
||||
delete organized[hash][epId]
|
||||
removed++
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
|
||||
}
|
||||
saveOrganizedJson(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}`))
|
||||
}
|
||||
// Supprimer le dossier de la série s'il existe encore
|
||||
if (serieFolder && fs.existsSync(serieFolder)) {
|
||||
try {
|
||||
fs.rmSync(serieFolder, { recursive: true, force: true })
|
||||
logger.info('api', `Dossier série supprimé : ${serieFolder}`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Impossible de supprimer le dossier série "${serieFolder}" : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Supprimer les demandes "disponible" liées à cette série
|
||||
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', `Désimport série ${serieId} — ${completedRequests.length} demande(s) "disponible" supprimée(s)`)
|
||||
|
||||
logger.info('api', `Désimport 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 ───────────────────────────────────────────
|
||||
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 season = sd.seasons?.find((s: any) => s.id === seasonId)
|
||||
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
|
||||
const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.id)) ?? [])
|
||||
let removed = 0
|
||||
const errors: string[] = []
|
||||
const emptyHashes: string[] = []
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const epId of Object.keys(episodes)) {
|
||||
if (!episodeIds.has(epId)) continue
|
||||
@@ -232,10 +331,16 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
delete organized[hash][epId]
|
||||
removed++
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
||||
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
logger.info('api', `Désimport série ${serieId} — ${removed} épisode(s) retirés`)
|
||||
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` : ''}`)
|
||||
res.json({ ok: true, removed, errors })
|
||||
})
|
||||
|
||||
@@ -256,9 +361,14 @@ router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) =>
|
||||
logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`)
|
||||
}
|
||||
delete organized[foundHash][episodeId]
|
||||
if (Object.keys(organized[foundHash]).length === 0) delete organized[foundHash]
|
||||
const torrentEmpty = Object.keys(organized[foundHash]).length === 0
|
||||
if (torrentEmpty) delete organized[foundHash]
|
||||
saveOrganizedJson(organized)
|
||||
logger.info('api', `Désimport ep ${episodeId}`)
|
||||
// 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}`))
|
||||
}
|
||||
logger.info('api', `Désimport ep ${episodeId}${deleteFile && torrentEmpty ? ` + torrent ${foundHash.slice(0, 8)}… retiré du client` : ''}`)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
@@ -282,20 +392,20 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
|
||||
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 }
|
||||
// Même stratégie que organized/:serieId : on cherche via ep.paths d'abord
|
||||
// pour garantir le bon hash → bon formatted_name dans resolveEpNaming
|
||||
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) 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 })
|
||||
}
|
||||
}
|
||||
@@ -328,15 +438,8 @@ 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 {
|
||||
|
||||
@@ -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 : maxUses ?? 1,
|
||||
note,
|
||||
})
|
||||
res.json(invite)
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
|
||||
}
|
||||
})
|
||||
|
||||
// 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' })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/invites/:code/info (public — pour afficher la 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 via 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: 'username et password requis' }); return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
res.status(400).json({ error: 'Le mot de passe doit faire 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' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Router } from 'express'
|
||||
import { readSettings, writeSettings } from '../settings.js'
|
||||
import { readUsers, createUser, safeUser } from '../users.js'
|
||||
import { testJellyfinConnection, fetchJellyfinUsers } from '../lib/jellyfin.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// GET /api/jellyfin/settings
|
||||
router.get('/jellyfin/settings', (_req, res) => {
|
||||
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
|
||||
res.json({
|
||||
jellyfinUrl,
|
||||
// Ne jamais retourner le token en clair — juste indiquer s'il est configuré
|
||||
hasToken: !!jellyfinAdminToken,
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/jellyfin/settings
|
||||
router.post('/jellyfin/settings', (req, res) => {
|
||||
const { jellyfinUrl, jellyfinAdminToken } = req.body
|
||||
const update: Record<string, string> = {}
|
||||
if (jellyfinUrl !== undefined) update.jellyfinUrl = String(jellyfinUrl).trim()
|
||||
if (jellyfinAdminToken !== undefined) update.jellyfinAdminToken = String(jellyfinAdminToken).trim()
|
||||
const updated = writeSettings(update)
|
||||
res.json({ jellyfinUrl: updated.jellyfinUrl, hasToken: !!updated.jellyfinAdminToken })
|
||||
})
|
||||
|
||||
// POST /api/jellyfin/test
|
||||
router.post('/jellyfin/test', async (_req, res) => {
|
||||
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
|
||||
if (!jellyfinUrl || !jellyfinAdminToken) {
|
||||
res.status(400).json({ ok: false, error: 'URL et token requis' }); return
|
||||
}
|
||||
const result = await testJellyfinConnection(jellyfinUrl, jellyfinAdminToken)
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
export async function runJellyfinSync(): Promise<{ created: number; skipped: number; users: string[] }> {
|
||||
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
|
||||
if (!jellyfinUrl || !jellyfinAdminToken) return { created: 0, skipped: 0, users: [] }
|
||||
|
||||
const jellyfinUsers = await fetchJellyfinUsers()
|
||||
const fankarrUsers = readUsers()
|
||||
const results = { created: 0, skipped: 0, users: [] as string[] }
|
||||
|
||||
for (const jUser of jellyfinUsers) {
|
||||
if (jUser.Policy?.IsDisabled) { results.skipped++; continue }
|
||||
|
||||
const exists = fankarrUsers.some(u =>
|
||||
u.username.toLowerCase() === jUser.Name.toLowerCase()
|
||||
)
|
||||
if (exists) { results.skipped++; continue }
|
||||
|
||||
const randomPass = Math.random().toString(36).slice(2, 10) +
|
||||
Math.random().toString(36).slice(2, 10)
|
||||
createUser(jUser.Name, randomPass, 'user')
|
||||
results.created++
|
||||
results.users.push(jUser.Name)
|
||||
logger.info('jellyfin', `Compte créé pour "${jUser.Name}" (sync Jellyfin)`)
|
||||
}
|
||||
|
||||
logger.info('jellyfin', `Sync terminée — ${results.created} créés, ${results.skipped} ignorés`)
|
||||
return results
|
||||
}
|
||||
|
||||
// POST /api/jellyfin/sync
|
||||
router.post('/jellyfin/sync', async (_req, res) => {
|
||||
try {
|
||||
const results = await runJellyfinSync()
|
||||
res.json(results)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur sync Jellyfin' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,12 +1,26 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { organizeTorrent } from '../organize.js'
|
||||
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
|
||||
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
|
||||
import { recentOrganized, pushNotif } from '../lib/notifs.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 loadEnrichedSeriesData()
|
||||
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
||||
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
logger.error('api', `Migration IDs échouée : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/organize/recent', requireAuth, (_req, res) => {
|
||||
res.json(recentOrganized)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* API publique v1 — pour le plugin Jellyfin et intégrations tierces
|
||||
* Auth : Bearer {apiToken} dans le header Authorization
|
||||
*/
|
||||
|
||||
import { Router } from 'express'
|
||||
import { findByApiToken } from '../users.js'
|
||||
import { validateJellyfinToken } from '../lib/jellyfin.js'
|
||||
import { readUsers, safeUser } from '../users.js'
|
||||
import { upsertRequest, approveRequest, getRequestsForUser } from '../requests.js'
|
||||
import { autoDownloadRequest } from './requests.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { readAvailable } from '../lib/github-cache.js'
|
||||
import { fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode } from '../lib/serie-helpers.js'
|
||||
import { logger } from '../logger.js'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ── Middleware auth API token ─────────────────────────────────
|
||||
|
||||
function requireApiToken(req: Request, res: Response, next: NextFunction): void {
|
||||
const header = req.headers.authorization
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Token requis (Authorization: Bearer <token>)' }); return
|
||||
}
|
||||
const user = findByApiToken(header.slice(7))
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'Token invalide' }); return
|
||||
}
|
||||
req.user = user
|
||||
next()
|
||||
}
|
||||
|
||||
// ── POST /api/v1/auth/jellyfin ────────────────────────────────
|
||||
// Échange un token Jellyfin contre un token FanKarr
|
||||
// Body: { jellyfinUserId, jellyfinToken }
|
||||
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 ?? 'Authentification Jellyfin échouée' }); return
|
||||
}
|
||||
|
||||
// Trouver l'utilisateur FanKarr correspondant (par username)
|
||||
const users = readUsers()
|
||||
const fanUser = users.find(u => u.username.toLowerCase() === info.username!.toLowerCase())
|
||||
if (!fanUser) {
|
||||
logger.warn('public-api', `SSO Jellyfin : aucun compte FanKarr pour "${info.username}" — sync requise`)
|
||||
res.status(404).json({
|
||||
error: `Aucun compte FanKarr pour "${info.username}". Demandez à l'admin de lancer la synchronisation Jellyfin.`,
|
||||
}); return
|
||||
}
|
||||
|
||||
logger.info('public-api', `SSO Jellyfin réussi 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= ─────────────────────────────
|
||||
// Recherche dans le catalogue FanKai
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /api/v1/series/:id ────────────────────────────────────
|
||||
// Détail d'une série : saisons + épisodes (proxy FanKai)
|
||||
router.get('/v1/series/:id', requireApiToken, async (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'id 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' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── POST /api/v1/requests ─────────────────────────────────────
|
||||
// Créer ou mettre à jour une demande
|
||||
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,
|
||||
)
|
||||
|
||||
// Auto-approbation + téléchargement si l'utilisateur est autorisé
|
||||
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', `Auto-dl échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
}
|
||||
|
||||
res.json(request)
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /api/v1/requests ──────────────────────────────────────
|
||||
// Demandes de l'utilisateur authentifié
|
||||
router.get('/v1/requests', requireApiToken, (req, res) => {
|
||||
res.json(getRequestsForUser(req.user!.id))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAdmin } from '../auth.js'
|
||||
import {
|
||||
readRequests, upsertRequest, approveRequest,
|
||||
rejectRequest, completeRequest, deleteRequest,
|
||||
getRequestsForUser, getPendingCount, mergedSeasons, mergedEpisodes,
|
||||
type SerieRequest,
|
||||
} from '../requests.js'
|
||||
import { dispatchDownload } from '../torrent-clients/index.js'
|
||||
import { readSerieData } from '../lib/github-cache.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// GET /api/requests — admin voit tout, user voit les siennes
|
||||
router.get('/requests', (req, res) => {
|
||||
const list = req.user!.role === 'admin'
|
||||
? readRequests()
|
||||
: getRequestsForUser(req.user!.id)
|
||||
res.json(list)
|
||||
})
|
||||
|
||||
// GET /api/requests/pending-count — pour le widget dashboard
|
||||
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 })
|
||||
})
|
||||
|
||||
// POST /api/requests — créer ou mettre à jour une demande
|
||||
router.post('/requests', (req, res) => {
|
||||
const { serieId, serieName, seasons, episodes, torrentOverride } = 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,
|
||||
)
|
||||
|
||||
// Auto-approbation + téléchargement si l'utilisateur est autorisé
|
||||
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) {
|
||||
// L'utilisateur a sélectionné un torrent spécifique → l'envoyer directement
|
||||
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', `Auto-dl (override) échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
} else {
|
||||
// Pas de torrent sélectionné → résolution automatique
|
||||
const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes }
|
||||
autoDownloadRequest(request, dlOverride).catch(err =>
|
||||
logger.warn('requests', `Auto-dl échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
res.json(request)
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
|
||||
}
|
||||
})
|
||||
|
||||
// PATCH /api/requests/:id — admin : approve / reject / complete
|
||||
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)
|
||||
// Approbation manuelle par l'admin → toujours lancer le téléchargement
|
||||
autoDownloadRequest(request).catch(err =>
|
||||
logger.warn('requests', `Auto-dl échoué pour "${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 invalide (approve | reject | complete)' }); return }
|
||||
res.json(request)
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Trouve et envoie au client torrent les fichiers correspondant à la demande.
|
||||
* @param override - si fourni, utilise ces saisons/épisodes au lieu du merged de la demande
|
||||
* (utile pour ne télécharger que ce qui vient d'être ajouté)
|
||||
*/
|
||||
export async function autoDownloadRequest(req: SerieRequest, override?: { seasons: number[]; episodes: number[] }): Promise<void> {
|
||||
const serieData = await readSerieData(req.serieId)
|
||||
if (!serieData) {
|
||||
logger.warn('requests', `Auto-dl : aucune donnée torrent pour série ${req.serieId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const seasons = override !== undefined ? override.seasons : mergedSeasons(req)
|
||||
const episodes = override !== undefined ? override.episodes : mergedEpisodes(req)
|
||||
|
||||
type TorrentEntry = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null }
|
||||
const toDownload: TorrentEntry[] = []
|
||||
const seenHashes = new Set<string>()
|
||||
|
||||
// Tous les torrents pack (série + saisons) pour résoudre les ep.paths
|
||||
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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute le premier torrent disponible par épisode (individuel ou via file_index dans un pack).
|
||||
* On prend le premier uniquement pour éviter les doublons quand plusieurs sources existent.
|
||||
*/
|
||||
function addEpisodesByIndex(epList: any[]) {
|
||||
for (const ep of epList) {
|
||||
if (ep.torrents?.length > 0) {
|
||||
// Premier torrent individuel disponible
|
||||
addTorrent(ep.torrents[0])
|
||||
} else if (ep.paths?.length > 0) {
|
||||
// Premier path dans un pack
|
||||
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) {
|
||||
// Premier pack saison disponible → télécharger en bloc
|
||||
addTorrent(season.torrents[0])
|
||||
} else {
|
||||
// Pas de pack → épisode par épisode avec file_index
|
||||
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)) {
|
||||
// Packs saison (premier par saison)
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
if (season.torrents?.length > 0) addTorrent(season.torrents[0])
|
||||
}
|
||||
} else {
|
||||
// Fallback : épisode par épisode avec file_index
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
addEpisodesByIndex(season.episodes ?? [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toDownload.length === 0) {
|
||||
logger.warn('requests', `Auto-dl : 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 {
|
||||
await dispatchDownload(url, { infohash: t.infohash ?? undefined, magnet: t.magnet ?? undefined, file_index: t.file_index ?? null, file_path: t.file_path ?? null })
|
||||
sent++
|
||||
} catch (err) {
|
||||
logger.warn('requests', `Auto-dl erreur torrent : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
logger.info('requests', `Auto-dl "${req.serieName}" — ${sent}/${toDownload.length} torrent(s) envoyé(s)`)
|
||||
}
|
||||
|
||||
// DELETE /api/requests — admin : supprimer toutes les demandes
|
||||
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 })
|
||||
})
|
||||
|
||||
// DELETE /api/requests/:id — admin seulement
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { loadSynced, setSync, isSynced, runRssSync } from '../lib/rss-sync.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** GET /api/rss-sync → liste des séries surveillées */
|
||||
router.get('/rss-sync', requireAuth, (_req, res) => {
|
||||
res.json(Object.values(loadSynced()))
|
||||
})
|
||||
|
||||
/** POST /api/rss-sync/run → forcer un cycle de sync maintenant (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', `Sync forcé échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
/** GET /api/rss-sync/:id → statut sync pour une série */
|
||||
router.get('/rss-sync/:id', requireAuth, (req, res) => {
|
||||
const serieId = Number(req.params.id)
|
||||
res.json({ synced: isSynced(serieId) })
|
||||
})
|
||||
|
||||
/** POST /api/rss-sync/:id → activer la surveillance */
|
||||
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: 'serieId et serieName requis' }); return }
|
||||
const map = setSync(serieId, serieName, true)
|
||||
logger.info('rss-sync', `Surveillance activée pour "${serieName}" (id ${serieId})`)
|
||||
res.json({ synced: true, total: Object.keys(map).length })
|
||||
})
|
||||
|
||||
/** DELETE /api/rss-sync/:id → désactiver la surveillance */
|
||||
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
|
||||
@@ -10,6 +10,7 @@ import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cac
|
||||
import {
|
||||
fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode,
|
||||
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
|
||||
deduplicateEpisodes,
|
||||
} from '../lib/serie-helpers.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -40,7 +41,7 @@ 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 serie = { ...normalizeSerie(serieRaw), wiki: serieData?.wiki ?? null }
|
||||
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`)
|
||||
@@ -99,7 +100,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
|
||||
@@ -124,7 +125,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 +134,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 +161,9 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
organized: organizedEpisodeIds.has(ep.id),
|
||||
}
|
||||
})
|
||||
// Dédoublonnage : plusieurs variantes (x264/x265…) du même episode_number
|
||||
// sont fusionnées en un seul épisode avec la liste de torrents combinée
|
||||
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
|
||||
|
||||
+11
-10
@@ -10,17 +10,18 @@ router.get('/settings', requireAuth, (_req, res) => {
|
||||
})
|
||||
|
||||
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 } = 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
|
||||
const updated = writeSettings(patch)
|
||||
logger.info('api', 'Paramètres mis à jour')
|
||||
res.json(updated)
|
||||
|
||||
+27
-2
@@ -47,11 +47,36 @@ router.get('/version', (_req, res) => {
|
||||
|
||||
// ── Parcours fichiers ──────────────────────────────────────────
|
||||
router.get('/browse', requireAuth, (req, res) => {
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
const isWindows = process.platform === 'win32'
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
|
||||
// ── Racine virtuelle Windows : liste les lecteurs disponibles ──
|
||||
if (isWindows && dirPath === '/') {
|
||||
const drives: string[] = []
|
||||
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
||||
const d = `${letter}:\\`
|
||||
try { fs.readdirSync(d); drives.push(d) } catch {}
|
||||
}
|
||||
res.json({ path: '/', parent: null, dirs: drives, drivesRoot: true })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b))
|
||||
res.json({ path: dirPath, parent: dirPath === '/' ? null : path.dirname(dirPath), dirs })
|
||||
|
||||
// Sur Windows, remonter à '/' quand on est à la racine d'un lecteur (ex: C:\)
|
||||
let parent: string | null
|
||||
if (dirPath === '/') {
|
||||
parent = null
|
||||
} else if (isWindows && /^[A-Za-z]:\\?$/.test(dirPath)) {
|
||||
parent = '/'
|
||||
} else {
|
||||
const up = path.dirname(dirPath)
|
||||
parent = up === dirPath ? null : up // dirname de C:\ retourne C:\ → null
|
||||
}
|
||||
|
||||
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' })
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Router } from 'express'
|
||||
import {
|
||||
readUsers, createUser, updateUser, deleteUser,
|
||||
regenerateApiToken, safeUser,
|
||||
} from '../users.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// GET /api/users
|
||||
router.get('/users', (_req, res) => {
|
||||
res.json(readUsers().map(safeUser))
|
||||
})
|
||||
|
||||
// POST /api/users
|
||||
router.post('/users', (req, res) => {
|
||||
const { username, password, role } = req.body
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'username et password requis' }); return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
res.status(400).json({ error: 'Le mot de passe doit faire 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' })
|
||||
}
|
||||
})
|
||||
|
||||
// PATCH /api/users/:id
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/users/:id
|
||||
router.delete('/users/:id', (req, res) => {
|
||||
// Empêche l'admin de se supprimer lui-même
|
||||
if (req.user?.id === req.params.id) {
|
||||
res.status(400).json({ error: 'Impossible de supprimer son 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' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/users/:id/regenerate-token
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
+15
-9
@@ -7,23 +7,29 @@ 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[] // IDs users autorisés au dl auto sur approbation ; [] = désactivé
|
||||
jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096)
|
||||
jellyfinAdminToken : string // Token admin Jellyfin pour la sync
|
||||
}
|
||||
|
||||
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 : '',
|
||||
}
|
||||
|
||||
export function readSettings(): Settings {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Deluge Web JSON-RPC driver
|
||||
*
|
||||
* Auth : POST {url}/json → auth.login(password) → cookie _session_id
|
||||
* RPC : POST {url}/json avec { method, params, id } + Cookie header
|
||||
* States : "Downloading" | "Seeding" | "Paused" | "Checking"
|
||||
* "Error" | "Queued" | "Allocating" | "Moving"
|
||||
* Progress : float 0–100 (déjà en pourcentage, pas 0–1)
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// ─── Session cache ─────────────────────────────────────────────────────────────
|
||||
// Chaque entrée est indexée par "url::password" pour supporter plusieurs instances.
|
||||
// TTL 30 min — Deluge invalide les sessions inactives, on relogine automatiquement.
|
||||
|
||||
interface SessionEntry { cookie: string; expires: number }
|
||||
const _sessions = new Map<string, SessionEntry>()
|
||||
|
||||
function sessionKey(config: Record<string, string | number>): string {
|
||||
return `${config.url}::${config.password}`
|
||||
}
|
||||
|
||||
// ─── Helpers HTTP ──────────────────────────────────────────────────────────────
|
||||
|
||||
function delugeUrl(config: Record<string, string | number>): string {
|
||||
return String(config.url ?? '').replace(/\/+$/, '') + '/json'
|
||||
}
|
||||
|
||||
function parseCookie(raw: string): string {
|
||||
// "set-cookie: _session_id=abc123; Path=/; HttpOnly" → "_session_id=abc123"
|
||||
return raw.split(';')[0].trim()
|
||||
}
|
||||
|
||||
async function delugeRPC(
|
||||
url : string,
|
||||
method : string,
|
||||
params : unknown[],
|
||||
cookie : string,
|
||||
): Promise<{ result: any; newCookie: string | null }> {
|
||||
const res = await fetch(url, {
|
||||
method : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept' : 'application/json',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
},
|
||||
body: JSON.stringify({ method, params, id: 1 }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`Deluge HTTP ${res.status} : ${text}`)
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
if (json.error) {
|
||||
throw new Error(`Deluge RPC : ${json.error.message ?? JSON.stringify(json.error)}`)
|
||||
}
|
||||
|
||||
const setCookie = res.headers.get('set-cookie')
|
||||
return {
|
||||
result : json.result,
|
||||
newCookie: setCookie ? parseCookie(setCookie) : null,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Login ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function delugeLogin(config: Record<string, string | number>): Promise<string> {
|
||||
const url = delugeUrl(config)
|
||||
const res = await fetch(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) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`Deluge HTTP ${res.status} : ${text}`)
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
if (json.error) throw new Error(`Deluge login : ${json.error.message ?? JSON.stringify(json.error)}`)
|
||||
if (!json.result) throw new Error('Deluge : authentification échouée (mot de passe incorrect ?)')
|
||||
|
||||
const setCookie = res.headers.get('set-cookie')
|
||||
if (!setCookie) throw new Error('Deluge : pas de cookie de session reçu après login')
|
||||
|
||||
return parseCookie(setCookie)
|
||||
}
|
||||
|
||||
// ─── Call avec gestion de session ─────────────────────────────────────────────
|
||||
|
||||
async function delugeCall(
|
||||
config : Record<string, string | number>,
|
||||
method : string,
|
||||
params : unknown[] = [],
|
||||
): Promise<any> {
|
||||
const url = delugeUrl(config)
|
||||
const key = sessionKey(config)
|
||||
|
||||
// Récupère ou rafraîchit la session
|
||||
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(url, method, params, entry.cookie)
|
||||
// Mise à jour du cookie si Deluge en renvoie un nouveau
|
||||
if (newCookie) {
|
||||
entry.cookie = newCookie
|
||||
entry.expires = Date.now() + 30 * 60_000
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
// Session expirée → relogin unique
|
||||
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(url, method, params, cookie)
|
||||
return result
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mapping état ──────────────────────────────────────────────────────────────
|
||||
|
||||
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 (asynchrone, best-effort) ──────────────────────────
|
||||
// Attend que les métadonnées soient disponibles (~60 s max) puis applique
|
||||
// les priorités : fichier voulu = 1 (normal), tous les autres = 0 (skip).
|
||||
|
||||
async function applyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
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 ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
logger.warn('deluge', `Timeout : priorité fichier non appliquée pour ${hash.slice(0, 8)}…`)
|
||||
}
|
||||
|
||||
// ─── Driver ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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 (Label plugin)', 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 },
|
||||
],
|
||||
},
|
||||
|
||||
// ── Test ───────────────────────────────────────────────────────────────────
|
||||
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 inconnue'
|
||||
logger.warn('deluge', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
// ── Healthcheck ────────────────────────────────────────────────────────────
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
// daemon.get_version() retourne la version du daemon Deluge
|
||||
const version = await delugeCall(config, 'daemon.get_version')
|
||||
return { online: true, version: String(version ?? 'inconnue') }
|
||||
} catch {
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
// ── List ───────────────────────────────────────────────────────────────────
|
||||
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',
|
||||
]
|
||||
// filter_dict vide → tous les torrents ; le filtrage par label se fait en JS
|
||||
// pour ne pas dépendre du Label plugin côté serveur.
|
||||
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 ?? ''),
|
||||
}))
|
||||
},
|
||||
|
||||
// ── Add ────────────────────────────────────────────────────────────────────
|
||||
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?.slice(0, 8) ?? '?'}…`)
|
||||
} else {
|
||||
// Télécharge le .torrent puis l'envoie en base64 via core.add_torrent_file
|
||||
const torrentRes = await fetch(url)
|
||||
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent : ${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', `.torrent uploadé → ${hash?.slice(0, 8) ?? '?'}…`)
|
||||
}
|
||||
|
||||
// Label plugin — best-effort (silencieux si plugin absent)
|
||||
if (config.category && hash) {
|
||||
delugeCall(config, 'label.set_torrent', [hash, String(config.category)]).catch(() => {})
|
||||
}
|
||||
|
||||
// Sélection de fichier — asynchrone, best-effort
|
||||
if (options?.file_index != null && hash) {
|
||||
applyFilePriority(config, hash, options.file_index).catch(() => {})
|
||||
}
|
||||
},
|
||||
|
||||
// ── Remove ─────────────────────────────────────────────────────────────────
|
||||
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)' : ''}`)
|
||||
},
|
||||
|
||||
// ── Get files ──────────────────────────────────────────────────────────────
|
||||
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
|
||||
@@ -36,7 +36,9 @@ export interface SavedClient {
|
||||
|
||||
export interface TorrentFileProgress {
|
||||
index : number
|
||||
progress: number // 0–100
|
||||
name ?: string // chemin relatif dans le torrent (ex: "Pack S1/Episode1.mkv") — optionnel selon le client
|
||||
progress: number // 0–1
|
||||
priority: number // 0 = do not download, 1 = normal, 6 = high, 7 = max
|
||||
}
|
||||
|
||||
export interface TorrentInfo {
|
||||
@@ -59,7 +61,8 @@ export interface TorrentInfo {
|
||||
export interface DownloadOptions {
|
||||
file_index?: number | null
|
||||
file_path ?: string | null
|
||||
magnet ?: string | null // conservé pour extraire le hash même quand on envoie le .torrent
|
||||
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
|
||||
}
|
||||
|
||||
export interface TorrentClientDriver {
|
||||
@@ -69,6 +72,7 @@ export interface TorrentClientDriver {
|
||||
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>
|
||||
getFiles ?: (config: Record<string, string | number>, hash: string) => Promise<TorrentFileProgress[]>
|
||||
}
|
||||
|
||||
// ─── Registry ─────────────────────────────────────────────────────────────────
|
||||
@@ -340,4 +344,27 @@ export async function dispatchList(
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ─── Dispatch file progress ────────────────────────────────────────────────────
|
||||
// Retourne la liste des fichiers d'un torrent avec leur progression (0–1).
|
||||
// clientUuid optionnel : si fourni, interroge uniquement ce client.
|
||||
|
||||
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,22 @@
|
||||
/**
|
||||
* qBittorrent Driver
|
||||
* Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication)
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// qBittorrent répond normalement "Ok." mais certains setups (reverse proxy, etc.)
|
||||
// peuvent wrapper la réponse en JSON — on accepte les deux
|
||||
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,6 +26,15 @@ function mapState(state: string): TorrentInfo['state'] {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
// Retourne les headers d'authentification : Bearer (≥5.2.0) ou cookie SID
|
||||
async function qbAuth(config: Record<string, string | number>): Promise<Record<string, string>> {
|
||||
if (config.apiKey) {
|
||||
return { Authorization: `Bearer ${config.apiKey}` }
|
||||
}
|
||||
const cookie = await qbLogin(config)
|
||||
return { Cookie: cookie }
|
||||
}
|
||||
|
||||
async function qbLogin(config: Record<string, string | number>): Promise<string> {
|
||||
const form = new URLSearchParams()
|
||||
form.append('username', String(config.username ?? ''))
|
||||
@@ -26,18 +47,18 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
|
||||
if (!res.ok) throw new Error(`Login échoué : ${text.trim() || 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: Record<string, string | number>, 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}` },
|
||||
headers: authHeaders,
|
||||
})
|
||||
if (!res.ok) return false
|
||||
const list = await res.json()
|
||||
@@ -48,79 +69,71 @@ async function qbTorrentExists(config: Record<string, string | number>, sid: str
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend que les métadonnées soient disponibles, applique les priorités, puis reprend le torrent.
|
||||
* Utilisé après un ajout avec stopCondition=MetadataReceived (qBit actif → auto-pause après metadata)
|
||||
* ou pour un torrent déjà présent (resume=false, juste mise à jour de priorité).
|
||||
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
|
||||
* puis applique les priorités (désactive tout sauf la cible).
|
||||
*/
|
||||
async function qbApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
resume : boolean,
|
||||
): Promise<void> {
|
||||
// 60 tentatives × 500 ms = 30 s max
|
||||
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
|
||||
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 authH = await qbAuth(config)
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
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)`)
|
||||
}
|
||||
|
||||
// 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.
|
||||
const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0)
|
||||
|
||||
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
|
||||
if (isInitialState) {
|
||||
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
|
||||
if (unwanted.length > 0) {
|
||||
const form = new FormData()
|
||||
form.append('hash', hash)
|
||||
form.append('id', unwanted.join('|'))
|
||||
form.append('priority', '0')
|
||||
await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body: form, headers: { Cookie: `SID=${sid}` } })
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
// Activer le fichier cible (dans tous les cas)
|
||||
const form2 = new FormData()
|
||||
form2.append('hash', hash)
|
||||
form2.append('id', String(fileIndex))
|
||||
form2.append('priority', '1')
|
||||
await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body: form2, headers: { Cookie: `SID=${sid}` } })
|
||||
const r2 = await fetch(`${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 }
|
||||
|
||||
// Reprendre (torrent auto-mis en pause par stopCondition=MetadataReceived)
|
||||
// On appelle les deux endpoints : resume (qBit ≤ 4.x) et start (qBit ≥ 5.0)
|
||||
if (resume) {
|
||||
const resumeForm = new FormData()
|
||||
resumeForm.append('hashes', hash)
|
||||
await Promise.allSettled([
|
||||
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeForm, headers: { Cookie: `SID=${sid}` } }),
|
||||
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body: resumeForm, headers: { Cookie: `SID=${sid}` } }),
|
||||
])
|
||||
// Vérification : le fichier cible est bien à priorité > 0
|
||||
const verif = await fetch(`${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`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné${resume ? ' + reprise' : ''} pour ${hash.slice(0, 8)}…`)
|
||||
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
|
||||
if (resume) {
|
||||
try {
|
||||
const sid = await qbLogin(config)
|
||||
const resumeForm = new FormData()
|
||||
resumeForm.append('hashes', hash)
|
||||
await Promise.allSettled([
|
||||
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeForm, headers: { Cookie: `SID=${sid}` } }),
|
||||
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body: resumeForm, headers: { Cookie: `SID=${sid}` } }),
|
||||
])
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('hors limites')) throw err
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
|
||||
@@ -132,8 +145,9 @@ const QB: TorrentClientDriver = {
|
||||
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: 'apiKey', label: 'Clé API (≥5.2.0)', type: 'password', placeholder: 'qbt_xxxx…', required: false },
|
||||
{ key: 'username', label: 'Identifiant', 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 cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||
@@ -143,7 +157,9 @@ const QB: TorrentClientDriver = {
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
await qbLogin(config)
|
||||
const authH = await qbAuth(config)
|
||||
const res = await fetch(`${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) {
|
||||
@@ -155,12 +171,13 @@ const QB: TorrentClientDriver = {
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const sid = await qbLogin(config)
|
||||
const authH = await qbAuth(config)
|
||||
const res = await fetch(`${config.url}/api/v2/app/version`, {
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) return { online: false }
|
||||
const version = (await res.text()).trim()
|
||||
if (version.startsWith('<')) throw new Error('Réponse HTML reçue — clé API invalide ou non supportée par cette version de qBittorrent')
|
||||
logger.debug('qbittorrent', `Healthcheck OK — version ${version}`)
|
||||
return { online: true, version }
|
||||
} catch (err) {
|
||||
@@ -170,11 +187,11 @@ const QB: TorrentClientDriver = {
|
||||
},
|
||||
|
||||
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}` },
|
||||
headers: authH,
|
||||
})
|
||||
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
|
||||
const data: any[] = await res.json()
|
||||
@@ -184,7 +201,7 @@ const QB: TorrentClientDriver = {
|
||||
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}` },
|
||||
headers: authH,
|
||||
})
|
||||
if (r.ok) fileMap.set(t.hash, await r.json())
|
||||
} catch {}
|
||||
@@ -207,78 +224,145 @@ 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 getFiles(config, hash) {
|
||||
const authH = await qbAuth(config)
|
||||
const res = await fetch(`${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 sid = await qbLogin(config)
|
||||
const authH = await qbAuth(config)
|
||||
|
||||
// Extraire le hash depuis l'URL (si magnet) ou depuis options.magnet (si .torrent URL)
|
||||
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
const hash = hashMatch?.[1]?.toLowerCase() ?? null
|
||||
// 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()
|
||||
|| null
|
||||
|
||||
if (options?.file_index != null && hash) {
|
||||
const exists = await qbTorrentExists(config, sid, hash)
|
||||
|
||||
if (exists) {
|
||||
// Torrent déjà présent → mettre à jour la priorité directement (async OK)
|
||||
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}`)
|
||||
)
|
||||
return
|
||||
if (options?.file_index != null) {
|
||||
if (hash) {
|
||||
const exists = await qbTorrentExists(config, authH, hash)
|
||||
if (exists) {
|
||||
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
|
||||
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Nouveau torrent → ajout actif avec stopCondition=MetadataReceived
|
||||
// qBit fetch les métadonnées normalement via DHT/peers,
|
||||
// puis se met en pause automatiquement dès qu'elles sont disponibles.
|
||||
// On set les priorités sur la pause, puis on reprend. Zéro data indésirable.
|
||||
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('stopCondition', 'MetadataReceived') // qBit ≥ 4.6
|
||||
// Snapshot des hashes avant ajout (pour retrouver le nouveau si hash inconnu)
|
||||
let knownHashes: Set<string> = new Set()
|
||||
if (!hash) {
|
||||
try {
|
||||
const lr = await fetch(`${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 {}
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
|
||||
})
|
||||
// Stratégie d'ajout selon le type de lien :
|
||||
// magnet link → URLSearchParams (application/x-www-form-urlencoded), pas de binaire
|
||||
// .torrent URL → FormData multipart obligatoire pour uploader le blob binaire
|
||||
// (qBit n'a pas forcément accès à internet / nyaa.si depuis son réseau Docker)
|
||||
const isMagnet = url.startsWith('magnet:')
|
||||
let res: Response
|
||||
|
||||
if (isMagnet) {
|
||||
const params = new URLSearchParams()
|
||||
params.set('urls', url)
|
||||
if (config.category) params.set('category', String(config.category))
|
||||
if (config.savePath) params.set('savepath', String(config.savePath))
|
||||
res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
method: 'POST', body: params,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
} else {
|
||||
// FanKarr télécharge le .torrent et l'envoie directement à qBit
|
||||
logger.debug('qbittorrent', `Téléchargement .torrent via 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 .torrent (${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 fetch(`${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(`Ajout échoué : ${text}`)
|
||||
|
||||
logger.info('qbittorrent', `Torrent ajouté (stopCondition=MetadataReceived, 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}`)
|
||||
)
|
||||
if (hash) {
|
||||
logger.info('qbittorrent', `Torrent ajouté — sélection fichier ${options.file_index} en attente des métadonnées`)
|
||||
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('qbittorrent', `Priorité 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})`)
|
||||
const fileIndex = options.file_index
|
||||
;(async () => {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
const ah = await qbAuth(config)
|
||||
const lr = await fetch(`${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)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
logger.warn('qbittorrent', `Impossible de résoudre le hash pour appliquer la priorité fichier ${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))
|
||||
// Pas de sélection de fichier → ajout normal (URLSearchParams, pas de binaire)
|
||||
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}` },
|
||||
method: 'POST', body: params,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
const text = await res.text()
|
||||
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||
if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${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 authH = await qbAuth(config)
|
||||
const params = new URLSearchParams({ hashes: hash, deleteFiles: deleteFiles ? 'true' : 'false' })
|
||||
const res = await fetch(`${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}`)
|
||||
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Real-Debrid Driver
|
||||
*
|
||||
* Real-Debrid est un service debrid cloud : les torrents téléchargent sur leurs serveurs,
|
||||
* puis les fichiers sont accessibles via des liens directs (HTTPS).
|
||||
*
|
||||
* Workflow FanKarr :
|
||||
* 1. add() → POST /torrents/addMagnet puis selectFiles si file_index fourni
|
||||
* 2. list() → GET /torrents (filtre par status=downloaded pour state='seeding')
|
||||
* 3. remove() → DELETE /torrents/delete/{id}
|
||||
*
|
||||
* Le save_path de chaque torrent est construit à partir du localPath configuré
|
||||
* (dossier où l'utilisateur télécharge ses fichiers RD via FUSE, WebDAV ou autre).
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
|
||||
|
||||
function rdHeaders(config: Record<string, string | number>) {
|
||||
return {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
}
|
||||
|
||||
async function rdFetch(
|
||||
config : Record<string, string | number>,
|
||||
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.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`Real-Debrid HTTP ${res.status} : ${text}`)
|
||||
}
|
||||
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 local (FUSE/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 inconnue'
|
||||
logger.warn('real-debrid', `Test de connexion échoué : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const user = await rdFetch(config, 'GET', '/user')
|
||||
return { online: true, version: `RD — ${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
|
||||
|
||||
// Vérifier si le torrent est déjà présent sur RD
|
||||
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 RD`)
|
||||
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 {
|
||||
// URL de fichier .torrent : télécharger puis uploader
|
||||
const torrentRes = await fetch(url)
|
||||
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${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(`Ajout .torrent échoué : ${uploadRes.status}`)
|
||||
const data = await uploadRes.json()
|
||||
torrentId = data.id
|
||||
logger.info('real-debrid', `.torrent uploadé → ID ${torrentId}`)
|
||||
}
|
||||
|
||||
// Sélection des fichiers
|
||||
if (options?.file_index != null) {
|
||||
// Attendre que la liste des fichiers soit disponible (état waiting_files_selection)
|
||||
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 ${options.file_index} sélectionné sur torrent RD ${torrentId}`)
|
||||
}
|
||||
} else if (files.length > 0) {
|
||||
// Sélectionner tous les fichiers si index hors limites
|
||||
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
|
||||
new URLSearchParams({ files: 'all' }))
|
||||
}
|
||||
} else {
|
||||
// Pas de sélection spécifique : tous les fichiers
|
||||
// Attendre waiting_files_selection pour envoyer "all"
|
||||
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
|
||||
// Construire un save_path "virtuel" à partir du localPath configuré + nom du torrent
|
||||
const save_path = localBase ? `${localBase}/${t.filename ?? t.id}` : ''
|
||||
|
||||
// Les liens sont les fichiers téléchargés (disponibles si status=downloaded)
|
||||
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?) {
|
||||
// Retrouver l'ID RD depuis le 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) 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
|
||||
@@ -244,7 +244,7 @@ const RT: TorrentClientDriver = {
|
||||
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 +277,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
|
||||
|
||||
@@ -2,21 +2,87 @@
|
||||
* Synology Download Station Driver
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
|
||||
// ─── Cache URI → infohash ──────────────────────────────────────────────────────
|
||||
// Synology n'expose pas toujours le hash BT via l'API (ancien DSM).
|
||||
// On persiste l'association URI → infohash au moment du add() pour la réutiliser
|
||||
// dans list() via le champ additional.detail.uri de chaque tâche.
|
||||
|
||||
const URI_HASH_PATH = path.join(DATA_DIR, 'synology_uri_hash.json')
|
||||
|
||||
// ─── Cache hash BT → ID Synology (en mémoire) ─────────────────────────────────
|
||||
// Synology utilise des IDs internes (dbid_X), pas des infohash BT.
|
||||
// Quand list() réussit à résoudre le vrai hash BT, on mémorise l'association
|
||||
// infohash → dbid_X pour que remove() / getFiles() puissent retrouver la tâche
|
||||
// même quand detail.hash et detail.uri sont vides.
|
||||
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 // déjà connu, pas besoin d'écrire
|
||||
_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. Magnet URI dans detail.uri → extraction directe du hash (hex 40 chars)
|
||||
if (detailUri.startsWith('magnet:')) {
|
||||
const m = detailUri.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||
if (m) return m[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 3. Notre cache URI → infohash (stocké au moment du add())
|
||||
if (detailUri) {
|
||||
const cached = _uriToHash.get(detailUri.toLowerCase())
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
// 4. Fallback : ID Synology natif (déjà au format "dbid_X")
|
||||
logger.debug('synology-ds', `Hash BT non résolu — fallback ID Synology : ${taskId}`)
|
||||
return String(taskId ?? '').toLowerCase()
|
||||
}
|
||||
|
||||
// ─── State mapping ─────────────────────────────────────────────────────────────
|
||||
|
||||
// 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
|
||||
// ─── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function dsLogin(config: Record<string, string | number>): Promise<string> {
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.API.Auth',
|
||||
@@ -27,7 +93,6 @@ 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 data = await res.json()
|
||||
@@ -51,18 +116,20 @@ async function dsRequest(
|
||||
return data.data
|
||||
}
|
||||
|
||||
// ─── Driver ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 },
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -92,10 +159,8 @@ const DS: TorrentClientDriver = {
|
||||
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,71 +168,167 @@ 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
|
||||
})
|
||||
return tasks.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 ?? ''),
|
||||
)
|
||||
|
||||
// Mémoriser l'association hash BT → ID Synology (pour remove/getFiles)
|
||||
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', 'Sélection de fichier non supportée — téléchargement complet')
|
||||
|
||||
// Mémoriser l'association URI → infohash AVANT l'envoi (le hash est connu côté FanKarr)
|
||||
if (options?.infohash) {
|
||||
storeUriHash(url, options.infohash)
|
||||
logger.debug('synology-ds', `URI→hash mémorisé : ${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)
|
||||
const makeBody = (withDestination: boolean) => {
|
||||
const body = new URLSearchParams({
|
||||
api : 'SYNO.DownloadStation.Task',
|
||||
version: '1',
|
||||
method : 'create',
|
||||
_sid : sid,
|
||||
uri : url,
|
||||
})
|
||||
if (withDestination && config.savePath) body.append('destination', String(config.savePath))
|
||||
return body
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.DownloadStation.Task',
|
||||
version: '1',
|
||||
method : 'create',
|
||||
_sid : sid,
|
||||
...extra,
|
||||
})
|
||||
const doRequest = async (body: URLSearchParams) => {
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi`, {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body : body.toString(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
let data = await doRequest(makeBody(true))
|
||||
|
||||
// Code 403 = destination inexistante sur le NAS → réessai sans destination
|
||||
if (!data.success && data.error?.code === 403 && config.savePath) {
|
||||
logger.warn('synology-ds', `Dossier cible "${config.savePath}" introuvable (code 403) — ajout sans destination`)
|
||||
data = await doRequest(makeBody(false))
|
||||
}
|
||||
|
||||
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)}`)
|
||||
|
||||
logger.info('synology-ds', `Torrent ajouté avec succès${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())
|
||||
|
||||
const h = hash.toLowerCase()
|
||||
const synoIdFromHash = _hashToSynoId.get(h) ?? ''
|
||||
const found = tasks.find((t: any) => {
|
||||
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
|
||||
const detailUri = String(t.additional?.detail?.uri ?? '').toLowerCase()
|
||||
const synoId = String(t.id ?? '').toLowerCase()
|
||||
const cachedHash = _uriToHash.get(detailUri) ?? ''
|
||||
return detailHash === h
|
||||
|| synoId === h
|
||||
|| cachedHash === h
|
||||
|| (synoIdFromHash && synoId === synoIdFromHash)
|
||||
})
|
||||
|
||||
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.DownloadStation.Task',
|
||||
version: '1',
|
||||
method : 'delete',
|
||||
id : found.id,
|
||||
force_complete: deleteFiles ? 'false' : 'false',
|
||||
_sid : sid,
|
||||
api : 'SYNO.DownloadStation.Task',
|
||||
version : '1',
|
||||
method : 'delete',
|
||||
id : found.id,
|
||||
force_complete: 'false',
|
||||
_sid : sid,
|
||||
})
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
@@ -175,4 +336,4 @@ const DS: TorrentClientDriver = {
|
||||
},
|
||||
}
|
||||
|
||||
export default DS
|
||||
export default DS
|
||||
@@ -52,6 +52,44 @@ async function trRequest(
|
||||
return { ...data, sessionId }
|
||||
}
|
||||
|
||||
async function trGetIdByHash(config: Record<string, string | number>, 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>,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
const id = await trGetIdByHash(config, hash)
|
||||
if (id == null) continue
|
||||
|
||||
const data = await trRequest(config, 'torrent-get', { ids: [id], fields: ['files'] })
|
||||
const files = data.arguments?.torrents?.[0]?.files
|
||||
if (!Array.isArray(files) || files.length === 0) continue
|
||||
|
||||
const unwanted = files.map((_: any, i: number) => i).filter((i: number) => i !== fileIndex)
|
||||
await trRequest(config, 'torrent-set', {
|
||||
ids : [id],
|
||||
'files-wanted' : [fileIndex],
|
||||
'files-unwanted' : unwanted,
|
||||
})
|
||||
logger.info('transmission', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
|
||||
}
|
||||
|
||||
const TR: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'transmission',
|
||||
@@ -96,7 +134,7 @@ const TR: TorrentClientDriver = {
|
||||
'hashString', 'name', 'status', 'percentDone', 'totalSize',
|
||||
'downloadedEver', 'uploadedEver', 'uploadRatio',
|
||||
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
|
||||
'fileStats', // progression par fichier (bytesCompleted + length)
|
||||
'files', // bytesCompleted + length par fichier (fileStats n'a pas 'length')
|
||||
]
|
||||
const data = await trRequest(config, 'torrent-get', { fields })
|
||||
const torrents: any[] = data.arguments?.torrents ?? []
|
||||
@@ -120,42 +158,37 @@ const TR: TorrentClientDriver = {
|
||||
eta : t.eta ?? -1,
|
||||
save_path : t.downloadDir,
|
||||
category : t.labels?.[0] ?? '',
|
||||
files : Array.isArray(t.fileStats) && t.fileStats.length > 0
|
||||
? t.fileStats.map((fs: any, i: number) => ({
|
||||
files : Array.isArray(t.files) && t.files.length > 0
|
||||
? t.files.map((f: any, i: number) => ({
|
||||
index : i,
|
||||
progress: fs.length > 0 ? Math.round((fs.bytesCompleted / fs.length) * 100) : 0,
|
||||
name : String(f.name ?? ''),
|
||||
progress: f.length > 0 ? f.bytesCompleted / f.length : 0,
|
||||
}))
|
||||
: undefined,
|
||||
} satisfies TorrentInfo))
|
||||
},
|
||||
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const args: Record<string, unknown> = { filename: url }
|
||||
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
||||
const hash = hashMatch?.[1]?.toLowerCase() ?? null
|
||||
|
||||
const args: Record<string, unknown> = { filename: url }
|
||||
if (config.savePath) args['download-dir'] = String(config.savePath)
|
||||
if (config.category) args['labels'] = [String(config.category)]
|
||||
|
||||
// Transmission applique files-wanted après récupération des métadonnées,
|
||||
// sans démarrer les autres fichiers entre-temps — natif et fiable.
|
||||
if (options?.file_index != null) {
|
||||
args['files-wanted'] = [options.file_index]
|
||||
}
|
||||
|
||||
const res = await trRequest(config, 'torrent-add', args)
|
||||
|
||||
// Torrent déjà présent → Transmission renvoie torrent-duplicate
|
||||
// On s'assure quand même que le fichier voulu est activé
|
||||
if (options?.file_index != null && res.arguments?.['torrent-duplicate']) {
|
||||
const dup = res.arguments['torrent-duplicate']
|
||||
try {
|
||||
await trRequest(config, 'torrent-set', {
|
||||
ids : [dup.id],
|
||||
'files-wanted': [options.file_index],
|
||||
})
|
||||
logger.info('transmission', `Torrent ${dup.hashString?.slice(0, 8)}… déjà présent, fichier ${options.file_index} activé`)
|
||||
} catch (err) {
|
||||
logger.warn('transmission', `Activation fichier sur torrent existant échouée : ${err instanceof Error ? err.message : err}`)
|
||||
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}`)
|
||||
} else {
|
||||
logger.info('transmission', `Torrent ajouté (sélection fichier ${options.file_index} en attente de 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}`)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Gestion des utilisateurs multi-user
|
||||
* Migration automatique depuis l'ancien auth.json mono-user
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
|
||||
export type UserRole = 'admin' | 'user'
|
||||
|
||||
export interface User {
|
||||
id : string
|
||||
username : string
|
||||
passwordHash: string
|
||||
role : UserRole
|
||||
apiToken : string
|
||||
createdAt : string
|
||||
}
|
||||
|
||||
const USERS_PATH = path.join(DATA_DIR, 'users.json')
|
||||
const LEGACY_PATH = path.join(DATA_DIR, 'auth.json')
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
// ── Migration mono → multi-user ───────────────────────────────
|
||||
|
||||
export function migrateIfNeeded(): void {
|
||||
if (fs.existsSync(USERS_PATH)) return // déjà en format multi-user
|
||||
if (!fs.existsSync(LEGACY_PATH)) return // pas encore configuré
|
||||
|
||||
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 auth.json → users.json réussie pour "${legacy.username}"`)
|
||||
} catch (err) {
|
||||
logger.error('users', `Échec migration auth.json : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers fichier ───────────────────────────────────────────
|
||||
|
||||
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) }
|
||||
|
||||
/** Sérialise sans passwordHash pour les réponses API */
|
||||
export function safeUser(user: User) {
|
||||
const { passwordHash: _, ...safe } = user
|
||||
return safe
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
export function createUser(username: string, password: string, role: UserRole = 'user'): User {
|
||||
const users = readUsers()
|
||||
if (users.some(u => u.username.toLowerCase() === username.toLowerCase())) {
|
||||
throw new Error(`Nom d'utilisateur "${username}" déjà utilisé`)
|
||||
}
|
||||
const user: User = {
|
||||
id : crypto.randomUUID(),
|
||||
username,
|
||||
passwordHash: bcrypt.hashSync(password, SALT_ROUNDS),
|
||||
role,
|
||||
apiToken : crypto.randomBytes(32).toString('hex'),
|
||||
createdAt : new Date().toISOString(),
|
||||
}
|
||||
writeUsers([...users, user])
|
||||
logger.info('users', `Utilisateur "${username}" créé (rôle: ${role})`)
|
||||
return user
|
||||
}
|
||||
|
||||
export function updateUser(
|
||||
id : string,
|
||||
changes: Partial<{ username: string; password: string; role: UserRole }>,
|
||||
): User {
|
||||
const users = readUsers()
|
||||
const idx = users.findIndex(u => u.id === id)
|
||||
if (idx === -1) throw new Error('Utilisateur introuvable')
|
||||
|
||||
const user = users[idx]
|
||||
|
||||
if (changes.username !== undefined && changes.username !== user.username) {
|
||||
if (users.some((u, i) => i !== idx && u.username.toLowerCase() === changes.username!.toLowerCase())) {
|
||||
throw new Error(`Nom d'utilisateur "${changes.username}" déjà utilisé`)
|
||||
}
|
||||
user.username = changes.username
|
||||
}
|
||||
if (changes.role !== undefined) user.role = changes.role
|
||||
if (changes.password !== undefined) user.passwordHash = bcrypt.hashSync(changes.password, SALT_ROUNDS)
|
||||
|
||||
writeUsers(users)
|
||||
logger.info('users', `Utilisateur "${user.username}" mis à jour`)
|
||||
return user
|
||||
}
|
||||
|
||||
export function deleteUser(id: string): void {
|
||||
const users = readUsers()
|
||||
const user = users.find(u => u.id === id)
|
||||
if (!user) throw new Error('Utilisateur introuvable')
|
||||
writeUsers(users.filter(u => u.id !== id))
|
||||
logger.info('users', `Utilisateur "${user.username}" supprimé`)
|
||||
}
|
||||
|
||||
export function changePassword(id: string, currentPassword: string, newPassword: string): void {
|
||||
const users = readUsers()
|
||||
const user = users.find(u => u.id === id)
|
||||
if (!user) throw new Error('Utilisateur introuvable')
|
||||
if (!bcrypt.compareSync(currentPassword, user.passwordHash)) {
|
||||
throw new Error('Mot de passe actuel incorrect')
|
||||
}
|
||||
user.passwordHash = bcrypt.hashSync(newPassword, SALT_ROUNDS)
|
||||
writeUsers(users)
|
||||
logger.info('users', `Mot de passe changé pour "${user.username}"`)
|
||||
}
|
||||
|
||||
export function 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
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<!-- 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="Retour à la racine">
|
||||
<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"/>
|
||||
@@ -85,12 +85,13 @@ import { ref, watch } from 'vue'
|
||||
const props = defineProps<{ initialPath?: string }>()
|
||||
const emit = defineEmits<{ select: [path: string]; cancel: [] }>()
|
||||
|
||||
const current = ref(props.initialPath || '/')
|
||||
const inputPath = ref(current.value)
|
||||
const dirs = ref<string[]>([])
|
||||
const parent = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const current = ref(props.initialPath || '/')
|
||||
const inputPath = ref(current.value)
|
||||
const dirs = ref<string[]>([])
|
||||
const parent = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const drivesRoot = ref(false) // true quand la liste affiche les lecteurs Windows
|
||||
|
||||
// FIX : la regex /\/+$/ dans un @click inline fait planter le parser Vue
|
||||
function selectCurrent() {
|
||||
@@ -98,8 +99,10 @@ function selectCurrent() {
|
||||
}
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
// Lecteur Windows complet (ex: "C:\") → chemin absolu, pas de jointure
|
||||
if (/^[A-Za-z]:\\$/.test(name)) return name
|
||||
if (base === '/') return `/${name}`
|
||||
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base
|
||||
const cleanBase = base.replace(/[\\/]+$/, '')
|
||||
return `${cleanBase}/${name}`
|
||||
}
|
||||
|
||||
@@ -114,10 +117,11 @@ async function navigateTo(p: string) {
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
current.value = data.path
|
||||
inputPath.value = data.path
|
||||
parent.value = data.parent
|
||||
dirs.value = data.dirs
|
||||
current.value = data.path
|
||||
inputPath.value = data.path
|
||||
parent.value = data.parent
|
||||
dirs.value = data.dirs
|
||||
drivesRoot.value = data.drivesRoot ?? false
|
||||
} catch {
|
||||
error.value = 'Impossible de lire ce dossier'
|
||||
} finally {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</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">
|
||||
@@ -120,8 +120,10 @@
|
||||
|
||||
<!-- 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 class="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
:class="importResult.errors.length === 0 ? 'bg-green-500/10' : 'bg-yellow-500/10'">
|
||||
<svg v-if="importResult.errors.length === 0" 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>
|
||||
<svg v-else width="20" height="20" viewBox="0 0 24 24" stroke="#eab308" stroke-width="2" fill="none"><path d="M12 2l9.27 16H2.73L12 2z"/><line x1="12" y1="9" x2="12" y2="13"/><circle cx="12" cy="16.5" r=".5" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-primary">Import terminé</p>
|
||||
@@ -138,7 +140,10 @@
|
||||
<span class="text-primary font-mono">{{ e.file }}</span> — {{ e.error }}
|
||||
</div>
|
||||
</div>
|
||||
<button @click="$emit('close'); $emit('imported')" class="btn-primary">Fermer</button>
|
||||
<div class="flex gap-2">
|
||||
<button @click="backToList" class="btn-secondary">Voir les fichiers</button>
|
||||
<button @click="$emit('close')" class="btn-primary">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -180,11 +185,12 @@ 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 // ep_id au moment du scan (immuable) — sert au serveur pour cleanup
|
||||
hash : string | null
|
||||
alreadyImported : boolean
|
||||
willRename : boolean
|
||||
}
|
||||
|
||||
const items = ref<FileItem[]>([])
|
||||
@@ -261,8 +267,20 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
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]
|
||||
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
|
||||
|
||||
// Retirer les autres fichiers "déjà importés" pour ce même épisode :
|
||||
// l'utilisateur a choisi de les remplacer par ce fichier-ci.
|
||||
if (newEpId !== null) {
|
||||
items.value = items.value.filter(other =>
|
||||
other === item || !(other.episode_id === newEpId && other.alreadyImported)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────
|
||||
@@ -287,11 +305,11 @@ async function scan() {
|
||||
// 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'
|
||||
@@ -322,15 +340,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 ?? 'Erreur inconnue'
|
||||
toast(data.error ?? 'Erreur lors de l\'import', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
importResult.value = { done: data.done, errors: data.errors ?? [] }
|
||||
|
||||
if (data.done > 0) {
|
||||
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} importé${data.done > 1 ? 's' : ''} ✓`, 'success')
|
||||
}
|
||||
if ((data.errors ?? []).length > 0) {
|
||||
toast(`${data.errors.length} fichier${data.errors.length > 1 ? 's' : ''} en erreur`, 'error')
|
||||
}
|
||||
|
||||
// Notifier le parent immédiatement (rafraîchit organizedByEpisode + page série)
|
||||
emit('imported')
|
||||
|
||||
step.value = 'done'
|
||||
} catch {
|
||||
importError.value = 'Impossible de contacter le serveur'
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
step.value = 'list'
|
||||
scan() // re-scan → les fichiers importés passent en "déjà importé"
|
||||
}
|
||||
|
||||
onMounted(scan)
|
||||
</script>
|
||||
@@ -4,7 +4,30 @@
|
||||
<!-- 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>
|
||||
<!-- Nom du torrent + tooltip épisodes -->
|
||||
<div class="relative group/eps inline-block max-w-full">
|
||||
<p class="text-sm text-primary font-medium truncate cursor-default">{{ torrent.name }}</p>
|
||||
<!-- Tooltip épisodes -->
|
||||
<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="bg-card border border-border rounded-lg p-3 text-xs shadow-xl">
|
||||
<p class="text-muted font-medium mb-2">{{ episodesWithProgress.length }} épisode{{ episodesWithProgress.length > 1 ? 's' : '' }}</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">
|
||||
<span class="text-primary shrink-0">S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}</span>
|
||||
<span v-if="ep.progress !== null"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded shrink-0"
|
||||
:class="ep.progress >= 100 ? 'bg-green-500/15 text-green-500' : ep.priority === 0 ? 'bg-border/50 text-muted' : 'bg-accent/15 text-accent'">
|
||||
{{ ep.priority === 0 ? 'ignoré' : ep.progress >= 100 ? '✓' : `${ep.progress}%` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Nom de la série -->
|
||||
<p v-if="torrent.serieName" class="text-[11px] text-muted truncate mt-0.5">{{ torrent.serieName }}</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>
|
||||
@@ -65,8 +88,12 @@
|
||||
: '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 v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing"
|
||||
class="btn-secondary text-xs py-1 flex items-center gap-1.5 min-w-[72px] justify-center">
|
||||
<svg v-if="importing" width="10" height="10" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" fill="none" class="animate-spin shrink-0">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>
|
||||
{{ importing ? 'Import…' : 'Importer' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -130,6 +157,23 @@ const withFiles = ref(false)
|
||||
|
||||
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.torrent.progress ?? 0)))
|
||||
|
||||
// Épisodes enrichis avec leur progression depuis les données de fichiers du client
|
||||
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,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function stateBadge(state: string) {
|
||||
return ({
|
||||
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-main" />
|
||||
</div>
|
||||
|
||||
<div class="relative px-4 md:px-8 pt-6 pb-10 flex gap-6 min-h-[200px]">
|
||||
<div class="relative px-4 md:px-8 pt-6 pb-10 flex items-start gap-6 min-h-[200px]">
|
||||
<!-- Poster -->
|
||||
<div class="shrink-0 w-32 rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
|
||||
<div class="shrink-0 w-32 aspect-[2/3] 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">
|
||||
<Tv :size="28" />
|
||||
@@ -39,17 +39,36 @@
|
||||
>{{ serie.status }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="serie.plot" class="text-sm text-secondary leading-relaxed max-w-2xl line-clamp-3">
|
||||
<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>
|
||||
|
||||
<!-- Lien wiki -->
|
||||
<a
|
||||
v-if="serie.wiki"
|
||||
:href="serie.wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors w-fit"
|
||||
>
|
||||
<ExternalLink :size="12" />
|
||||
Wiki
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ArrowLeft, Tv } from 'lucide-vue-next'
|
||||
import { ArrowLeft, Tv, ExternalLink } from 'lucide-vue-next'
|
||||
|
||||
defineProps<{ serie: any }>()
|
||||
const expanded = ref(false)
|
||||
</script>
|
||||
@@ -31,9 +31,81 @@
|
||||
|
||||
<!-- Boutons saison -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 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>
|
||||
<!-- Corbeille saison (admin uniquement) -->
|
||||
<button
|
||||
v-if="!requestMode && 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>
|
||||
|
||||
<!-- ── Mode demande ────────────────────────────────────── -->
|
||||
<template v-if="requestMode">
|
||||
<button
|
||||
@click.stop="emit('requestSeason', season.season_number)"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
|
||||
:class="isSeasonRequested
|
||||
? 'bg-blue-500/10 text-blue-400 border-blue-500/20 cursor-default'
|
||||
: 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'"
|
||||
>
|
||||
<component :is="isSeasonRequested ? checkIcon : requestIcon" />
|
||||
{{ isSeasonRequested ? 'Demandé' : 'Demander' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- ── Mode téléchargement (admin) ─────────────────────── -->
|
||||
<template v-else>
|
||||
<!-- 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>
|
||||
<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"
|
||||
>
|
||||
<component :is="downloadingSeason ? loaderIcon : downloadIcon" />
|
||||
{{ downloadingSeason ? 'Envoi…' : 'Saison' }}
|
||||
</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>
|
||||
|
||||
<!-- 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"
|
||||
@@ -41,77 +113,25 @@
|
||||
<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">
|
||||
<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="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"
|
||||
v-for="(t, i) in season.torrents"
|
||||
:key="i"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
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="downloadIcon" />
|
||||
{{ opt.label }}
|
||||
<component :is="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
|
||||
{{ groupLabels(season.torrents)[i] }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<component :is="downloadingSeason ? loaderIcon : downloadIcon" />
|
||||
{{ downloadingSeason ? 'Envoi…' : 'Saison' }}
|
||||
</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>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<component :is="downloadIcon" />
|
||||
Saison ▾
|
||||
</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] }}
|
||||
</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
|
||||
@@ -140,7 +160,7 @@
|
||||
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">
|
||||
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
<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 -->
|
||||
@@ -166,12 +186,38 @@
|
||||
<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>
|
||||
<!-- Badge épisodes -->
|
||||
<span
|
||||
v-if="parseEpChap(ep.plot).episodes"
|
||||
class="shrink-0 text-[10px] font-medium leading-none px-1.5 py-0.5 rounded border bg-violet-500/10 border-violet-500/20 text-violet-300 select-none"
|
||||
:title="`Plage d'épisodes couverts`"
|
||||
>{{ parseEpChap(ep.plot).episodes }}</span>
|
||||
<!-- Badge chapitres -->
|
||||
<span
|
||||
v-if="parseEpChap(ep.plot).chapters"
|
||||
class="shrink-0 text-[10px] font-medium leading-none px-1.5 py-0.5 rounded border bg-teal-500/10 border-teal-500/20 text-teal-300 select-none"
|
||||
:title="`Plage de chapitres manga couverts`"
|
||||
>{{ parseEpChap(ep.plot).chapters }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton état épisode (dropdown si plusieurs options) -->
|
||||
<div class="relative shrink-0" @click.stop>
|
||||
<!-- Bouton épisode -->
|
||||
<div v-if="!ep.organized" class="relative shrink-0" @click.stop>
|
||||
<!-- Mode demande -->
|
||||
<button
|
||||
v-if="requestMode"
|
||||
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
|
||||
:class="isEpisodeRequested(ep.id)
|
||||
? 'border-blue-500/30 text-blue-400 bg-blue-500/10 cursor-default'
|
||||
: 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'"
|
||||
:title="isEpisodeRequested(ep.id) ? 'Déjà demandé' : 'Demander cet épisode'"
|
||||
@click="handleEpRequestClick(ep)"
|
||||
>
|
||||
<component :is="isEpisodeRequested(ep.id) ? checkIcon : requestIcon" />
|
||||
</button>
|
||||
<!-- Mode téléchargement (admin) -->
|
||||
<button
|
||||
v-else
|
||||
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
|
||||
:class="epBtnClass(ep)"
|
||||
:disabled="epState(ep) !== 'idle'"
|
||||
@@ -179,16 +225,34 @@
|
||||
>
|
||||
<component :is="epStateIcon(ep)" />
|
||||
</button>
|
||||
<!-- Dropdown quand plusieurs options de torrent -->
|
||||
<div v-if="epOptionsOpen === ep.id && !ep.organized && ep.torrents && ep.torrents.length > 1"
|
||||
|
||||
<!-- Dropdown sélection torrent — mode demande -->
|
||||
<div v-if="requestMode && 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-72 shadow-xl flex flex-col gap-0.5">
|
||||
<p class="px-3 pt-1.5 pb-0.5 text-[10px] text-muted font-medium uppercase tracking-wide">Choisir le torrent</p>
|
||||
<button
|
||||
v-for="(t, i) in ep.torrents"
|
||||
:key="i"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="flex items-start gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
|
||||
@click="emit('requestEpisode', season.season_number, ep.id, t); epOptionsOpen = null"
|
||||
>
|
||||
<component :is="requestIcon" class="shrink-0 mt-0.5" />
|
||||
<span class="whitespace-normal break-words leading-relaxed">{{ t.raw ?? t.torrent_name ?? `Option ${i + 1}` }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown sélection torrent — mode téléchargement -->
|
||||
<div v-if="!requestMode && 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">
|
||||
<button
|
||||
v-for="(t, i) in ep.torrents"
|
||||
:key="i"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
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), epOptionsOpen = null)"
|
||||
@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" />
|
||||
{{ groupLabels(ep.torrents)[i] }}
|
||||
@@ -196,37 +260,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions épisode importé -->
|
||||
<template v-if="ep.organized && organizedByEpisode[String(ep.id)]">
|
||||
<span
|
||||
<!-- Actions épisode importé (admin uniquement) -->
|
||||
<template v-if="!requestMode && ep.organized && organizedByEpisode[String(ep.id)]">
|
||||
<!-- Badge rename cliquable -->
|
||||
<button
|
||||
v-if="epNeedsRename(ep)"
|
||||
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-yellow-500/10 text-yellow-400 border-yellow-500/20 cursor-default"
|
||||
@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</span>
|
||||
|
||||
<div class="relative" @click.stop>
|
||||
<button
|
||||
@click="epMenuOpen = epMenuOpen === ep.id ? null : ep.id"
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||
</button>
|
||||
<div v-if="epMenuOpen === ep.id" class="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl p-1 z-20 w-44 shadow-xl flex flex-col gap-0.5">
|
||||
<button v-if="epNeedsRename(ep)" @click="emit('renameEpisode', ep, season); epMenuOpen = null" :disabled="epActionLoading[ep.id]" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
Renommer
|
||||
</button>
|
||||
<button @click="emit('unimportEpisode', ep, season, false); epMenuOpen = null" :disabled="epActionLoading[ep.id]" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:bg-hover transition-colors">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg>
|
||||
Désimporter
|
||||
</button>
|
||||
<button @click="emit('unimportEpisode', ep, season, true); epMenuOpen = null" :disabled="epActionLoading[ep.id]" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-red-400 hover:bg-red-500/10 transition-colors">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg>
|
||||
Supprimer le fichier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
>Rename</button>
|
||||
<!-- Corbeille épisode -->
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
<Trash2 :size="12" />
|
||||
</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'"
|
||||
title="Voir le synopsis"
|
||||
>
|
||||
<ChevronUp :size="14" class="transition-transform duration-200" :class="plotOpen === ep.id ? '' : 'rotate-180'" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Barre progression épisode -->
|
||||
@@ -235,16 +299,61 @@
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${epProgress(ep)!.progress}%` }" />
|
||||
</div>
|
||||
</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">{{ parseEpChap(ep.plot).cleanPlot }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</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' }}
|
||||
</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>
|
||||
</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' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
|
||||
import { ChevronUp, Download, Loader, Check, X } from 'lucide-vue-next'
|
||||
import { ChevronUp, Download, Loader, Check, X, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
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
|
||||
@@ -256,19 +365,44 @@ 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 cet user pour cette série
|
||||
requestedEpisodes ?: number[] // IDs d'épisodes déjà demandés par cet user pour cette série
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle : [id: number]
|
||||
download : [key: string, url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null]
|
||||
download : [key: string, url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null]
|
||||
downloadSeason : [season: any, packHash?: string]
|
||||
renameEpisode : [ep: any, season: any]
|
||||
unimportEpisode: [ep: any, season: any, deleteFile: boolean]
|
||||
unimportSeason : [season: any, deleteFiles: boolean]
|
||||
// Mode demande
|
||||
requestSeason : [seasonNumber: number]
|
||||
requestEpisode : [seasonNumber: number, episodeId: number, torrent?: any]
|
||||
}>()
|
||||
|
||||
const epMenuOpen = ref<number | null>(null)
|
||||
const epOptionsOpen = ref<number | null>(null)
|
||||
const seasonMenuOpen = ref(false)
|
||||
const requestIcon = h('svg', { viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': 2, fill: 'none', width: 12, height: 12 },
|
||||
[h('circle', { cx: 12, cy: 12, r: 10 }), h('path', { d: 'M12 8v4l2 2' })]
|
||||
)
|
||||
|
||||
const isSeasonRequested = computed(() =>
|
||||
props.requestedSeasons?.includes(props.season.season_number) ?? false
|
||||
)
|
||||
|
||||
/** Un épisode est "demandé" si sa saison l'est, ou si son ID est dans requestedEpisodes */
|
||||
function isEpisodeRequested(episodeId: number): boolean {
|
||||
return isSeasonRequested.value || (props.requestedEpisodes?.includes(episodeId) ?? false)
|
||||
}
|
||||
|
||||
const epOptionsOpen = ref<number | null>(null)
|
||||
const plotOpen = ref<number | null>(null)
|
||||
const seasonMenuOpen = ref(false)
|
||||
const unimportModal = ref(false)
|
||||
const deleteFileOnUnimport = ref(false)
|
||||
const unimportEpTarget = ref<{ ep: any; season: any } | null>(null)
|
||||
const unimportSeasonModal = ref(false)
|
||||
const deleteSeasonFiles = ref(false)
|
||||
const downloadIcon = h(Download, { size: 12 })
|
||||
const loaderIcon = h(Loader, { size: 12, class: 'animate-spin' })
|
||||
const checkIcon = h(Check, { size: 12 })
|
||||
@@ -366,15 +500,16 @@ 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,
|
||||
// pas à l'état global du torrent — évite d'afficher les fichiers non sélectionnés (progress 0).
|
||||
if (file != null && (sessionDl || file.progress > 0)) {
|
||||
return { ...active, progress: file.progress }
|
||||
// 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 && 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 : session ou torrent terminé
|
||||
if (!sessionDl && active.state !== 'seeding') continue
|
||||
// Pas de données par fichier : ignorer si pas de session active
|
||||
if (!sessionDl) continue
|
||||
return { ...active, progress: active.state === 'seeding' ? 100 : active.progress }
|
||||
}
|
||||
}
|
||||
@@ -393,10 +528,21 @@ function handleEpBtnClick(ep: any) {
|
||||
}
|
||||
// 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)
|
||||
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) {
|
||||
// Plusieurs torrents → afficher le picker
|
||||
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
|
||||
return
|
||||
}
|
||||
// Torrent unique ou aucun → émettre directement
|
||||
emit('requestEpisode', 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'
|
||||
@@ -430,14 +576,69 @@ 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
|
||||
// Préférer le formatted_name du torrent qui correspond au fichier importé.
|
||||
// On essaie de matcher par hash (si l'entrée organized l'expose), sinon on
|
||||
// cherche le premier torrent qui a un formatted_name.
|
||||
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
|
||||
}
|
||||
|
||||
// ── Extraction épisodes / chapitres depuis le synopsis ─────────
|
||||
// Cache léger pour éviter de recalculer pour chaque accès template (badges + synopsis)
|
||||
const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parseEpChap>>()
|
||||
/**
|
||||
* Extrait les plages d'épisodes et chapitres depuis un synopsis.
|
||||
* Ex: "...épisodes 628 à 634 soit les chapitres 700 à 706."
|
||||
* → { episodes: "628–634", chapters: "700–706", cleanPlot: "..." }
|
||||
* La phrase (ou ligne) contenant ces infos est retirée du synopsis affiché.
|
||||
*/
|
||||
function _parseEpChap(plot: string | null | undefined): {
|
||||
episodes: string | null
|
||||
chapters: string | null
|
||||
cleanPlot: string
|
||||
} {
|
||||
const text = plot ?? ''
|
||||
|
||||
const epMatch = text.match(/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
||||
const chapMatch = text.match(/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
||||
|
||||
const episodes = epMatch
|
||||
? (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
|
||||
|
||||
// Retirer les lignes contenant les références d'épisodes ou chapitres
|
||||
let cleanPlot = text
|
||||
if (episodes || chapters) {
|
||||
cleanPlot = text
|
||||
.split(/\r?\n/)
|
||||
.filter(line => !/[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+/i.test(line))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
return { episodes, 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
|
||||
}
|
||||
|
||||
// ── Détection langue ───────────────────────────────────────────
|
||||
@@ -474,23 +675,33 @@ function groupLabels(torrents: any[]): string[] {
|
||||
}
|
||||
|
||||
// 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
|
||||
// Permet le dropdown "Saison ▾" quand une saison est couverte par plusieurs intégrales.
|
||||
// Un torrent individuel par épisode (hash unique) n'est PAS un pack → ignoré ici,
|
||||
// le bouton "Saison" simple suffira pour lancer tous les épisodes.
|
||||
const uniquePackOptions = computed(() => {
|
||||
if (props.season.torrents?.length) return []
|
||||
const seen = new Set<string>()
|
||||
const raw: { infohash: string; torrent: any }[] = []
|
||||
|
||||
// Compter combien d'épisodes chaque hash couvre
|
||||
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] }))
|
||||
|
||||
// Garder uniquement les hash qui couvrent ≥ 2 épisodes (= vrais packs / intégrales)
|
||||
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 {
|
||||
@@ -505,9 +716,35 @@ function formatDuration(seconds: number): string {
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
function togglePlot(epId: number) {
|
||||
plotOpen.value = plotOpen.value === epId ? null : epId
|
||||
}
|
||||
|
||||
function openUnimportModal(ep: any, season: any) {
|
||||
unimportEpTarget.value = { ep, season }
|
||||
deleteFileOnUnimport.value = false
|
||||
unimportModal.value = true
|
||||
}
|
||||
|
||||
function confirmUnimport() {
|
||||
if (!unimportEpTarget.value) return
|
||||
emit('unimportEpisode', unimportEpTarget.value.ep, unimportEpTarget.value.season, deleteFileOnUnimport.value)
|
||||
unimportModal.value = false
|
||||
unimportEpTarget.value = null
|
||||
}
|
||||
|
||||
function openUnimportSeasonModal() {
|
||||
deleteSeasonFiles.value = false
|
||||
unimportSeasonModal.value = true
|
||||
}
|
||||
|
||||
function confirmUnimportSeason() {
|
||||
emit('unimportSeason', props.season, deleteSeasonFiles.value)
|
||||
unimportSeasonModal.value = false
|
||||
}
|
||||
|
||||
function closeAllMenus() {
|
||||
epMenuOpen.value = null
|
||||
epOptionsOpen.value = null
|
||||
epOptionsOpen.value = null
|
||||
seasonMenuOpen.value = false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Copie du texte dans le presse-papiers avec fallback pour HTTP non-localhost. */
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return
|
||||
}
|
||||
// Fallback : execCommand (HTTP non sécurisé)
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
}
|
||||
+56
-31
@@ -16,7 +16,7 @@
|
||||
</Transition>
|
||||
|
||||
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-sidebar shrink-0">
|
||||
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-sidebar shrink-0 relative z-10">
|
||||
<button @click="mobileOpen = true" class="text-muted hover:text-primary transition-colors">
|
||||
<Menu :size="20" />
|
||||
</button>
|
||||
@@ -39,46 +39,71 @@ import { ref, computed } from 'vue'
|
||||
import { Menu } from 'lucide-vue-next'
|
||||
import SidebarNav from '@/components/nav/SidebarNav.vue'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
|
||||
const mobileOpen = ref(false)
|
||||
const dlStore = useDownloadsStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const navItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon : 'LayoutDashboard',
|
||||
to : '/dashboard',
|
||||
},
|
||||
{
|
||||
label : 'Médiathèque',
|
||||
icon : 'Tv',
|
||||
to : '/series',
|
||||
children: [
|
||||
{ label: 'Séries', to: '/series' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Activité',
|
||||
icon : 'Activity',
|
||||
to : '/activity',
|
||||
badge: dlStore.activeCount > 0 ? dlStore.activeCount : undefined,
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label : 'Paramètres',
|
||||
icon : 'Settings',
|
||||
to : '/settings',
|
||||
children: [
|
||||
const navItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon : 'LayoutDashboard',
|
||||
to : '/dashboard',
|
||||
},
|
||||
{
|
||||
label : 'Médiathèque',
|
||||
icon : 'Tv',
|
||||
to : '/series',
|
||||
children: [
|
||||
{ label: 'Séries', to: '/series' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Demandes',
|
||||
icon : 'ClipboardList',
|
||||
to : '/requests',
|
||||
},
|
||||
]
|
||||
|
||||
if (auth.isAdmin) {
|
||||
items.push({
|
||||
label: 'Activité',
|
||||
icon : 'Activity',
|
||||
to : '/activity',
|
||||
badge: dlStore.activeCount > 0 ? dlStore.activeCount : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
items.push({ separator: true })
|
||||
|
||||
const settingsChildren: NavItem[] = [
|
||||
{ label: 'Mon profil', to: '/settings/profile' },
|
||||
]
|
||||
if (auth.isAdmin) {
|
||||
settingsChildren.push(
|
||||
{ label: 'Clients de téléchargement', to: '/settings/download-client' },
|
||||
{ label: 'Gestion des médias', to: '/settings/media-management' },
|
||||
{ label: 'Management des séries', to: '/settings/import-management' },
|
||||
{ label: 'Management des séries', to: '/settings/import-management' },
|
||||
{ label: 'Catalogue Fankai', to: '/settings/catalogue' },
|
||||
{ label: 'Journaux', to: '/settings/logs' },
|
||||
{ label: 'Avancé', to: '/settings/advanced' },
|
||||
],
|
||||
},
|
||||
])
|
||||
{ label: 'Utilisateurs', to: '/settings/users' },
|
||||
{ label: 'Jellyfin & API', to: '/settings/jellyfin' },
|
||||
)
|
||||
}
|
||||
|
||||
items.push({
|
||||
label : 'Paramètres',
|
||||
icon : 'Settings',
|
||||
to : '/settings',
|
||||
children: settingsChildren,
|
||||
})
|
||||
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+47
-12
@@ -1,16 +1,28 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
public? : boolean
|
||||
adminOnly? : boolean
|
||||
}
|
||||
}
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppLayout from '@/layouts/AppLayout.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
// ─── Page publique hors layout ───────────────────────────
|
||||
// ─── Pages publiques hors layout ─────────────────────────
|
||||
{
|
||||
path: '/auth',
|
||||
component: () => import('@/views/AuthView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/invite/:code',
|
||||
component: () => import('@/views/InviteView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
|
||||
// ─── App principale avec layout sidebar ──────────────────
|
||||
{
|
||||
@@ -52,7 +64,12 @@ const router = createRouter({
|
||||
path: 'settings',
|
||||
component: () => import('@/views/settings/SettingsLayout.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/settings/download-client' },
|
||||
{ path: '', redirect: '/settings/profile' },
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'settings-profile',
|
||||
component: () => import('@/views/settings/ProfileView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'download-client',
|
||||
name: 'settings-download',
|
||||
@@ -83,8 +100,27 @@ const router = createRouter({
|
||||
name: 'settings-advanced',
|
||||
component: () => import('@/views/settings/AdvancedView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'settings-users',
|
||||
component: () => import('@/views/settings/UsersView.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'jellyfin',
|
||||
name: 'settings-jellyfin',
|
||||
component: () => import('@/views/settings/JellyfinView.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Demandes
|
||||
{
|
||||
path: 'requests',
|
||||
name: 'requests',
|
||||
component: () => import('@/views/RequestsView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -97,21 +133,20 @@ const router = createRouter({
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Guard auth (inchangé) ────────────────────────────────────
|
||||
// ─── Guard auth ───────────────────────────────────────────────
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (auth.loading) {
|
||||
await auth.checkStatus()
|
||||
}
|
||||
if (auth.loading) await auth.checkStatus()
|
||||
|
||||
if (!to.meta.public && !auth.loggedIn) {
|
||||
return '/auth'
|
||||
}
|
||||
// Redirige vers /auth si non connecté
|
||||
if (!to.meta.public && !auth.loggedIn) return '/auth'
|
||||
|
||||
if (to.path === '/auth' && auth.loggedIn) {
|
||||
return '/series'
|
||||
}
|
||||
// Redirige vers /series si déjà connecté et tente d'accéder à /auth
|
||||
if (to.path === '/auth' && auth.loggedIn) return '/series'
|
||||
|
||||
// Redirige les non-admins hors des pages admin
|
||||
if (to.meta.adminOnly && !auth.isAdmin) return '/series'
|
||||
})
|
||||
|
||||
export default router
|
||||
+34
-17
@@ -1,17 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const loggedIn = ref(false)
|
||||
const setup = ref(false)
|
||||
const loading = ref(true)
|
||||
const loggedIn = ref(false)
|
||||
const setup = ref(false)
|
||||
const loading = ref(true)
|
||||
const role = ref<'admin' | 'user' | null>(null)
|
||||
const username = ref<string | null>(null)
|
||||
const userId = ref<string | null>(null)
|
||||
|
||||
const isAdmin = computed(() => role.value === 'admin')
|
||||
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/status')
|
||||
const res = await fetch('/api/auth/status')
|
||||
const data = await res.json()
|
||||
setup.value = data.setup
|
||||
setup.value = data.setup
|
||||
loggedIn.value = data.loggedIn
|
||||
role.value = data.role ?? null
|
||||
username.value = data.username ?? null
|
||||
userId.value = data.userId ?? null
|
||||
} catch {
|
||||
loggedIn.value = false
|
||||
} finally {
|
||||
@@ -19,30 +27,36 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function login(username: string, password: string): Promise<string | null> {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
async function login(u: string, password: string): Promise<string | null> {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
body : JSON.stringify({ username: u, password }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
loggedIn.value = true
|
||||
role.value = data.user?.role ?? null
|
||||
username.value = data.user?.username ?? null
|
||||
userId.value = data.user?.id ?? null
|
||||
return null
|
||||
}
|
||||
return data.error
|
||||
}
|
||||
|
||||
async function setupAccount(username: string, password: string): Promise<string | null> {
|
||||
const res = await fetch('/api/auth/setup', {
|
||||
method: 'POST',
|
||||
async function setupAccount(u: string, password: string): Promise<string | null> {
|
||||
const res = await fetch('/api/auth/setup', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
body : JSON.stringify({ username: u, password }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
loggedIn.value = true
|
||||
setup.value = true
|
||||
setup.value = true
|
||||
role.value = data.user?.role ?? 'admin'
|
||||
username.value = data.user?.username ?? u
|
||||
userId.value = data.user?.id ?? null
|
||||
return null
|
||||
}
|
||||
return data.error
|
||||
@@ -51,7 +65,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
loggedIn.value = false
|
||||
role.value = null
|
||||
username.value = null
|
||||
userId.value = null
|
||||
}
|
||||
|
||||
return { loggedIn, setup, loading, checkStatus, login, setupAccount, logout }
|
||||
})
|
||||
return { loggedIn, setup, loading, role, username, userId, isAdmin, checkStatus, login, setupAccount, logout }
|
||||
})
|
||||
@@ -100,12 +100,12 @@ export const useSeriesStore = defineStore('series', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function download(torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null): Promise<{ success: boolean; error?: string }> {
|
||||
async function download(torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await fetch('/api/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ torrent_url, magnet, file_index: file_index ?? null, file_path: file_path ?? null })
|
||||
body: JSON.stringify({ torrent_url, magnet, file_index: file_index ?? null, file_path: file_path ?? null, infohash: infohash ?? null })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) return { success: true }
|
||||
|
||||
+79
-11
@@ -2,7 +2,7 @@
|
||||
<div class="px-8 py-8 max-w-4xl">
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<div class="grid gap-3 mb-6" :class="auth.isAdmin ? 'grid-cols-2 md:grid-cols-4' : 'grid-cols-2'">
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Catalogue</span>
|
||||
<span class="text-2xl font-semibold text-primary">{{ stats.catalogue }}</span>
|
||||
@@ -13,19 +13,19 @@
|
||||
<span class="text-2xl font-semibold text-green-500">{{ stats.imported }}</span>
|
||||
<span class="text-xs text-muted">{{ stats.catalogue > 0 ? Math.round(stats.imported / stats.catalogue * 100) : 0 }}% du catalogue</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<div v-if="auth.isAdmin" class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">En cours</span>
|
||||
<span class="text-2xl font-semibold text-accent">{{ stats.downloading }}</span>
|
||||
<span class="text-xs text-muted">téléchargements</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<div v-if="auth.isAdmin" class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Erreurs</span>
|
||||
<span class="text-2xl font-semibold" :class="stats.errors > 0 ? 'text-red-400' : 'text-muted'">{{ stats.errors }}</span>
|
||||
<span class="text-xs text-muted">{{ stats.errors === 0 ? 'aucune erreur' : 'à corriger' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4 mb-6">
|
||||
<div v-if="auth.isAdmin" class="grid md:grid-cols-2 gap-4 mb-6">
|
||||
|
||||
<!-- Téléchargements actifs -->
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
@@ -80,6 +80,48 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Widget demandes -->
|
||||
<div class="settings-card flex flex-col gap-3 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-medium text-secondary">
|
||||
{{ auth.isAdmin ? 'Demandes en attente' : 'Mes demandes' }}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="auth.isAdmin && pendingRequests.length > 0" class="text-xs px-2 py-0.5 rounded-full bg-yellow-500/10 text-yellow-400 border border-yellow-500/20">
|
||||
{{ pendingRequests.length }} en attente
|
||||
</span>
|
||||
<RouterLink to="/requests" class="text-xs text-muted hover:text-accent transition">Voir tout →</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loadingRequests" class="flex items-center gap-2 text-muted text-xs py-2">
|
||||
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
</div>
|
||||
<div v-else-if="pendingRequests.length === 0" class="text-xs text-muted py-2">
|
||||
{{ auth.isAdmin ? 'Aucune demande en attente' : 'Aucune demande' }}
|
||||
</div>
|
||||
<div v-else class="flex flex-col divide-y divide-border/50">
|
||||
<div v-for="req in pendingRequests" :key="req.id" class="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||
<div class="flex-1 min-w-0">
|
||||
<RouterLink :to="`/series/${req.serieId}`" class="text-xs text-primary truncate hover:text-accent transition block">{{ req.serieName }}</RouterLink>
|
||||
<p v-if="auth.isAdmin" class="text-[11px] text-muted mt-0.5">
|
||||
{{ req.requesters.length }} demandeur{{ req.requesters.length > 1 ? 's' : '' }}
|
||||
· {{ req.requesters.map((r: any) => r.username).join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded border shrink-0"
|
||||
:class="{
|
||||
'bg-yellow-500/10 text-yellow-400 border-yellow-500/20': req.status === 'pending',
|
||||
'bg-blue-500/10 text-blue-400 border-blue-500/20' : req.status === 'approved',
|
||||
'bg-green-500/10 text-green-400 border-green-500/20' : req.status === 'completed',
|
||||
'bg-red-500/10 text-red-400 border-red-500/20' : req.status === 'rejected',
|
||||
}">
|
||||
{{ { pending: 'Attente', approved: 'Approuvée', completed: 'Dispo', rejected: 'Refusée' }[req.status as string] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panneau debug -->
|
||||
<div v-if="devMode" class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -129,7 +171,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loadingTorrents = ref(true)
|
||||
const loadingNotifs = ref(true)
|
||||
const devMode = ref(false)
|
||||
@@ -137,10 +182,13 @@ const debug = ref<any>(null)
|
||||
const stats = ref({ catalogue: 0, imported: 0, downloading: 0, errors: 0 })
|
||||
const activeTorrents = ref<any[]>([])
|
||||
const recentNotifs = ref<any[]>([])
|
||||
const pendingRequests = ref<any[]>([])
|
||||
const loadingRequests = ref(true)
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function fetchSettings() {
|
||||
if (!auth.isAdmin) return
|
||||
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const s = await res.json()
|
||||
@@ -156,10 +204,9 @@ async function fetchDebug() {
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const [statusRes, seriesRes, downloadsRes] = await Promise.all([
|
||||
const [statusRes, seriesRes] = await Promise.all([
|
||||
fetch('/api/torrents/status', { credentials: 'include' }),
|
||||
fetch('/api/series', { credentials: 'include' }),
|
||||
fetch('/api/downloads', { credentials: 'include' }),
|
||||
])
|
||||
if (statusRes.ok) { const s = await statusRes.json(); stats.value.catalogue = s.count ?? 0 }
|
||||
if (seriesRes.ok) {
|
||||
@@ -167,15 +214,20 @@ async function fetchStats() {
|
||||
const list = Array.isArray(s) ? s : (s.series ?? [])
|
||||
stats.value.imported = list.filter((s: any) => s.download_state === 'complete').length
|
||||
}
|
||||
if (downloadsRes.ok) {
|
||||
const torrents = await downloadsRes.json()
|
||||
stats.value.downloading = torrents.filter((t: any) => t.state === 'downloading').length
|
||||
stats.value.errors = torrents.filter((t: any) => t.state === 'error').length
|
||||
// Stats téléchargements : admin uniquement
|
||||
if (auth.isAdmin) {
|
||||
const downloadsRes = await fetch('/api/downloads', { credentials: 'include' })
|
||||
if (downloadsRes.ok) {
|
||||
const torrents = await downloadsRes.json()
|
||||
stats.value.downloading = torrents.filter((t: any) => t.state === 'downloading').length
|
||||
stats.value.errors = torrents.filter((t: any) => t.state === 'error').length
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchTorrents() {
|
||||
if (!auth.isAdmin) { loadingTorrents.value = false; return }
|
||||
const isFirst = loadingTorrents.value
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
@@ -189,6 +241,7 @@ async function fetchTorrents() {
|
||||
}
|
||||
|
||||
async function fetchNotifs() {
|
||||
if (!auth.isAdmin) { loadingNotifs.value = false; return }
|
||||
loadingNotifs.value = true
|
||||
try {
|
||||
const res = await fetch('/api/organize/recent', { credentials: 'include' })
|
||||
@@ -198,6 +251,21 @@ async function fetchNotifs() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRequests() {
|
||||
loadingRequests.value = true
|
||||
try {
|
||||
const res = await fetch('/api/requests', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const all = await res.json()
|
||||
pendingRequests.value = auth.isAdmin
|
||||
? all.filter((r: any) => r.status === 'pending').slice(0, 5)
|
||||
: all.slice(0, 5)
|
||||
}
|
||||
} catch {} finally {
|
||||
loadingRequests.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
@@ -223,7 +291,7 @@ function formatRelative(iso: string): string {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Promise.all([fetchStats(), fetchTorrents(), fetchNotifs(), fetchSettings()])
|
||||
Promise.all([fetchStats(), fetchTorrents(), fetchNotifs(), fetchSettings(), fetchRequests()])
|
||||
interval = setInterval(() => {
|
||||
fetchTorrents()
|
||||
if (devMode.value) fetchDebug()
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-shell flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center justify-center gap-2.5 mb-8">
|
||||
<svg class="w-8 h-8" 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>
|
||||
<span class="text-2xl font-bold text-primary tracking-wide">FanKarr</span>
|
||||
</div>
|
||||
|
||||
<!-- Chargement -->
|
||||
<div v-if="status === 'loading'" class="settings-card p-8 text-center text-sm text-muted">
|
||||
Vérification du lien…
|
||||
</div>
|
||||
|
||||
<!-- Lien invalide -->
|
||||
<div v-else-if="status === 'invalid'" class="settings-card p-8 flex flex-col gap-3 text-center">
|
||||
<p class="text-sm font-semibold text-red-400">Lien invalide</p>
|
||||
<p class="text-sm text-muted">{{ invalidReason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Succès -->
|
||||
<div v-else-if="status === 'done'" class="settings-card p-8 flex flex-col gap-4 text-center">
|
||||
<p class="text-sm font-semibold text-green-400">Compte créé !</p>
|
||||
<p class="text-sm text-muted">Tu peux maintenant te connecter.</p>
|
||||
<RouterLink to="/auth" class="btn-primary w-full py-2.5 text-center">Se connecter</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'inscription -->
|
||||
<div v-else class="settings-card p-8 flex flex-col gap-5">
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-1">Invitation FanKarr</p>
|
||||
<h1 class="text-lg font-semibold text-primary">Créer un compte</h1>
|
||||
<p v-if="note" class="text-xs text-muted mt-1">{{ note }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Identifiant</label>
|
||||
<input v-model="username" type="text" placeholder="username" class="settings-input" @keyup.enter="submit" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Mot de passe</label>
|
||||
<input v-model="password" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Confirmer</label>
|
||||
<input v-model="confirm" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
|
||||
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary w-full py-2.5 mt-1">
|
||||
{{ submitting ? '…' : 'Créer le compte' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const code = route.params.code as string
|
||||
|
||||
type Status = 'loading' | 'ready' | 'invalid' | 'done'
|
||||
|
||||
const status = ref<Status>('loading')
|
||||
const invalidReason = ref('')
|
||||
const note = ref<string | null>(null)
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch(`/api/invites/${code}/info`)
|
||||
const data = await res.json()
|
||||
if (!res.ok || !data.valid) {
|
||||
invalidReason.value = data.reason ?? 'Lien invalide'
|
||||
status.value = 'invalid'
|
||||
return
|
||||
}
|
||||
note.value = data.note
|
||||
status.value = 'ready'
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
if (!username.value || !password.value || !confirm.value) {
|
||||
error.value = 'Tous les champs sont requis'; return
|
||||
}
|
||||
if (password.value !== confirm.value) {
|
||||
error.value = 'Les mots de passe ne correspondent pas'; return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
const res = await fetch(`/api/invites/${code}/register`, {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify({ username: username.value, password: password.value }),
|
||||
})
|
||||
const data = await res.json()
|
||||
submitting.value = false
|
||||
|
||||
if (!res.ok) { error.value = data.error; return }
|
||||
status.value = 'done'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<div class="px-4 md:px-8 py-8 max-w-4xl">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-primary">Demandes</h1>
|
||||
<p class="text-sm text-muted mt-0.5">
|
||||
{{ auth.isAdmin ? 'Toutes les demandes des utilisateurs.' : 'Vos demandes de séries.' }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Actions admin -->
|
||||
<div v-if="auth.isAdmin" class="flex items-center gap-2">
|
||||
<button
|
||||
@click="removeAll"
|
||||
class="px-3 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 border border-red-500/20 transition"
|
||||
>Tout supprimer</button>
|
||||
</div>
|
||||
<!-- Filtres statuts -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="f in filters" :key="f.value"
|
||||
@click="activeFilter = f.value"
|
||||
class="px-3 py-1.5 rounded-lg text-xs transition"
|
||||
:class="activeFilter === f.value
|
||||
? 'bg-accent text-white'
|
||||
: 'text-muted hover:text-primary hover:bg-hover'"
|
||||
>
|
||||
{{ f.label }}
|
||||
<span v-if="counts[f.value]" class="ml-1 opacity-70">({{ counts[f.value] }})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistiques (admin) -->
|
||||
<div v-if="auth.isAdmin" class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">En attente</span>
|
||||
<span class="text-2xl font-semibold text-yellow-400">{{ counts.pending }}</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Approuvées</span>
|
||||
<span class="text-2xl font-semibold text-blue-400">{{ counts.approved }}</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Disponibles</span>
|
||||
<span class="text-2xl font-semibold text-green-400">{{ counts.completed }}</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Refusées</span>
|
||||
<span class="text-2xl font-semibold" :class="counts.rejected > 0 ? 'text-red-400' : 'text-muted'">{{ counts.rejected }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste -->
|
||||
<div v-if="loading" class="flex items-center gap-2 text-muted text-sm py-8">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
</div>
|
||||
|
||||
<div v-else-if="filtered.length === 0" class="settings-card text-sm text-muted py-8 text-center">
|
||||
Aucune demande {{ activeFilter !== 'all' ? `avec ce statut` : '' }}.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="req in filtered" :key="req.id"
|
||||
class="settings-card flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<!-- Infos série -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<RouterLink :to="`/series/${req.serieId}`" class="text-sm font-semibold text-primary hover:text-accent transition truncate">
|
||||
{{ req.serieName }}
|
||||
</RouterLink>
|
||||
<span class="text-xs px-2 py-0.5 rounded border shrink-0" :class="statusClass(req.status)">
|
||||
{{ statusLabel(req.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Saisons / épisodes fusionnés -->
|
||||
<p class="text-xs text-muted mt-1">
|
||||
<template v-if="mergedEpisodes(req).length > 0">
|
||||
{{ mergedEpisodes(req).length }} épisode{{ mergedEpisodes(req).length > 1 ? 's' : '' }} spécifique{{ mergedEpisodes(req).length > 1 ? 's' : '' }}
|
||||
<template v-if="mergedSeasons(req).length > 0"> · Saisons : {{ mergedSeasons(req).map((s: number) => s === 0 ? 'SP' : `S${s}`).join(', ') }}</template>
|
||||
</template>
|
||||
<template v-else-if="mergedSeasons(req).length === 0">Toutes les saisons</template>
|
||||
<template v-else>Saisons : {{ mergedSeasons(req).map((s: number) => s === 0 ? 'SP' : `S${s}`).join(', ') }}</template>
|
||||
</p>
|
||||
<!-- Message de refus -->
|
||||
<p v-if="req.rejectionMessage" class="text-xs text-red-400 mt-1">
|
||||
Refus : {{ req.rejectionMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions admin -->
|
||||
<div v-if="auth.isAdmin" class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
v-if="req.status === 'pending' || req.status === 'rejected'"
|
||||
@click="approve(req)"
|
||||
class="btn-ghost text-xs text-blue-400 hover:text-blue-300"
|
||||
>Approuver</button>
|
||||
<button
|
||||
v-if="req.status === 'approved'"
|
||||
@click="complete(req)"
|
||||
class="btn-ghost text-xs text-green-400 hover:text-green-300"
|
||||
>Marquer disponible</button>
|
||||
<button
|
||||
v-if="req.status !== 'rejected'"
|
||||
@click="openRejectModal(req)"
|
||||
class="btn-ghost text-xs text-red-400 hover:text-red-300"
|
||||
>Refuser</button>
|
||||
<button @click="remove(req)" class="btn-ghost text-xs text-muted hover:text-red-400">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requesters (admin) -->
|
||||
<div v-if="auth.isAdmin && req.requesters.length > 0" class="flex flex-wrap gap-2">
|
||||
<div v-for="r in req.requesters" :key="r.userId"
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-shell border border-border text-xs">
|
||||
<span class="w-4 h-4 rounded-full bg-border flex items-center justify-center text-[9px] font-semibold text-muted shrink-0">
|
||||
{{ r.username[0].toUpperCase() }}
|
||||
</span>
|
||||
<span class="text-muted">{{ r.username }}</span>
|
||||
<span v-if="r.episodes?.length > 0" class="text-muted/60">
|
||||
({{ r.episodes.length }} ep.)
|
||||
</span>
|
||||
<span v-else-if="r.seasons.length > 0" class="text-muted/60">
|
||||
({{ r.seasons.map((s: number) => s === 0 ? 'SP' : `S${s}`).join(', ') }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-muted">Demandé {{ formatRelative(req.createdAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal refus -->
|
||||
<Teleport to="body">
|
||||
<div v-if="rejectModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="rejectModal = null">
|
||||
<div class="settings-card w-full max-w-sm flex flex-col gap-4 p-6">
|
||||
<p class="text-sm font-semibold text-primary">Refuser la demande</p>
|
||||
<p class="text-xs text-muted truncate">{{ rejectModal.serieName }}</p>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Message de refus (optionnel)</label>
|
||||
<input v-model="rejectMessage" type="text" class="settings-input" placeholder="Raison du refus…" @keyup.enter="confirmReject" />
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="rejectModal = null" class="btn-ghost">Annuler</button>
|
||||
<button @click="confirmReject" class="px-4 py-2 rounded-lg border border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20 transition text-xs">
|
||||
Refuser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface Requester { userId: string; username: string; seasons: number[]; episodes: number[]; requestedAt: string }
|
||||
interface SerieRequest {
|
||||
id: string; serieId: number; serieName: string
|
||||
requesters: Requester[]; status: string
|
||||
rejectionMessage?: string; createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const requests = ref<SerieRequest[]>([])
|
||||
|
||||
const activeFilter = ref<string>('all')
|
||||
const filters = [
|
||||
{ value: 'all', label: 'Toutes' },
|
||||
{ value: 'pending', label: 'En attente' },
|
||||
{ value: 'approved', label: 'Approuvées' },
|
||||
{ value: 'completed', label: 'Disponibles' },
|
||||
{ value: 'rejected', label: 'Refusées' },
|
||||
]
|
||||
|
||||
const counts = computed(() => ({
|
||||
all : requests.value.length,
|
||||
pending : requests.value.filter(r => r.status === 'pending').length,
|
||||
approved : requests.value.filter(r => r.status === 'approved').length,
|
||||
completed: requests.value.filter(r => r.status === 'completed').length,
|
||||
rejected : requests.value.filter(r => r.status === 'rejected').length,
|
||||
}))
|
||||
|
||||
const filtered = computed(() =>
|
||||
activeFilter.value === 'all'
|
||||
? requests.value
|
||||
: requests.value.filter(r => r.status === activeFilter.value)
|
||||
)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const res = await fetch('/api/requests', { credentials: 'include' })
|
||||
if (res.ok) requests.value = await res.json()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
// ── Actions admin ─────────────────────────────────────────────
|
||||
async function approve(req: SerieRequest) {
|
||||
const res = await fetch(`/api/requests/${req.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ action: 'approve' }),
|
||||
})
|
||||
if (res.ok) load()
|
||||
}
|
||||
|
||||
async function complete(req: SerieRequest) {
|
||||
const res = await fetch(`/api/requests/${req.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ action: 'complete' }),
|
||||
})
|
||||
if (res.ok) load()
|
||||
}
|
||||
|
||||
async function remove(req: SerieRequest) {
|
||||
if (!confirm(`Supprimer la demande pour "${req.serieName}" ?`)) return
|
||||
await fetch(`/api/requests/${req.id}`, { method: 'DELETE', credentials: 'include' })
|
||||
load()
|
||||
}
|
||||
|
||||
async function removeAll() {
|
||||
const n = requests.value.length
|
||||
if (!n) return
|
||||
if (!confirm(`Supprimer les ${n} demande${n > 1 ? 's' : ''} ?`)) return
|
||||
await fetch('/api/requests', { method: 'DELETE', credentials: 'include' })
|
||||
load()
|
||||
}
|
||||
|
||||
const rejectModal = ref<SerieRequest | null>(null)
|
||||
const rejectMessage = ref('')
|
||||
function openRejectModal(req: SerieRequest) { rejectModal.value = req; rejectMessage.value = '' }
|
||||
async function confirmReject() {
|
||||
if (!rejectModal.value) return
|
||||
await fetch(`/api/requests/${rejectModal.value.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ action: 'reject', rejectionMessage: rejectMessage.value || undefined }),
|
||||
})
|
||||
rejectModal.value = null
|
||||
load()
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
function mergedSeasons(req: SerieRequest): number[] {
|
||||
if (req.requesters.some(r => r.seasons.length === 0 && (r.episodes?.length ?? 0) === 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
return { pending: 'En attente', approved: 'Approuvée', rejected: 'Refusée', completed: 'Disponible' }[s] ?? s
|
||||
}
|
||||
function statusClass(s: string) {
|
||||
return {
|
||||
pending : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
||||
approved : 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
rejected : 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
|
||||
}[s] ?? ''
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const m = Math.floor(diff / 60000), h = Math.floor(diff / 3600000), d = Math.floor(diff / 86400000)
|
||||
if (m < 1) return "à l'instant"
|
||||
if (m < 60) return `il y a ${m}min`
|
||||
if (h < 24) return `il y a ${h}h`
|
||||
return `il y a ${d}j`
|
||||
}
|
||||
</script>
|
||||
+308
-37
@@ -17,8 +17,8 @@
|
||||
<!-- Hero — poster, titre, métadonnées, synopsis -->
|
||||
<SerieHero :serie="data.serie" />
|
||||
|
||||
<!-- Barre d'actions -->
|
||||
<div class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
|
||||
<!-- Barre d'actions admin -->
|
||||
<div v-if="auth.isAdmin" class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
|
||||
|
||||
<!-- Gauche : gestion bibliothèque -->
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -35,30 +35,39 @@
|
||||
Import manuel
|
||||
</button>
|
||||
|
||||
<!-- Désimporter -->
|
||||
<div v-if="Object.keys(organizedByEpisode).length > 0" class="relative" @click.stop>
|
||||
<button
|
||||
@click="unimportMenuOpen = !unimportMenuOpen"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 transition"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>
|
||||
</svg>
|
||||
Désimporter
|
||||
</button>
|
||||
<div v-if="unimportMenuOpen" class="absolute top-full left-0 mt-1 bg-card border border-border rounded-xl p-1 z-20 w-48 shadow-xl flex flex-col gap-0.5">
|
||||
<button @click="unimportSerie(false); unimportMenuOpen = false" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:bg-hover transition-colors">
|
||||
Retirer de la bibliothèque
|
||||
</button>
|
||||
<button @click="unimportSerie(true); unimportMenuOpen = false" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-red-400 hover:bg-red-500/10 transition-colors">
|
||||
Supprimer les fichiers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Supprimer série -->
|
||||
<button
|
||||
v-if="Object.keys(organizedByEpisode).length > 0"
|
||||
@click="openUnimportSerieModal"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 transition"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>
|
||||
</svg>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Droite : téléchargements -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Bouton RSS sync -->
|
||||
<button
|
||||
@click="toggleRssSync"
|
||||
:title="rssSync ? 'Surveillance activée — cliquer pour désactiver' : 'Surveiller les nouveaux épisodes'"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
|
||||
:class="rssSync
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20 hover:bg-green-500/20'
|
||||
: 'bg-shell text-muted border-border hover:text-primary hover:bg-hover'"
|
||||
>
|
||||
<!-- Icône antenne RSS -->
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M4 11a9 9 0 0 1 9 9"/>
|
||||
<path d="M4 4a16 16 0 0 1 16 16"/>
|
||||
<circle cx="5" cy="19" r="1" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
{{ rssSync ? 'Surveillé' : 'Surveiller' }}
|
||||
</button>
|
||||
|
||||
<!-- Bouton unique "Tout télécharger" — dropdown si plusieurs intégrales -->
|
||||
<div v-if="hasSomethingToDownload" class="relative" @click.stop>
|
||||
<button
|
||||
@@ -94,6 +103,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre utilisateur (mode demande) -->
|
||||
<div v-if="!auth.isAdmin" class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
|
||||
<div v-if="myRequest" class="flex items-center gap-3">
|
||||
<span class="text-xs px-2 py-1 rounded border" :class="requestStatusClass(myRequest.status)">
|
||||
{{ requestStatusLabel(myRequest.status) }}
|
||||
</span>
|
||||
<span v-if="myRequest.status === 'rejected' && myRequest.rejectionMessage" class="text-xs text-muted">
|
||||
{{ myRequest.rejectionMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="text-xs text-muted">Cliquez sur une saison ou un épisode pour faire une demande</div>
|
||||
<button
|
||||
v-if="!myRequest || myRequest.status === 'rejected' || myRequest.status === 'completed'"
|
||||
@click="openRequestModal()"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent text-white hover:bg-accent-hover transition"
|
||||
>
|
||||
Demander la série
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Saisons -->
|
||||
<div class="px-4 md:px-8 pb-16 flex flex-col gap-4 pt-4">
|
||||
<SerieSeasonCard
|
||||
@@ -108,15 +137,80 @@
|
||||
:ep-action-loading="epActionLoading"
|
||||
:downloading-season="!!downloadingSeason[season.id]"
|
||||
:nfo-support="nfoSupport"
|
||||
:request-mode="!auth.isAdmin"
|
||||
:requested-seasons="requestedSeasonNumbers"
|
||||
:requested-episodes="requestedEpisodeIds"
|
||||
@toggle="toggleSeason"
|
||||
@download="(key, url, magnet, fi, fp) => download(key, url, magnet, fi, fp)"
|
||||
@download="(key, url, magnet, fi, fp, ih) => download(key, url, magnet, fi, fp, ih)"
|
||||
@download-season="(s, h) => downloadSeason(s, h)"
|
||||
@rename-episode="(ep, s) => renameEpisode(ep, s)"
|
||||
@unimport-episode="(ep, s, del) => unimportEpisode(ep, s, del)"
|
||||
@unimport-season="(s, del) => unimportSeason(s, del)"
|
||||
@request-season="handleRequestSeason"
|
||||
@request-episode="handleRequestEpisode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Modal demande (utilisateurs) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="requestModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="requestModal = false">
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-5">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-primary">Demander une série</p>
|
||||
<p class="text-xs text-muted mt-1 truncate">{{ data?.serie?.title }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs text-muted font-medium">Saisons demandées</p>
|
||||
<label
|
||||
v-for="season in data?.seasons"
|
||||
:key="season.id"
|
||||
class="flex items-center gap-3 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="season.season_number"
|
||||
v-model="requestSeasons"
|
||||
class="w-4 h-4 rounded border-border accent-accent"
|
||||
/>
|
||||
<span class="text-sm 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">— {{ season.title }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button @click="requestModal = false" class="btn-secondary text-xs">Annuler</button>
|
||||
<button @click="submitRequest" :disabled="requestSeasons.length === 0" class="btn-primary text-xs disabled:opacity-50">
|
||||
Envoyer la demande
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<!-- Modal désimport série -->
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportSerieModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="unimportSerieModal = false">
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-5">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-primary">Supprimer la série</p>
|
||||
<p class="text-xs text-muted mt-1 truncate">{{ data?.serie?.title }}</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteSerieFiles" class="w-4 h-4 rounded border-border accent-accent" />
|
||||
<span class="text-xs text-muted">Supprimer également les fichiers physiques du disque</span>
|
||||
</label>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button @click="unimportSerieModal = false" class="btn-secondary text-xs">Annuler</button>
|
||||
<button @click="confirmUnimportSerie" class="px-4 py-2 rounded-lg border border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20 transition-colors text-xs font-medium">
|
||||
{{ deleteSerieFiles ? 'Supprimer les fichiers' : 'Supprimer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Modal import manuel -->
|
||||
<ManualImportModal
|
||||
v-if="manualImportOpen && data"
|
||||
@@ -126,7 +220,7 @@
|
||||
:organized="organizedByEpisode"
|
||||
:initial-path="mediaPath"
|
||||
@close="manualImportOpen = false"
|
||||
@imported="load(); fetchOrganized(); manualImportOpen = false"
|
||||
@imported="load(); fetchOrganized()"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -136,6 +230,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import ManualImportModal from '@/components/ManualImportModal.vue'
|
||||
import SerieHero from '@/components/serie/SerieHero.vue'
|
||||
@@ -144,8 +239,130 @@ import type { Season } from '@/stores/series'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useSeriesStore()
|
||||
const auth = useAuthStore()
|
||||
const { add: toast } = useToast()
|
||||
|
||||
// ── Mode demande (utilisateurs non-admin) ─────────────────────
|
||||
const myRequest = ref<any>(null) // demande existante de l'utilisateur pour cette série
|
||||
const requestModal = ref(false)
|
||||
const requestSeasons = ref<number[]>([]) // saisons cochées dans le modal
|
||||
|
||||
const requestedSeasonNumbers = computed<number[]>(() => {
|
||||
if (!myRequest.value) return []
|
||||
const r = myRequest.value.requesters?.find((r: any) => r.userId === auth.userId)
|
||||
if (!r) return []
|
||||
// seasons=[] ET episodes=[] → toutes les saisons demandées
|
||||
// seasons=[] mais episodes=[…] → seulement des épisodes ciblés, pas toute une saison
|
||||
if (r.seasons.length === 0 && (r.episodes ?? []).length === 0) {
|
||||
return data.value?.seasons?.map((s: any) => s.season_number) ?? []
|
||||
}
|
||||
return r.seasons
|
||||
})
|
||||
|
||||
const requestedEpisodeIds = computed<number[]>(() => {
|
||||
if (!myRequest.value) return []
|
||||
const r = myRequest.value.requesters?.find((r: any) => r.userId === auth.userId)
|
||||
if (!r) return []
|
||||
return r.episodes ?? []
|
||||
})
|
||||
|
||||
async function fetchMyRequest() {
|
||||
try {
|
||||
const res = await fetch('/api/requests', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const list = await res.json()
|
||||
myRequest.value = list.find((r: any) =>
|
||||
r.serieId === Number(route.params.id) &&
|
||||
r.status !== 'rejected' && r.status !== 'completed'
|
||||
) ?? null
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openRequestModal(preselectedSeason?: number) {
|
||||
// Pré-cocher soit la saison cliquée, soit toutes les saisons disponibles
|
||||
if (preselectedSeason !== undefined) {
|
||||
requestSeasons.value = [preselectedSeason]
|
||||
} else {
|
||||
requestSeasons.value = data.value?.seasons?.map((s: any) => s.season_number) ?? []
|
||||
}
|
||||
requestModal.value = true
|
||||
}
|
||||
|
||||
function handleRequestSeason(seasonNumber: number) {
|
||||
openRequestModal(seasonNumber)
|
||||
}
|
||||
|
||||
async function handleRequestEpisode(_seasonNumber: number, episodeId: number, torrent?: any) {
|
||||
if (!data.value) return
|
||||
try {
|
||||
const body: Record<string, any> = {
|
||||
serieId : Number(route.params.id),
|
||||
serieName: data.value.serie.title,
|
||||
seasons : [],
|
||||
episodes : [episodeId],
|
||||
}
|
||||
if (torrent) {
|
||||
body.torrentOverride = {
|
||||
torrent_url: torrent.torrent_url ?? null,
|
||||
magnet : torrent.magnet ?? null,
|
||||
infohash : torrent.infohash ?? null,
|
||||
file_index : torrent.file_index ?? null,
|
||||
file_path : torrent.file_path ?? null,
|
||||
}
|
||||
}
|
||||
const res = await fetch('/api/requests', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res.ok) {
|
||||
myRequest.value = await res.json()
|
||||
toast('Épisode demandé ✓', 'success')
|
||||
} else {
|
||||
const d = await res.json()
|
||||
toast(d.error ?? 'Erreur', 'error')
|
||||
}
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
}
|
||||
|
||||
async function submitRequest() {
|
||||
if (!data.value) return
|
||||
try {
|
||||
const res = await fetch('/api/requests', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
serieId : Number(route.params.id),
|
||||
serieName: data.value.serie.title,
|
||||
seasons : requestSeasons.value,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
myRequest.value = await res.json()
|
||||
requestModal.value = false
|
||||
toast('Demande envoyée ✓', 'success')
|
||||
} else {
|
||||
const d = await res.json()
|
||||
toast(d.error ?? 'Erreur', 'error')
|
||||
}
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
}
|
||||
|
||||
function requestStatusLabel(status: string) {
|
||||
return { pending: 'En attente', approved: 'Approuvée', rejected: 'Refusée', completed: 'Disponible' }[status] ?? status
|
||||
}
|
||||
function requestStatusClass(status: string) {
|
||||
return {
|
||||
pending : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
||||
approved : 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
rejected : 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
|
||||
}[status] ?? ''
|
||||
}
|
||||
|
||||
const collapsedSeasons = ref<Set<number>>(new Set())
|
||||
const downloading = ref<string[]>([])
|
||||
const downloaded = ref<string[]>([])
|
||||
@@ -154,10 +371,12 @@ const organizedByEpisode = ref<Record<string, any>>({})
|
||||
const mediaPath = ref('/')
|
||||
const nfoSupport = ref(false)
|
||||
const epActionLoading = ref<Record<number, boolean>>({})
|
||||
const unimportMenuOpen = ref(false)
|
||||
const unimportSerieModal = ref(false)
|
||||
const deleteSerieFiles = ref(false)
|
||||
const downloadMenuOpen = ref(false)
|
||||
const downloadingAll = ref(false)
|
||||
const downloadingSeason = ref<Record<number, boolean>>({})
|
||||
const rssSync = ref(false)
|
||||
|
||||
export interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[]; save_path?: string; name?: string }
|
||||
const activeTorrents = ref<ActiveTorrent[]>([])
|
||||
@@ -214,7 +433,13 @@ function isDownloading(key: string) { return downloading.value.includes(key) }
|
||||
function isDownloaded(key: string) { return downloaded.value.includes(key) }
|
||||
function addDownloading(key: string) { if (!downloading.value.includes(key)) downloading.value.push(key) }
|
||||
function removeDownloading(key: string) { downloading.value = downloading.value.filter(k => k !== key) }
|
||||
function addDownloaded(key: string) { if (!downloaded.value.includes(key)) downloaded.value.push(key) }
|
||||
function addDownloaded(key: string) { if (!downloaded.value.includes(key)) downloaded.value.push(key) }
|
||||
function removeDownloaded(key: string) { downloaded.value = downloaded.value.filter(k => k !== key) }
|
||||
function clearEpDownloaded(ep: any) {
|
||||
removeDownloaded(`ep-${ep.id}`)
|
||||
const count = ep.torrents?.length ?? 0
|
||||
for (let i = 0; i < count; i++) removeDownloaded(`ep-${ep.id}-${i}`)
|
||||
}
|
||||
function extractHash(torrent: any): string | null {
|
||||
if (torrent?.infohash) return torrent.infohash.toLowerCase()
|
||||
if (!torrent?.magnet) return null
|
||||
@@ -247,6 +472,30 @@ async function fetchSettings() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchRssSync() {
|
||||
try {
|
||||
const res = await fetch(`/api/rss-sync/${route.params.id}`, { credentials: 'include' })
|
||||
if (res.ok) { const d = await res.json(); rssSync.value = !!d.synced }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function toggleRssSync() {
|
||||
const serieId = Number(route.params.id)
|
||||
const serieName = data.value?.serie?.title ?? String(serieId)
|
||||
try {
|
||||
if (rssSync.value) {
|
||||
const res = await fetch(`/api/rss-sync/${serieId}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (res.ok) { rssSync.value = false; toast('Surveillance désactivée', 'success') }
|
||||
} else {
|
||||
const res = await fetch(`/api/rss-sync/${serieId}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ serieName }),
|
||||
})
|
||||
if (res.ok) { rssSync.value = true; toast('Surveillance activée — les nouveaux épisodes seront téléchargés automatiquement ✓', 'success') }
|
||||
}
|
||||
} catch { toast('Erreur lors de la mise à jour de la surveillance', 'error') }
|
||||
}
|
||||
|
||||
async function fetchActiveDownloads() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
@@ -264,7 +513,7 @@ async function fetchActiveDownloads() {
|
||||
const key = `${t.hash}:${f.index}`
|
||||
const prevProg = _prevFileProgresses.get(key)
|
||||
_prevFileProgresses.set(key, f.progress)
|
||||
if (!isFirstPoll && prevProg !== undefined && prevProg < 100 && f.progress === 100 && !_recentlyOrganized.has(key)) {
|
||||
if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(key)) {
|
||||
_recentlyOrganized.add(key)
|
||||
triggerOrganize(t)
|
||||
}
|
||||
@@ -314,16 +563,16 @@ async function triggerOrganize(torrent: any) {
|
||||
function openManualImport() { fetchOrganized(); manualImportOpen.value = true }
|
||||
|
||||
// ── Download ──────────────────────────────────────────────────
|
||||
async function download(key: string, torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null) {
|
||||
async function download(key: string, torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null) {
|
||||
if (isDownloading(key) || isDownloaded(key)) return
|
||||
addDownloading(key)
|
||||
const result = await store.download(torrent_url, magnet, file_index, file_path)
|
||||
const result = await store.download(torrent_url, magnet, file_index, file_path, infohash)
|
||||
removeDownloading(key)
|
||||
if (result.success) { addDownloaded(key); toast('Téléchargement lancé ✓', 'success'); fetchActiveDownloads() }
|
||||
else toast(result.error ?? 'Erreur inconnue', 'error')
|
||||
}
|
||||
|
||||
type DlItem = { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }
|
||||
type DlItem = { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null; infohash?: string | null }
|
||||
|
||||
// Construit la liste de tout ce qui doit être téléchargé.
|
||||
// integraleIndex : si défini, utilise uniquement cette intégrale (+ épisodes non couverts).
|
||||
@@ -369,7 +618,7 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
for (const season of data.value.seasons)
|
||||
for (const ep of season.episodes) {
|
||||
if (!ep.torrent || !ep.available || ep.organized || isAlreadyQueued(ep.torrent) || isDownloaded(`ep-${ep.id}`) || covered.has(ep.id)) continue
|
||||
result.push({ key: `ep-${ep.id}`, torrent_url: ep.torrent.torrent_url, magnet: ep.torrent.magnet, file_index: ep.torrent.file_index ?? null, file_path: ep.torrent.file_path ?? null })
|
||||
result.push({ key: `ep-${ep.id}`, torrent_url: ep.torrent.torrent_url, magnet: ep.torrent.magnet, file_index: ep.torrent.file_index ?? null, file_path: ep.torrent.file_path ?? null, infohash: ep.torrent.infohash ?? null })
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -393,7 +642,7 @@ async function downloadAll(integraleIndex?: number) {
|
||||
const torrents = collectDownloadables(integraleIndex)
|
||||
let sent = 0
|
||||
for (const t of torrents) {
|
||||
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
|
||||
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
|
||||
}
|
||||
downloadingAll.value = false
|
||||
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
|
||||
@@ -418,12 +667,12 @@ async function downloadSeason(season: Season, packHash?: string) {
|
||||
? (ep.torrents?.find((t: any) => (t.infohash ?? extractHash(t)) === packHash) ?? ep.torrent)
|
||||
: ep.torrent
|
||||
if (!epTorrent || !ep.available || ep.organized || isAlreadyQueued(epTorrent) || isDownloaded(`ep-${ep.id}`)) continue
|
||||
torrents.push({ key: `ep-${ep.id}`, torrent_url: epTorrent.torrent_url, magnet: epTorrent.magnet, file_index: epTorrent.file_index ?? null, file_path: epTorrent.file_path ?? null })
|
||||
torrents.push({ key: `ep-${ep.id}`, torrent_url: epTorrent.torrent_url, magnet: epTorrent.magnet, file_index: epTorrent.file_index ?? null, file_path: epTorrent.file_path ?? null, infohash: epTorrent.infohash ?? null })
|
||||
}
|
||||
}
|
||||
let sent = 0
|
||||
for (const t of torrents) {
|
||||
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
|
||||
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
|
||||
}
|
||||
downloadingSeason.value[season.id] = false
|
||||
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
|
||||
@@ -436,12 +685,28 @@ function toggleSeason(id: number) {
|
||||
else collapsedSeasons.value.add(id)
|
||||
}
|
||||
|
||||
function openUnimportSerieModal() { deleteSerieFiles.value = false; unimportSerieModal.value = true }
|
||||
async function confirmUnimportSerie() { unimportSerieModal.value = false; await unimportSerie(deleteSerieFiles.value) }
|
||||
|
||||
async function unimportSeason(season: any, deleteFile: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${route.params.id}/seasons/${season.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport saison', 'error'); return }
|
||||
const d = await res.json()
|
||||
toast(`${d.removed} épisode(s) désimporté(s) ✓`, 'success')
|
||||
for (const ep of season.episodes ?? []) clearEpDownloaded(ep)
|
||||
removeDownloaded(`season-${season.id}`)
|
||||
await fetchOrganized(); load()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
}
|
||||
|
||||
async function unimportSerie(deleteFile: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${route.params.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
|
||||
const d = await res.json()
|
||||
toast(`${d.removed} épisode(s) désimporté(s) ✓`, 'success')
|
||||
downloaded.value = []
|
||||
await fetchOrganized(); load()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
}
|
||||
@@ -468,16 +733,22 @@ async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
|
||||
const res = await fetch(`/api/organized/${route.params.id}/${ep.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
|
||||
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
|
||||
clearEpDownloaded(ep)
|
||||
await fetchOrganized(); load()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { epActionLoading.value[ep.id] = false }
|
||||
}
|
||||
|
||||
const closeMenus = () => { unimportMenuOpen.value = false; downloadMenuOpen.value = false }
|
||||
const closeMenus = () => { downloadMenuOpen.value = false }
|
||||
|
||||
onMounted(() => {
|
||||
load(); fetchSettings(); fetchOrganized(); fetchActiveDownloads()
|
||||
pollTimer = setInterval(fetchActiveDownloads, 5000)
|
||||
load()
|
||||
if (auth.isAdmin) {
|
||||
fetchSettings(); fetchOrganized(); fetchActiveDownloads(); fetchRssSync()
|
||||
pollTimer = setInterval(fetchActiveDownloads, 5000)
|
||||
} else {
|
||||
fetchMyRequest()
|
||||
}
|
||||
document.addEventListener('click', closeMenus)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -88,8 +88,9 @@
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Tv } from 'lucide-vue-next'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { usePosterSize } from '@/composables/usePosterSize'
|
||||
import SeriesToolbar from '@/components/series/SeriesToolbar.vue'
|
||||
@@ -98,6 +99,7 @@ defineOptions({ name: 'SeriesView' })
|
||||
|
||||
const store = useSeriesStore()
|
||||
const dlStore = useDownloadsStore()
|
||||
const auth = useAuthStore()
|
||||
const { add: toast } = useToast()
|
||||
const { current: posterSize, sizes: posterSizes } = usePosterSize()
|
||||
|
||||
@@ -198,17 +200,19 @@ async function fetchOrganizeNotifs() {
|
||||
|
||||
onMounted(async () => {
|
||||
if (store.series.length === 0) await store.fetchSeries()
|
||||
dlStore.refresh()
|
||||
fetchOrganizeNotifs()
|
||||
dlInterval = setInterval(() => {
|
||||
if (auth.isAdmin) {
|
||||
dlStore.refresh()
|
||||
fetchOrganizeNotifs()
|
||||
}, 10000)
|
||||
dlInterval = setInterval(() => {
|
||||
dlStore.refresh()
|
||||
fetchOrganizeNotifs()
|
||||
}, 10000)
|
||||
}
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
store.fetchSeries()
|
||||
dlStore.refresh()
|
||||
if (auth.isAdmin) dlStore.refresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
const devMode = ref(false)
|
||||
const saved = ref(false)
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let loaded = false
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||
@@ -33,9 +34,11 @@ onMounted(async () => {
|
||||
const s = await res.json()
|
||||
devMode.value = s.devMode ?? false
|
||||
}
|
||||
loaded = true
|
||||
})
|
||||
|
||||
watch(devMode, async (val) => {
|
||||
if (!loaded) return
|
||||
await fetch('/api/settings', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -48,40 +48,64 @@
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-border bg-shell">
|
||||
<th class="text-left px-4 py-2.5 text-muted font-medium">Client</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Sélection fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Catégories</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Dossier cible</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Remap. chemin</th>
|
||||
<th class="text-left px-4 py-2.5 text-muted font-medium whitespace-nowrap">Client</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Auth</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Sél. fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Prog. fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Upload .torrent</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Catégories</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Suppr. fichiers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/60">
|
||||
<tr v-for="row in clientMatrix" :key="row.id" class="hover:bg-hover/40 transition-colors">
|
||||
<!-- Client + version -->
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === true ? 'bg-green-500' :
|
||||
healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === false ? 'bg-red-500' : 'bg-transparent'" />
|
||||
<span class="text-primary font-medium">{{ row.label }}</span>
|
||||
:class="healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === true ? 'bg-green-500' :
|
||||
healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === false ? 'bg-red-500' : 'bg-transparent'" />
|
||||
<div>
|
||||
<p class="text-primary font-medium">{{ row.label }}</p>
|
||||
<p class="text-[10px] text-muted mt-0.5">{{ row.version }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Auth -->
|
||||
<td class="text-center px-3 py-2.5 text-secondary whitespace-nowrap">{{ row.auth }}</td>
|
||||
<!-- Sélection fichier -->
|
||||
<td class="text-center px-3 py-2.5">
|
||||
<span :class="row.fileSelect === '✗' ? 'text-muted' : row.fileSelect.startsWith('Async') ? 'text-yellow-500' : 'text-green-400'">
|
||||
{{ row.fileSelect }}
|
||||
<span :class="row.fileSelect ? 'text-yellow-400' : 'text-muted'">
|
||||
{{ row.fileSelect ? 'Async ✓' : '✗' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-green-400' : 'text-muted'">{{ row.categories ? '✓' : '✗' }}</td>
|
||||
<td class="text-center px-3 py-2.5 text-green-400">✓</td>
|
||||
<td class="text-center px-3 py-2.5 text-green-400">✓</td>
|
||||
<!-- Progression par fichier -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.fileProgress ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.fileProgress ? '✓' : '✗' }}
|
||||
</td>
|
||||
<!-- Upload .torrent (FanKarr proxy) -->
|
||||
<td class="text-center px-3 py-2.5">
|
||||
<span :class="row.torrentUpload === 'proxy' ? 'text-green-400' : row.torrentUpload === 'url' ? 'text-secondary' : 'text-muted'">
|
||||
{{ row.torrentUpload === 'proxy' ? '✓ Proxy' : row.torrentUpload === 'url' ? 'URL' : '✗' }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Catégories -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.categories ? '✓' : '✗' }}
|
||||
</td>
|
||||
<!-- Suppression fichiers -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.deleteFiles ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.deleteFiles ? '✓' : '✗' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted mt-2">
|
||||
<span class="text-green-400">✓ Natif</span> — sélection à l'ajout, aucun fichier superflu ·
|
||||
<span class="text-green-400">Async ✓</span> — sélection dès réception des métadonnées, avant tout téléchargement ·
|
||||
<span class="text-muted">✗</span> — non supporté, torrent complet téléchargé
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-[11px] text-muted">
|
||||
<span><span class="text-yellow-400">Async ✓</span> — sélection dès réception des métadonnées, aucun octet superflu téléchargé</span>
|
||||
<span><span class="text-green-400">✓ Proxy</span> — FanKarr télécharge le .torrent et l'envoie au client (liens protégés supportés)</span>
|
||||
<span><span class="text-secondary">URL</span> — le client télécharge le .torrent directement depuis l'URL</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal ajout -->
|
||||
@@ -110,18 +134,61 @@
|
||||
<option v-for="def in availableClients" :key="def.id" :value="def.id">{{ def.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="currentDefinition" v-for="field in currentDefinition.fields" :key="field.key">
|
||||
<label class="settings-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="modal.config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="field.placeholder ?? ''"
|
||||
class="settings-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Champs principaux -->
|
||||
<template v-if="currentDefinition">
|
||||
<div v-for="field in basicFields" :key="field.key">
|
||||
<label class="settings-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="modal.config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="field.placeholder ?? ''"
|
||||
class="settings-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Paramètres avancés -->
|
||||
<div v-if="advancedFields.length > 0">
|
||||
<button
|
||||
type="button"
|
||||
@click="advancedOpen = !advancedOpen"
|
||||
class="flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-full py-1"
|
||||
>
|
||||
<ChevronDown
|
||||
:size="13"
|
||||
class="transition-transform duration-200 shrink-0"
|
||||
:class="advancedOpen ? 'rotate-180' : ''"
|
||||
/>
|
||||
Paramètres avancés
|
||||
</button>
|
||||
|
||||
<div v-if="advancedOpen" class="flex flex-col gap-3 mt-2 pl-3 border-l border-border">
|
||||
<div v-for="field in advancedFields" :key="field.key">
|
||||
<label class="settings-label flex items-center gap-1.5">
|
||||
{{ field.label }}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-4 h-4 rounded-full border border-border text-[10px] text-muted cursor-default leading-none shrink-0 relative group"
|
||||
tabindex="-1"
|
||||
>
|
||||
i
|
||||
<span class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-56 text-[11px] text-secondary bg-card border border-border rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-10 leading-relaxed">
|
||||
{{ fieldTooltip(field.key) }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="modal.config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
placeholder=""
|
||||
class="settings-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p v-if="!modal.tested && modal.type" class="text-xs text-muted">
|
||||
@@ -154,6 +221,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
@@ -163,11 +231,34 @@ const availableClients = ref<any[]>([])
|
||||
const healthStatus = ref<Record<string, boolean | null>>({})
|
||||
|
||||
const clientMatrix = [
|
||||
{ id: 'qbittorrent', label: 'qBittorrent', fileSelect: 'Async ✓', categories: true },
|
||||
{ id: 'transmission', label: 'Transmission', fileSelect: '✓ Natif', categories: true },
|
||||
{ id: 'rtorrent', label: 'rTorrent / ruTorrent', fileSelect: 'Async ✓', categories: true },
|
||||
{ id: 'utorrent', label: 'uTorrent', fileSelect: 'Async ✓', categories: true },
|
||||
{ id: 'synology-ds', label: 'Synology Download Station', fileSelect: '✗', categories: false },
|
||||
{
|
||||
id: 'qbittorrent', label: 'qBittorrent', version: '4.1+',
|
||||
auth: 'Login / MdP · Clé API (5.2+)', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'transmission', label: 'Transmission', version: '3.x / 4.x',
|
||||
auth: 'Login / MdP', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'rtorrent', label: 'rTorrent / ruTorrent', version: '0.9+',
|
||||
auth: 'URL xmlrpc', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'utorrent', label: 'uTorrent', version: '3.x',
|
||||
auth: 'Login / MdP', fileSelect: true, fileProgress: false, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'synology-ds', label: 'Synology Download Station', version: '6.x+',
|
||||
auth: 'Login / MdP', fileSelect: false, fileProgress: false, torrentUpload: 'url', categories: false, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
|
||||
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'alldebrid', label: 'AllDebrid', version: 'API v4',
|
||||
auth: 'Clé API', fileSelect: false, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
||||
},
|
||||
]
|
||||
|
||||
const modal = ref({
|
||||
@@ -181,10 +272,32 @@ const modal = ref({
|
||||
tested : false,
|
||||
})
|
||||
|
||||
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath']
|
||||
|
||||
const FIELD_TOOLTIPS: Record<string, string> = {
|
||||
savePath : 'Optionnel. Permet de placer les téléchargements dans un sous-dossier spécifique à l\'intérieur du dossier déjà configuré dans la gestion des médias.',
|
||||
remotePath : 'Si le client tourne sur une autre machine, indiquez ici le chemin qu\'il utilise (ex: /downloads). À associer avec le chemin local ci-dessous.',
|
||||
localPath : 'Chemin équivalent au chemin distant, mais vu par FanKarr sur sa machine (ex: /mnt/nas/downloads). Les deux champs fonctionnent en binôme pour faire la correspondance.',
|
||||
}
|
||||
|
||||
function fieldTooltip(key: string): string {
|
||||
return FIELD_TOOLTIPS[key] ?? ''
|
||||
}
|
||||
|
||||
const advancedOpen = ref(false)
|
||||
|
||||
const currentDefinition = computed(() =>
|
||||
availableClients.value.find(d => d.id === modal.value.type) ?? null
|
||||
)
|
||||
|
||||
const basicFields = computed(() =>
|
||||
currentDefinition.value?.fields.filter((f: any) => !ADVANCED_KEYS.includes(f.key)) ?? []
|
||||
)
|
||||
|
||||
const advancedFields = computed(() =>
|
||||
currentDefinition.value?.fields.filter((f: any) => ADVANCED_KEYS.includes(f.key)) ?? []
|
||||
)
|
||||
|
||||
function clientLabel(type: string) {
|
||||
return availableClients.value.find(d => d.id === type)?.label ?? type
|
||||
}
|
||||
@@ -229,6 +342,7 @@ async function deleteClient(uuid: string) {
|
||||
|
||||
function openAddModal() {
|
||||
modal.value = { open: true, uuid: null, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
|
||||
advancedOpen.value = false
|
||||
}
|
||||
|
||||
function editClient(client: any) {
|
||||
@@ -249,6 +363,7 @@ function closeModal() { modal.value.open = false }
|
||||
function onTypeChange() {
|
||||
modal.value.config = {}
|
||||
modal.value.tested = false
|
||||
advancedOpen.value = false
|
||||
const def = currentDefinition.value
|
||||
if (def) {
|
||||
for (const field of def.fields) {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<!-- ── Connexion Jellyfin ─────────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Serveur Jellyfin</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Configurez la connexion à votre serveur Jellyfin pour synchroniser les utilisateurs
|
||||
et permettre l'authentification depuis le plugin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">URL du serveur</label>
|
||||
<input v-model="jellyfinUrl" type="url" class="settings-input" placeholder="http://jellyfin:8096" />
|
||||
<p class="text-xs text-muted mt-1">URL accessible depuis FanKarr (réseau local ou Docker)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Token admin Jellyfin</label>
|
||||
<input v-model="jellyfinAdminToken" type="password" class="settings-input"
|
||||
:placeholder="hasToken ? '••••••••••••••••••••• (configuré)' : 'Tableau de bord → Clés API'" />
|
||||
<p class="text-xs text-muted mt-1">
|
||||
Dans Jellyfin : Tableau de bord → Clés API → Créer une clé
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button @click="save" :disabled="saving" class="btn-primary">
|
||||
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
|
||||
</button>
|
||||
<button @click="testConnection" :disabled="testing" class="btn-secondary">
|
||||
{{ testing ? 'Test…' : 'Tester la connexion' }}
|
||||
</button>
|
||||
<span v-if="testResult" class="text-xs" :class="testResult.ok ? 'text-green-400' : 'text-red-400'">
|
||||
{{ testResult.ok ? `Connecté — Jellyfin ${testResult.version}` : testResult.error }}
|
||||
</span>
|
||||
<span v-if="saved" class="text-xs text-green-400">Enregistré.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Synchronisation des utilisateurs ──────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Synchronisation des utilisateurs</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Importe les utilisateurs Jellyfin en tant que comptes FanKarr.
|
||||
Les comptes existants sont ignorés. Les utilisateurs pourront ensuite se connecter
|
||||
via le plugin Jellyfin sans configuration supplémentaire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-4">
|
||||
<div v-if="syncResult" class="flex flex-col gap-1">
|
||||
<p class="text-sm text-primary font-medium">
|
||||
{{ syncResult.created }} compte{{ syncResult.created > 1 ? 's' : '' }} créé{{ syncResult.created > 1 ? 's' : '' }}
|
||||
· {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}
|
||||
</p>
|
||||
<p v-if="syncResult.users.length > 0" class="text-xs text-muted">
|
||||
Nouveaux : {{ syncResult.users.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary w-fit disabled:opacity-50">
|
||||
{{ syncing ? 'Synchronisation…' : 'Synchroniser maintenant' }}
|
||||
</button>
|
||||
<p v-if="syncError" class="text-xs text-red-400">{{ syncError }}</p>
|
||||
<p v-if="!hasToken" class="text-xs text-muted">Configurez d'abord le token admin.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Référence API ──────────────────────────────────────── -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-base font-semibold text-primary">Référence API v1</h2>
|
||||
<div class="settings-card overflow-x-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="text-left px-3 py-2 text-muted font-medium">Méthode</th>
|
||||
<th class="text-left px-3 py-2 text-muted font-medium">Endpoint</th>
|
||||
<th class="text-left px-3 py-2 text-muted font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/50">
|
||||
<tr v-for="ep in apiEndpoints" :key="ep.path">
|
||||
<td class="px-3 py-2">
|
||||
<span class="font-mono font-semibold" :class="methodClass(ep.method)">{{ ep.method }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<code class="text-muted">{{ ep.path }}</code>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-muted">{{ ep.desc }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { copyToClipboard } from '@/composables/useClipboard'
|
||||
|
||||
const jellyfinUrl = ref('')
|
||||
const jellyfinAdminToken = ref('')
|
||||
const hasToken = ref(false)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResult = ref<{ ok: boolean; version?: string; error?: string } | null>(null)
|
||||
const syncing = ref(false)
|
||||
const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null)
|
||||
const syncError = ref<string | null>(null)
|
||||
|
||||
const origin = window.location.origin
|
||||
|
||||
const apiEndpoints = [
|
||||
{ method: 'POST', path: '/api/v1/auth/jellyfin', desc: 'Échanger un token Jellyfin contre un token FanKarr' },
|
||||
{ method: 'GET', path: '/api/v1/auth/me', desc: 'Infos de l\'utilisateur authentifié' },
|
||||
{ method: 'GET', path: '/api/v1/series/search?q=', desc: 'Rechercher dans le catalogue FanKai' },
|
||||
{ method: 'POST', path: '/api/v1/requests', desc: 'Créer ou mettre à jour une demande' },
|
||||
{ method: 'GET', path: '/api/v1/requests', desc: 'Récupérer ses propres demandes' },
|
||||
]
|
||||
|
||||
function methodClass(m: string) {
|
||||
return { GET: 'text-green-400', POST: 'text-blue-400', DELETE: 'text-red-400' }[m] ?? 'text-muted'
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const res = await fetch('/api/jellyfin/settings', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
jellyfinUrl.value = d.jellyfinUrl ?? ''
|
||||
hasToken.value = d.hasToken ?? false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadSettings() })
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
const body: Record<string, string> = { jellyfinUrl: jellyfinUrl.value }
|
||||
if (jellyfinAdminToken.value) body.jellyfinAdminToken = jellyfinAdminToken.value
|
||||
const res = await fetch('/api/jellyfin/settings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
hasToken.value = d.hasToken
|
||||
jellyfinAdminToken.value = '' // vider le champ après sauvegarde
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
}
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
const res = await fetch('/api/jellyfin/test', { method: 'POST', credentials: 'include' })
|
||||
testResult.value = await res.json()
|
||||
testing.value = false
|
||||
}
|
||||
|
||||
async function syncUsers() {
|
||||
syncing.value = true
|
||||
syncError.value = null
|
||||
syncResult.value = null
|
||||
const res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (res.ok) syncResult.value = data
|
||||
else syncError.value = data.error ?? 'Erreur lors de la synchronisation'
|
||||
syncing.value = false
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
try {
|
||||
await copyToClipboard(myToken.value)
|
||||
tokenCopied.value = true
|
||||
setTimeout(() => { tokenCopied.value = false }, 2000)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
@@ -87,7 +87,8 @@
|
||||
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-model="form.nfoSupport"
|
||||
:model-value="form.nfoSupport"
|
||||
@update:model-value="onNfoToggle"
|
||||
label="NFO / Métadonnées"
|
||||
description="Télécharge les NFO et images depuis GitLab lors de l'import (Kodi, Infuse, etc.)."
|
||||
/>
|
||||
@@ -146,6 +147,34 @@
|
||||
:media-path="form.mediaPath"
|
||||
@close="plexOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Confirmation NFO -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="nfoConfirmOpen"
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"
|
||||
@click.self="nfoConfirmOpen = false"
|
||||
>
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<h3 class="text-sm font-semibold text-primary">Activer les NFO / Métadonnées ?</h3>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex ou Emby</span>,
|
||||
les métadonnées sont déjà gérées directement par l'agent — activer les NFO est inutile
|
||||
et peut créer des conflits.
|
||||
</p>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Activez cette option uniquement si vous utilisez <span class="text-primary">Kodi, Infuse</span>
|
||||
ou un lecteur qui lit les fichiers NFO locaux.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button @click="confirmNfo" class="btn-primary text-xs">Oui, activer quand même</button>
|
||||
<button @click="nfoConfirmOpen = false" class="btn-secondary text-xs">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -157,12 +186,26 @@ import PlexWizard from '@/components/settings/PlexWizard.vue'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const saving = ref(false)
|
||||
const scanning = ref(false)
|
||||
const isDocker = ref(false)
|
||||
const loaded = ref(false)
|
||||
const plexOpen = ref(false)
|
||||
const scanResult = ref<{ found: number; added: number } | null>(null)
|
||||
const saving = ref(false)
|
||||
const scanning = ref(false)
|
||||
const isDocker = ref(false)
|
||||
const loaded = ref(false)
|
||||
const plexOpen = ref(false)
|
||||
const nfoConfirmOpen = ref(false)
|
||||
const scanResult = ref<{ found: number; added: number } | null>(null)
|
||||
|
||||
function onNfoToggle(val: boolean) {
|
||||
if (val && !form.value.nfoSupport) {
|
||||
nfoConfirmOpen.value = true
|
||||
} else {
|
||||
form.value.nfoSupport = val
|
||||
}
|
||||
}
|
||||
|
||||
function confirmNfo() {
|
||||
form.value.nfoSupport = true
|
||||
nfoConfirmOpen.value = false
|
||||
}
|
||||
|
||||
const form = ref({
|
||||
mediaPath : '',
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<!-- ── Informations du compte ────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Mon compte</h2>
|
||||
<p class="text-sm text-muted mt-1">Informations associées à votre compte FanKarr.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="settings-label mb-1">Nom d'utilisateur</label>
|
||||
<p class="text-sm text-primary font-mono bg-shell px-3 py-2 rounded-lg border border-border w-fit">
|
||||
{{ auth.username }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1">Rôle</label>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
:class="auth.isAdmin ? 'bg-violet-500/20 text-violet-300' : 'bg-blue-500/20 text-blue-300'">
|
||||
{{ auth.isAdmin ? 'Administrateur' : 'Utilisateur' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Changer de mot de passe ───────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Changer le mot de passe</h2>
|
||||
<p class="text-sm text-muted mt-1">Votre nouveau mot de passe doit faire au moins 6 caractères.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Mot de passe actuel</label>
|
||||
<input v-model="currentPassword" type="password" class="settings-input" placeholder="••••••••" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Nouveau mot de passe</label>
|
||||
<input v-model="newPassword" type="password" class="settings-input" placeholder="••••••••" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Confirmer le nouveau mot de passe</label>
|
||||
<input v-model="confirmPassword" type="password" class="settings-input" placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button @click="changePassword" :disabled="changingPwd" class="btn-primary">
|
||||
{{ changingPwd ? 'Modification…' : 'Modifier le mot de passe' }}
|
||||
</button>
|
||||
<span v-if="pwdSuccess" class="text-xs text-green-400">Mot de passe modifié.</span>
|
||||
<span v-if="pwdError" class="text-xs text-red-400">{{ pwdError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Token API personnel ──────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Token API personnel</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Utilisez ce token pour accéder à l'API FanKarr depuis des applications tierces
|
||||
(ex : plugin Jellyfin).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 text-xs bg-shell px-3 py-2 rounded-lg border border-border text-muted font-mono truncate">
|
||||
{{ showToken ? myToken : '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••' }}
|
||||
</code>
|
||||
<button @click="showToken = !showToken" class="btn-ghost text-xs shrink-0">
|
||||
{{ showToken ? 'Masquer' : 'Afficher' }}
|
||||
</button>
|
||||
<button @click="copyToken" class="btn-ghost text-xs shrink-0">
|
||||
{{ tokenCopied ? 'Copié !' : 'Copier' }}
|
||||
</button>
|
||||
</div>
|
||||
<button @click="regenerateToken" class="btn-ghost text-xs text-red-400 hover:text-red-300 w-fit">
|
||||
Regénérer le token
|
||||
</button>
|
||||
<p class="text-xs text-muted">
|
||||
Authentification via header :
|
||||
<code class="bg-shell px-1.5 py-0.5 rounded text-xs">Authorization: Bearer <token></code>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { copyToClipboard } from '@/composables/useClipboard'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
|
||||
// ── Mot de passe ──────────────────────────────────────────────
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const changingPwd = ref(false)
|
||||
const pwdSuccess = ref(false)
|
||||
const pwdError = ref<string | null>(null)
|
||||
|
||||
async function changePassword() {
|
||||
pwdError.value = null
|
||||
pwdSuccess.value = false
|
||||
|
||||
if (!currentPassword.value || !newPassword.value) {
|
||||
pwdError.value = 'Tous les champs sont requis.'; return
|
||||
}
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
pwdError.value = 'Les mots de passe ne correspondent pas.'; return
|
||||
}
|
||||
if (newPassword.value.length < 6) {
|
||||
pwdError.value = 'Le mot de passe doit faire au moins 6 caractères.'; return
|
||||
}
|
||||
|
||||
changingPwd.value = true
|
||||
const res = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ currentPassword: currentPassword.value, newPassword: newPassword.value }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
pwdSuccess.value = true
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
setTimeout(() => { pwdSuccess.value = false }, 3000)
|
||||
} else {
|
||||
pwdError.value = data.error ?? 'Erreur lors du changement de mot de passe'
|
||||
}
|
||||
changingPwd.value = false
|
||||
}
|
||||
|
||||
// ── Token API ─────────────────────────────────────────────────
|
||||
const myToken = ref('')
|
||||
const showToken = ref(false)
|
||||
const tokenCopied = ref(false)
|
||||
|
||||
async function loadMyToken() {
|
||||
const res = await fetch('/api/auth/me', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
myToken.value = d.apiToken ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadMyToken)
|
||||
|
||||
async function copyToken() {
|
||||
try {
|
||||
await copyToClipboard(myToken.value)
|
||||
tokenCopied.value = true
|
||||
setTimeout(() => { tokenCopied.value = false }, 2000)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function regenerateToken() {
|
||||
if (!confirm('Regénérer le token ? L\'ancien token sera immédiatement invalidé.')) return
|
||||
const res = await fetch('/api/auth/regenerate-token', {
|
||||
method: 'POST', credentials: 'include',
|
||||
})
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
myToken.value = d.apiToken
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -14,10 +14,13 @@ import { useRoute } from 'vue-router'
|
||||
const route = useRoute()
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
'settings-profile' : 'Mon profil',
|
||||
'settings-download' : 'Clients de téléchargement',
|
||||
'settings-media' : 'Gestion des médias',
|
||||
'settings-catalogue' : 'Catalogue Fankai',
|
||||
'settings-logs' : 'Journaux',
|
||||
'settings-users' : 'Utilisateurs & Invitations',
|
||||
'settings-jellyfin' : 'Jellyfin & API',
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => titles[route.name as string] ?? 'Paramètres')
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8">
|
||||
|
||||
<!-- ── Utilisateurs ─────────────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Utilisateurs</h2>
|
||||
<p class="text-sm text-muted mt-1">Gérez les comptes ayant accès à FanKarr.</p>
|
||||
</div>
|
||||
<button @click="openCreateUser" class="btn-secondary">+ Créer</button>
|
||||
</div>
|
||||
|
||||
<div v-if="users.length === 0" class="settings-card text-sm text-muted">
|
||||
Aucun utilisateur.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="u in users" :key="u.id"
|
||||
class="settings-card flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
|
||||
>
|
||||
{{ u.username[0].toUpperCase() }}
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">{{ u.username }}</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ u.role === 'admin' ? 'Administrateur' : 'Utilisateur' }}
|
||||
· créé le {{ formatDate(u.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="openEditUser(u)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
|
||||
<button
|
||||
@click="confirmDelete(u)"
|
||||
:disabled="u.id === currentUserId"
|
||||
class="btn-ghost text-xs text-red-400 hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Invitations ──────────────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Invitations</h2>
|
||||
<p class="text-sm text-muted mt-1">Générez des liens pour permettre à de nouveaux utilisateurs de s'inscrire.</p>
|
||||
</div>
|
||||
<button @click="openCreateInvite" class="btn-secondary">+ Générer</button>
|
||||
</div>
|
||||
|
||||
<div v-if="invites.length === 0" class="settings-card text-sm text-muted">
|
||||
Aucun lien d'invitation actif.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="inv in invites" :key="inv.code"
|
||||
class="settings-card flex items-center justify-between gap-4"
|
||||
:class="{ 'opacity-50': isExpired(inv) || isExhausted(inv) }"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span v-if="isExpired(inv) || isExhausted(inv)" class="text-xs text-muted">[Invalide]</span>
|
||||
<span v-if="inv.note" class="text-sm text-primary font-medium truncate">{{ inv.note }}</span>
|
||||
<span v-else class="text-sm text-muted italic">Sans note</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
{{ inv.uses }}/{{ inv.maxUses ?? '∞' }} utilisations
|
||||
<template v-if="inv.expiresAt"> · expire {{ formatDate(inv.expiresAt) }}</template>
|
||||
<template v-else> · sans expiration</template>
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
<code class="text-xs text-muted bg-shell px-2 py-0.5 rounded truncate max-w-xs">
|
||||
{{ inviteUrl(inv.code) }}
|
||||
</code>
|
||||
<button @click="copyInvite(inv.code)" class="btn-ghost text-xs shrink-0">
|
||||
{{ copied === inv.code ? 'Copié !' : 'Copier' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="deleteInvite(inv.code)" class="btn-ghost text-xs text-red-400 hover:text-red-300 shrink-0">
|
||||
Révoquer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Téléchargement automatique des demandes ──────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Téléchargement automatique</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Quand une demande est approuvée, lancer automatiquement le téléchargement pour les utilisateurs sélectionnés.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-4">
|
||||
<!-- Tous les utilisateurs -->
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="autoDownloadAll"
|
||||
@change="toggleAutoDownloadAll"
|
||||
class="w-4 h-4 rounded border-border accent-accent"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">Tous les utilisateurs</p>
|
||||
<p class="text-xs text-muted">Chaque approbation déclenche un téléchargement, quel que soit le demandeur.</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Séparateur -->
|
||||
<div v-if="!autoDownloadAll && users.length > 0" class="border-t border-border/50" />
|
||||
|
||||
<!-- Par utilisateur -->
|
||||
<template v-if="!autoDownloadAll">
|
||||
<label
|
||||
v-for="u in users" :key="u.id"
|
||||
class="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="autoDownloadUserIds.includes(u.id)"
|
||||
@change="toggleAutoDownloadUser(u.id)"
|
||||
class="w-4 h-4 rounded border-border accent-accent"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
|
||||
>{{ u.username[0].toUpperCase() }}</span>
|
||||
<span class="text-sm text-primary">{{ u.username }}</span>
|
||||
<span class="text-xs text-muted">{{ u.role === 'admin' ? 'Admin' : 'Utilisateur' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<p v-if="users.length === 0" class="text-xs text-muted">Aucun utilisateur.</p>
|
||||
</template>
|
||||
|
||||
<p v-if="autoDownloadSaved" class="text-xs text-green-400">Paramètre sauvegardé.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Modal créer/modifier utilisateur ─────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div v-if="userModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="userModal = null">
|
||||
<div class="settings-card w-full max-w-sm flex flex-col gap-5 p-6">
|
||||
<h3 class="text-base font-semibold text-primary">
|
||||
{{ userModal.mode === 'create' ? 'Créer un utilisateur' : 'Modifier l\'utilisateur' }}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Identifiant</label>
|
||||
<input v-model="userModal.username" type="text" class="settings-input" placeholder="username" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">
|
||||
{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}
|
||||
</label>
|
||||
<input v-model="userModal.password" type="password" class="settings-input"
|
||||
:placeholder="userModal.mode === 'edit' ? 'Laisser vide pour ne pas changer' : '••••••••'" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Rôle</label>
|
||||
<select v-model="userModal.role" class="settings-input">
|
||||
<option value="user">Utilisateur</option>
|
||||
<option value="admin">Administrateur</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="userModal.error" class="text-xs text-red-400">{{ userModal.error }}</p>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="userModal = null" class="btn-ghost">Annuler</button>
|
||||
<button @click="submitUserModal" :disabled="userModal.loading" class="btn-primary">
|
||||
{{ userModal.loading ? '...' : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Modal créer invitation ────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div v-if="inviteModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="inviteModal = null">
|
||||
<div class="settings-card w-full max-w-sm flex flex-col gap-5 p-6">
|
||||
<h3 class="text-base font-semibold text-primary">Générer une invitation</h3>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Note (optionnelle)</label>
|
||||
<input v-model="inviteModal.note" type="text" class="settings-input" placeholder="Pour qui est ce lien ?" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Utilisations max</label>
|
||||
<input v-model.number="inviteModal.maxUses" type="number" min="1" class="settings-input" placeholder="1" />
|
||||
<p class="text-xs text-muted mt-1">Laisser vide pour illimité</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Expire dans (heures)</label>
|
||||
<input v-model.number="inviteModal.expiresInHours" type="number" min="1" class="settings-input" placeholder="24" />
|
||||
<p class="text-xs text-muted mt-1">Laisser vide pour pas d'expiration</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="inviteModal.error" class="text-xs text-red-400">{{ inviteModal.error }}</p>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="inviteModal = null" class="btn-ghost">Annuler</button>
|
||||
<button @click="submitInviteModal" :disabled="inviteModal.loading" class="btn-primary">
|
||||
{{ inviteModal.loading ? '...' : 'Générer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { copyToClipboard } from '@/composables/useClipboard'
|
||||
|
||||
interface User {
|
||||
id : string
|
||||
username : string
|
||||
role : 'admin' | 'user'
|
||||
apiToken : string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Invite {
|
||||
code : string
|
||||
createdBy : string
|
||||
createdAt : string
|
||||
expiresAt : string | null
|
||||
maxUses : number | null
|
||||
uses : number
|
||||
note? : string
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const currentUserId = auth.username // utilisé pour désactiver la suppression de soi-même
|
||||
|
||||
const users = ref<User[]>([])
|
||||
const invites = ref<Invite[]>([])
|
||||
const copied = ref<string | null>(null)
|
||||
|
||||
// ── Téléchargement auto des demandes ──────────────────────────
|
||||
const autoDownloadAll = ref(false)
|
||||
const autoDownloadUserIds = ref<string[]>([])
|
||||
const autoDownloadSaved = ref(false)
|
||||
let autoDownloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── Modals ────────────────────────────────────────────────────
|
||||
const userModal = ref<{
|
||||
mode : 'create' | 'edit'
|
||||
id? : string
|
||||
username: string
|
||||
password: string
|
||||
role : 'admin' | 'user'
|
||||
error : string | null
|
||||
loading : boolean
|
||||
} | null>(null)
|
||||
|
||||
const inviteModal = ref<{
|
||||
note? : string
|
||||
maxUses? : number
|
||||
expiresInHours?: number
|
||||
error : string | null
|
||||
loading : boolean
|
||||
} | null>(null)
|
||||
|
||||
// ── Chargement ────────────────────────────────────────────────
|
||||
async function loadUsers() {
|
||||
const res = await fetch('/api/users', { credentials: 'include' })
|
||||
if (res.ok) users.value = await res.json()
|
||||
}
|
||||
|
||||
async function loadInvites() {
|
||||
const res = await fetch('/api/invites', { credentials: 'include' })
|
||||
if (res.ok) invites.value = await res.json()
|
||||
}
|
||||
|
||||
async function loadAutoDownloadSetting() {
|
||||
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const s = await res.json()
|
||||
const val = s.requestAutoDownloadUsers ?? []
|
||||
if (val === 'all') {
|
||||
autoDownloadAll.value = true
|
||||
autoDownloadUserIds.value = []
|
||||
} else {
|
||||
autoDownloadAll.value = false
|
||||
autoDownloadUserIds.value = Array.isArray(val) ? val : []
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAutoDownloadSetting() {
|
||||
const value = autoDownloadAll.value ? 'all' : autoDownloadUserIds.value
|
||||
await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ requestAutoDownloadUsers: value }),
|
||||
})
|
||||
autoDownloadSaved.value = true
|
||||
if (autoDownloadTimer) clearTimeout(autoDownloadTimer)
|
||||
autoDownloadTimer = setTimeout(() => { autoDownloadSaved.value = false }, 2000)
|
||||
}
|
||||
|
||||
function toggleAutoDownloadAll(e: Event) {
|
||||
autoDownloadAll.value = (e.target as HTMLInputElement).checked
|
||||
if (autoDownloadAll.value) autoDownloadUserIds.value = []
|
||||
saveAutoDownloadSetting()
|
||||
}
|
||||
|
||||
function toggleAutoDownloadUser(userId: string) {
|
||||
const idx = autoDownloadUserIds.value.indexOf(userId)
|
||||
if (idx >= 0) autoDownloadUserIds.value.splice(idx, 1)
|
||||
else autoDownloadUserIds.value.push(userId)
|
||||
saveAutoDownloadSetting()
|
||||
}
|
||||
|
||||
onMounted(() => { loadUsers(); loadInvites(); loadAutoDownloadSetting() })
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────
|
||||
function openCreateUser() {
|
||||
userModal.value = { mode: 'create', username: '', password: '', role: 'user', error: null, loading: false }
|
||||
}
|
||||
|
||||
function openEditUser(u: User) {
|
||||
userModal.value = { mode: 'edit', id: u.id, username: u.username, password: '', role: u.role, error: null, loading: false }
|
||||
}
|
||||
|
||||
async function submitUserModal() {
|
||||
if (!userModal.value) return
|
||||
const m = userModal.value
|
||||
m.error = null
|
||||
m.loading = true
|
||||
|
||||
if (!m.username) { m.error = 'Identifiant requis'; m.loading = false; return }
|
||||
if (m.mode === 'create' && !m.password) { m.error = 'Mot de passe requis'; m.loading = false; return }
|
||||
|
||||
const url = m.mode === 'create' ? '/api/users' : `/api/users/${m.id}`
|
||||
const method = m.mode === 'create' ? 'POST' : 'PATCH'
|
||||
const body: Record<string, any> = { username: m.username, role: m.role }
|
||||
if (m.password) body.password = m.password
|
||||
|
||||
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body) })
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) { m.error = data.error; m.loading = false; return }
|
||||
userModal.value = null
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
async function confirmDelete(u: User) {
|
||||
if (!confirm(`Supprimer l'utilisateur "${u.username}" ?`)) return
|
||||
await fetch(`/api/users/${u.id}`, { method: 'DELETE', credentials: 'include' })
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
// ── Invites ───────────────────────────────────────────────────
|
||||
function openCreateInvite() {
|
||||
inviteModal.value = { note: '', maxUses: 1, expiresInHours: 24, error: null, loading: false }
|
||||
}
|
||||
|
||||
async function submitInviteModal() {
|
||||
if (!inviteModal.value) return
|
||||
const m = inviteModal.value
|
||||
m.error = null; m.loading = true
|
||||
|
||||
const body: Record<string, any> = {}
|
||||
if (m.note) body.note = m.note
|
||||
if (m.maxUses) body.maxUses = m.maxUses
|
||||
if (m.expiresInHours) body.expiresInHours = m.expiresInHours
|
||||
|
||||
const res = await fetch('/api/invites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body) })
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) { m.error = data.error; m.loading = false; return }
|
||||
inviteModal.value = null
|
||||
loadInvites()
|
||||
}
|
||||
|
||||
async function deleteInvite(code: string) {
|
||||
if (!confirm('Révoquer ce lien d\'invitation ?')) return
|
||||
await fetch(`/api/invites/${code}`, { method: 'DELETE', credentials: 'include' })
|
||||
loadInvites()
|
||||
}
|
||||
|
||||
function inviteUrl(code: string) {
|
||||
return `${window.location.origin}/invite/${code}`
|
||||
}
|
||||
|
||||
async function copyInvite(code: string) {
|
||||
try {
|
||||
await copyToClipboard(inviteUrl(code))
|
||||
copied.value = code
|
||||
setTimeout(() => { copied.value = null }, 2000)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function isExpired(inv: Invite) {
|
||||
return inv.expiresAt !== null && new Date(inv.expiresAt) < new Date()
|
||||
}
|
||||
|
||||
function isExhausted(inv: Invite) {
|
||||
return inv.maxUses !== null && inv.uses >= inv.maxUses
|
||||
}
|
||||
</script>
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.11.4
|
||||
3.19.0
|
||||
Reference in new issue
Block a user