Compare commits

...
90 Commits
Author SHA1 Message Date
Quentin Robert 1e6506aee6 Merge pull request #123 from Masutayunikon/release-please--branches--main
chore(main): release 3.18.0
2026-05-26 01:20:38 +02:00
Quentin Robert 2235541000 chore(main): release 3.18.0 2026-05-26 01:20:01 +02:00
MasutaYunikon 6f5b9e2ee4 Merge remote-tracking branch 'origin/main' 2026-05-26 01:19:32 +02:00
MasutaYunikon 045213d363 fix(client): alldebrid
feat(client): ajout deluge
2026-05-26 01:19:21 +02:00
github-actions[bot] 1f0c9a338a chore: update version.txt to v3.17.1 2026-05-24 13:25:00 +00:00
Quentin Robert 59b40cfd59 Merge pull request #122 from Masutayunikon/release-please--branches--main
chore(main): release 3.17.1
2026-05-24 15:24:40 +02:00
Quentin Robert fc4598a810 chore(main): release 3.17.1 2026-05-24 15:22:23 +02:00
MasutaYunikon 7b1863180f fix(api): la recherche ne retourne plus les series sans torrents
fix(client): alldebrid utilise maintenant les routes 4.1
2026-05-24 15:21:54 +02:00
MasutaYunikon edec048e30 fix(readme): update 2026-05-23 20:04:15 +02:00
MasutaYunikon 4690874be1 Merge remote-tracking branch 'origin/main' 2026-05-23 20:00:11 +02:00
MasutaYunikon bf9b18ec00 fix(readme): update 2026-05-23 20:00:00 +02:00
github-actions[bot] d08d6e6032 chore: update version.txt to v3.17.0 2026-05-23 17:49:19 +00:00
Quentin Robert 8737e515b7 Merge pull request #121 from Masutayunikon/release-please--branches--main
chore(main): release 3.17.0
2026-05-23 19:48:58 +02:00
Quentin Robert 4667cd47fe chore(main): release 3.17.0 2026-05-23 19:40:41 +02:00
MasutaYunikon 161f4f5051 fix(supression): supprime maintenant le dossier de la serie et l'ajoute a organized.json 2026-05-23 19:40:04 +02:00
MasutaYunikon 858a5b34bf feat(demandes): ajout d'un bouton supprimé pour les admin et supprime les demandes "disponible" si la serie a été supprimé apres 2026-05-23 19:29:47 +02:00
MasutaYunikon 4a6510adde feat(search): ajout des requetes deja demandé si existante sur la serie 2026-05-23 19:20:39 +02:00
MasutaYunikon 41addf9e4f fix: update request state after submit + override theme hover on modal buttons 2026-05-23 19:18:05 +02:00
MasutaYunikon b7c7e9d33d feat(search): ajout des requetes deja demandé si existante sur la serie 2026-05-23 19:05:51 +02:00
MasutaYunikon bd5b16e17c fix: button contrast and font size on card overlay 2026-05-23 19:01:03 +02:00
MasutaYunikon ec1d1705e7 feat(search): ajout des requetes deja demandé si existante sur la serie 2026-05-23 18:58:55 +02:00
MasutaYunikon 16b3f741e7 feat(search): ajout description 2026-05-23 15:14:01 +02:00
MasutaYunikon 99c8d86c6e feat(search): retourne maintenant l'année et le rating 2026-05-23 14:49:47 +02:00
MasutaYunikon 1176523d61 feat(demande): ajout des routes pour recuperer les saisons et episodes depuis le plugin jellyfin 2026-05-23 14:09:06 +02:00
MasutaYunikon 5478fa6d80 fix: search retourne une image mtn 2026-05-23 03:57:52 +02:00
MasutaYunikon 0bac6da968 fix: ajout cors type 2026-05-23 03:39:36 +02:00
MasutaYunikon 911ca86a0d fix: cors, alldebrid 2026-05-23 03:37:30 +02:00
MasutaYunikon e6b0bea455 fix(demandes): titres torrent 2026-05-22 22:55:44 +02:00
MasutaYunikon 85a218042d fix(demandes): multiples torrent et fix episode qui ne s'ajoute pas 2026-05-22 22:52:25 +02:00
MasutaYunikon 921f6eb8d2 fix: ajout du parametre auto download 2026-05-22 22:38:06 +02:00
MasutaYunikon 0ac48126aa feat: ajout du parametre auto download 2026-05-22 22:34:17 +02:00
MasutaYunikon 0544a515b6 fix: copy 2026-05-22 22:29:16 +02:00
MasutaYunikon 423ba6a777 feat: passer les demandes en disponible une fois l'import fait 2026-05-22 21:10:23 +02:00
MasutaYunikon 7f4121d9e4 fix: telechargement depuis les demandes de saison et integral 2026-05-22 21:06:55 +02:00
MasutaYunikon 62c5b2e622 fix: demandes d'épisode qui demande toutes la serie 2026-05-22 20:59:37 +02:00
MasutaYunikon 73678be2c8 fix: demandes d'épisode qui demande toutes la serie 2026-05-22 20:56:50 +02:00
MasutaYunikon 473d97e392 feat(demandes): telechargement auto lors de l'aprobation
feat(demandes): ajout des demandes par épisodes
2026-05-22 20:43:30 +02:00
MasutaYunikon f3d535d577 fix(serie): 403 pour les non admin
fix(settings): la clé api n'apparait que dans un menu pour les admin

feat(client): ajout de alldebrid dans le tableau de comparatif

fix(settings): bug ou les parametre se sauvegarder en rentrant sur lla page avancé
2026-05-22 20:31:15 +02:00
MasutaYunikon 5d06a23198 fix: argument type 2026-05-22 20:14:32 +02:00
MasutaYunikon dabd1d429e feat(auth): migration multi-utilisateurs, rôles admin/user et token API par compte
feat(users): gestion des comptes et système d'invitations avec page d'administration

feat(requests): demandes de séries avec fusion des saisons, vue admin et widget dashboard

feat(jellyfin): synchronisation des comptes, SSO token exchange et API publique v1
2026-05-22 20:12:05 +02:00
MasutaYunikon dfb515b0f8 Merge remote-tracking branch 'origin/main' 2026-05-22 19:14:21 +02:00
MasutaYunikon 6ce59b9e04 feat(client): ajour de alldebrid client
fix(log): changement pour une notification plus explicit lors du test client

fix(import): supprime les entrees organiser quand on supprime un fichier

fix(start): url cliquable au demarrage dans le terminal
2026-05-22 19:10:52 +02:00
github-actions[bot] 76778930b5 chore: update version.txt to v3.16.0 2026-05-11 17:27:55 +00:00
Quentin Robert 92433f33ca Merge pull request #119 from Masutayunikon/release-please--branches--main
chore(main): release 3.16.0
2026-05-11 19:27:24 +02:00
Quentin Robert b8037db423 chore(main): release 3.16.0 2026-05-11 19:13:00 +02:00
MasutaYunikon c9040ea68c fix(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 2026-05-11 19:12:10 +02:00
MasutaYunikon 8972679e25 fix(import): les episodes avec une association changé n'apparait plus sur la page serie 2026-05-11 19:03:26 +02:00
MasutaYunikon ad829c0a99 fix(import): on ne peux plus importer plusieurs fichiers sur le meme episodes, celui deja importé ce retire de l'importation dans le modal
fix(import): si on change un fichier son association alors il est bien retirer de l'import
2026-05-11 18:48:08 +02:00
MasutaYunikon a5e2aa0da1 fix(UI/UX): retour sur le modal d'import et refresh
fix(rss): le rss est maintenant sur les ajout apres l'activation de la surveillance
2026-05-11 18:26:31 +02:00
MasutaYunikon 086ca3053f fix(client): fix de plusieurs torrent et refonte du tableau de compatibilité
feat(rss): ajout du rss sur les series
2026-05-10 22:53:58 +02:00
MasutaYunikon f07b608288 fix(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 2026-05-10 20:04:46 +02:00
MasutaYunikon 42c979536b fix: utilisation de URLSearchParams pour les appels autre que pour le add du fichier .torrent 2026-05-10 19:57:35 +02:00
MasutaYunikon 4800167979 Merge remote-tracking branch 'origin/main' 2026-05-10 19:20:12 +02:00
MasutaYunikon cb28baf25d feat(UI/UX): affichage de la serie et des episodes sur la page download du torrent
fix: les torrents n'utilise plus la pause
feat(log): + de logs sur les erreurs d'import (chemin tenté et fichier du torrent chercher)
fix(docker): ajout du fichier de versions pour la navbar dans docker lors du build
fix(import): les imports se font maintenant sur les series sans torrents qui n'avais pas de fichier de catalogue depuis le scraper
fix(UI/UX): retour visuel lors de l'import d'une serie/episode
2026-05-10 19:20:01 +02:00
github-actions[bot] 4bd15f89d6 chore: update version.txt to v3.15.0 2026-05-10 00:59:16 +00:00
Quentin Robert 16bca596b6 Merge pull request #118 from Masutayunikon/release-please--branches--main
chore(main): release 3.15.0
2026-05-10 02:58:56 +02:00
Quentin Robert ebc902c9e7 chore(main): release 3.15.0 2026-05-10 02:57:38 +02:00
MasutaYunikon f81969cdd0 Merge remote-tracking branch 'origin/main' 2026-05-10 02:57:13 +02:00
MasutaYunikon 950a2abc49 feat: ajout build docker pour arm32 2026-05-10 02:56:48 +02:00
github-actions[bot] 04a900e120 chore: update version.txt to v3.14.6 2026-05-08 00:15:05 +00:00
Quentin Robert 1c11eb1e89 Merge pull request #117 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.6
2026-05-08 02:14:38 +02:00
Quentin Robert 4dd9ba2ff4 chore(main): release 3.14.6 2026-05-08 02:14:15 +02:00
MasutaYunikon 27a23d76dd Merge remote-tracking branch 'origin/main' 2026-05-08 02:13:44 +02:00
MasutaYunikon fcb176d519 fix: pnpm build 2026-05-08 02:13:32 +02:00
github-actions[bot] 359be7fd9f chore: update version.txt to v3.14.5 2026-05-08 00:11:32 +00:00
Quentin Robert ece35824d3 Merge pull request #116 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.5
2026-05-08 02:10:59 +02:00
Quentin Robert fe807bded4 chore(main): release 3.14.5 2026-05-08 02:07:29 +02:00
MasutaYunikon 7cdfe13151 Merge remote-tracking branch 'origin/main' 2026-05-08 02:07:04 +02:00
MasutaYunikon d04ca79e00 fix: pnpm build 2026-05-08 02:06:48 +02:00
github-actions[bot] a37b527895 chore: update version.txt to v3.14.4 2026-05-08 00:05:03 +00:00
Quentin Robert 1e1a04a465 Merge pull request #115 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.4
2026-05-08 02:04:34 +02:00
Quentin Robert c6555ae47d chore(main): release 3.14.4 2026-05-08 02:03:41 +02:00
MasutaYunikon a76328cc16 Merge remote-tracking branch 'origin/main' 2026-05-08 02:03:18 +02:00
MasutaYunikon ef527c2072 fix: lock file for build 2026-05-08 02:03:09 +02:00
github-actions[bot] 45079dce25 chore: update version.txt to v3.14.3 2026-05-08 00:01:05 +00:00
Quentin Robert 5466028ae2 Merge pull request #114 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.3
2026-05-08 02:00:44 +02:00
Quentin Robert ea265cfd9e chore(main): release 3.14.3 2026-05-08 02:00:12 +02:00
MasutaYunikon 0e3e5fe912 Merge remote-tracking branch 'origin/main' 2026-05-08 01:59:48 +02:00
MasutaYunikon 6d8ea36260 fix: build binaries node version 2026-05-08 01:59:26 +02:00
github-actions[bot] ea1fb49a37 chore: update version.txt to v3.14.2 2026-05-07 23:55:36 +00:00
Quentin Robert 244fa4ae43 Merge pull request #113 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.2
2026-05-08 01:55:06 +02:00
Quentin Robert 5cfb449c3c chore(main): release 3.14.2 2026-05-08 01:54:21 +02:00
MasutaYunikon 3c9b9f5344 Merge remote-tracking branch 'origin/main' 2026-05-08 01:53:57 +02:00
MasutaYunikon 9ebaca92f1 fix: build binaries node version 2026-05-08 01:53:46 +02:00
github-actions[bot] 494266953c chore: update version.txt to v3.14.1 2026-05-07 23:46:45 +00:00
Quentin Robert 583097cd16 Merge pull request #112 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.1
2026-05-08 01:46:20 +02:00
Quentin Robert cb1d1759e3 chore(main): release 3.14.1 2026-05-08 01:45:59 +02:00
MasutaYunikon 563ffb2bd3 Merge remote-tracking branch 'origin/main' 2026-05-08 01:45:26 +02:00
MasutaYunikon 25ab373539 fix: eslint build 2026-05-08 01:45:16 +02:00
github-actions[bot] ed0acdda22 chore: update version.txt to v3.14.0 2026-05-07 17:24:23 +00:00
51 changed files with 4732 additions and 394 deletions

No files matched your search

+4 -4
View File
@@ -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
+4 -2
View File
@@ -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 }}
View File
Whitespace-only changes.
+144
View File
@@ -1,5 +1,149 @@
# Changelog
## [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)
+3 -1
View File
@@ -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
+136 -2
View File
@@ -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
+2
View File
@@ -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",
+28
View File
@@ -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
View File
@@ -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)
}
+118 -9
View File
@@ -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'
@@ -12,13 +13,23 @@ import synologyDsDriver from './torrent-clients/synology-ds.js'
import utorrentDriver from './torrent-clients/utorrent.js'
import rtorrentDriver from './torrent-clients/rtorrent.js'
import realDebridDriver from './torrent-clients/real-debrid.js'
import allDebridDriver from './torrent-clients/alldebrid.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'
@@ -28,6 +39,8 @@ 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)
@@ -35,6 +48,8 @@ registerDriver(synologyDsDriver)
registerDriver(utorrentDriver)
registerDriver(rtorrentDriver)
registerDriver(realDebridDriver)
registerDriver(allDebridDriver)
registerDriver(delugeDriver)
const app = express()
const PORT = Number(process.env.PORT) || 9898
@@ -42,27 +57,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)) {
@@ -73,7 +127,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`)
@@ -98,6 +154,28 @@ 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(async seriesData => {
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
@@ -117,6 +195,21 @@ 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 {
@@ -161,4 +254,20 @@ server.listen(PORT, async () => {
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
})
+91
View File
@@ -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)
}
+91
View File
@@ -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' }
}
}
+241
View File
@@ -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 }
}
+13 -13
View File
@@ -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>>
@@ -353,7 +354,7 @@ async function organizeTorrent(
// Vérification de complétion pour épisode unique
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
debug(`Fichier incomplet (skip) : ${filename}`)
warn(`Skip (incomplet / priorité 0) : ${filename}`)
return { ...result, total: 1, skipped: 1 }
}
@@ -371,8 +372,8 @@ async function organizeTorrent(
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)) {
@@ -386,7 +387,7 @@ async function organizeTorrent(
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 unlinkWithRetry(src)
log(`${destName} → Saison ${season.season_number}`)
@@ -397,7 +398,7 @@ async function organizeTorrent(
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 }
@@ -418,9 +419,9 @@ async function organizeTorrent(
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: 1% téléchargé avant priorité appliquée).
// on skip les fichiers pas encore à 100% (ex: priorité 0 ou en cours de téléchargement).
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
debug(`Fichier incomplet (skip) : ${filename}`)
warn(`Skip (incomplet / priorité 0) : ${filename}`)
result.skipped++
continue
}
@@ -436,8 +437,7 @@ async function organizeTorrent(
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
}
@@ -449,7 +449,7 @@ async function organizeTorrent(
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
@@ -464,14 +464,14 @@ async function organizeTorrent(
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 unlinkWithRetry(src)
}
@@ -542,7 +542,7 @@ parentPort?.on('message', async (msg: any) => {
}
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
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}`)
}
+3 -1
View File
@@ -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>>
@@ -234,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) {
@@ -309,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,
+179
View File
@@ -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
}
+10 -2
View File
@@ -49,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) {
+59 -1
View File
@@ -10,6 +10,7 @@ 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()
@@ -32,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()
@@ -66,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}"`)
@@ -226,10 +262,15 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => {
let removed = 0
const errors: string[] = []
const emptyHashes: string[] = []
let serieFolder: string | null = null
for (const [hash, episodes] of Object.entries(organized)) {
for (const epId of Object.keys(episodes)) {
if (!episodeIds.has(epId)) continue
const entry = episodes[epId]
if (!serieFolder && entry) {
const dir = entry.dest_dir ?? path.dirname(entry.dest_path)
serieFolder = path.dirname(dir)
}
if (deleteFile && entry?.dest_path && fs.existsSync(entry.dest_path)) {
try { fs.unlinkSync(entry.dest_path) } catch { errors.push(entry.dest_path) }
}
@@ -244,7 +285,24 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => {
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 })
})
+75
View File
@@ -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
+77
View File
@@ -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
+209
View File
@@ -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
+231
View File
@@ -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
+48
View File
@@ -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
+11 -10
View File
@@ -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)
+66
View File
@@ -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
View File
@@ -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 {
+197
View File
@@ -0,0 +1,197 @@
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
const AD_BASE = 'https://api.alldebrid.com/v4.1'
const AD_BASE_V4 = 'https://api.alldebrid.com/v4'
const AD_AGENT = 'FanKarr'
function adUrl(config: Record<string, string | number>, path: string, base = AD_BASE): string {
const sep = path.includes('?') ? '&' : '?'
return `${base}${path}${sep}agent=${AD_AGENT}&apikey=${config.apiKey}`
}
async function adFetch(
config: Record<string, string | number>,
method: 'GET' | 'POST' | 'DELETE',
path : string,
body ?: URLSearchParams | FormData,
base = AD_BASE,
): Promise<any> {
const res = await fetch(adUrl(config, path, base), { method, body })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`AllDebrid HTTP ${res.status} : ${text}`)
}
const json = await res.json()
if (json.status === 'error') {
throw new Error(`AllDebrid : ${json.error?.message ?? json.error?.code ?? 'Erreur inconnue'}`)
}
return json.data
}
// statusCode : 0=Processing, 1=Downloading, 2=Compressing, 3=Uploading, 4=Ready,
// 5=Upload Fail, 6=Internal Error, 7=Not Downloaded, 8=File Not Available,
// 9=Banned BitTorrent, 10=Awaiting Slot, 11=Free Trial Limit Reached
// hasLocalMount : true si un chemin local (FUSE/WebDAV) est configuré.
// Sans montage local, statusCode 4 ("prêt sur les serveurs AD") ne doit pas
// déclencher l'import FanKarr — les fichiers ne sont pas encore sur le disque.
function mapStatus(statusCode: number, hasLocalMount: boolean): TorrentInfo['state'] {
switch (statusCode) {
case 4: return hasLocalMount ? 'seeding' : 'downloading'
case 1:
case 0:
case 2:
case 3:
case 10: return 'downloading'
case 5:
case 6:
case 7:
case 8:
case 9:
case 11: return 'error'
default: return 'unknown'
}
}
function flattenFiles(nodes: any[], prefix = '', startIdx = 0): TorrentFileProgress[] {
const result: TorrentFileProgress[] = []
let idx = startIdx
for (const node of nodes) {
if (node.e) {
const children = flattenFiles(node.e, prefix ? `${prefix}/${node.n}` : node.n, idx)
result.push(...children)
idx += children.length
} else if (node.n) {
result.push({ index: idx++, name: prefix ? `${prefix}/${node.n}` : node.n, progress: 0, priority: 1 })
}
}
return result
}
const adDriver: TorrentClientDriver = {
definition: {
id : 'alldebrid',
label : 'AllDebrid',
fields: [
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
{ key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/alldebrid', required: false },
],
},
async test(config) {
try {
const data = await adFetch(config, 'GET', '/user')
const username = data.user?.username ?? data.user?.login ?? '?'
logger.info('alldebrid', `Connexion réussie — utilisateur : ${username}`)
return { ok: true, message: `Connecté en tant que ${username}` }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('alldebrid', `Test de connexion échoué : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const data = await adFetch(config, 'GET', '/user')
const username = data.user?.username ?? data.user?.login ?? '?'
return { online: true, version: `AD — ${username}` }
} catch {
return { online: false }
}
},
async add(config, url, options?: DownloadOptions) {
if (options?.file_index != null)
logger.warn('alldebrid', `Sélection de fichier (index ${options.file_index}) non supportée par AllDebrid — pack complet téléchargé`)
const isMagnet = url.startsWith('magnet:')
if (isMagnet) {
const body = new URLSearchParams()
body.append('magnets[]', url)
const data = await adFetch(config, 'POST', '/magnet/upload', body)
const id = data.magnets?.[0]?.id
logger.info('alldebrid', `Magnet ajouté → ID ${id}`)
} else {
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('files[]', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
const data = await adFetch(config, 'POST', '/magnet/upload/file', formData)
const id = data.magnets?.[0]?.id
logger.info('alldebrid', `.torrent uploadé → ID ${id}`)
}
},
async list(config, _category?) {
const data = await adFetch(config, 'GET', '/magnet/status')
const magnets: any[] = data.magnets ?? []
const localBase = String(config.localPath ?? '').replace(/\/+$/, '')
return magnets.map((m: any): TorrentInfo => {
const statusCode = typeof m.statusCode === 'number' ? m.statusCode : 0
const size = typeof m.size === 'number' ? m.size : 0
const downloaded = typeof m.downloaded === 'number' ? m.downloaded : 0
const progress = size > 0
? Math.round((downloaded / size) * 100)
: (statusCode === 4 ? 100 : 0)
// save_path = dossier PARENT (comme qBittorrent), sans le nom du torrent.
// Le worker construit lui-même le chemin complet via getFiles().
const save_path = localBase
const eta = (typeof m.completionDate === 'number' && m.completionDate > 0)
? Math.max(0, m.completionDate - Math.floor(Date.now() / 1000))
: -1
return {
hash : String(m.hash ?? m.id ?? '').toLowerCase(),
name : String(m.filename ?? m.id ?? ''),
state : mapStatus(statusCode, Boolean(localBase)),
progress,
size,
downloaded,
uploaded : typeof m.uploaded === 'number' ? m.uploaded : 0,
ratio : 0,
speed : typeof m.downloadSpeed === 'number' ? m.downloadSpeed : 0,
upspeed : typeof m.uploadSpeed === 'number' ? m.uploadSpeed : 0,
eta,
save_path,
category : '',
}
})
},
async remove(config, hash, _deleteFiles?) {
const data = await adFetch(config, 'GET', '/magnet/status')
const magnets: any[] = data.magnets ?? []
const target = magnets.find((m: any) => String(m.hash ?? m.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur AllDebrid`)
await adFetch(config, 'DELETE', `/magnet/delete?id=${target.id}`)
logger.info('alldebrid', `Torrent ${hash.slice(0, 8)}… supprimé de AllDebrid`)
},
async getFiles(config, hash) {
const data = await adFetch(config, 'GET', '/magnet/status')
const magnets: any[] = data.magnets ?? []
const target = magnets.find((m: any) => String(m.hash ?? m.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) return []
const statusCode = typeof target.statusCode === 'number' ? target.statusCode : 0
const size = typeof target.size === 'number' ? target.size : 0
const downloaded = typeof target.downloaded === 'number' ? target.downloaded : 0
const overallProgress = size > 0 ? downloaded / size : (statusCode === 4 ? 1 : 0)
const body = new URLSearchParams()
body.append('id[]', String(target.id))
const filesData = await adFetch(config, 'POST', '/magnet/files', body, AD_BASE_V4)
const magnetEntry = (filesData.magnets ?? [])[0]
if (!Array.isArray(magnetEntry?.files)) return []
return flattenFiles(magnetEntry.files).map(f => ({ ...f, progress: overallProgress }))
},
}
export default adDriver
+319
View File
@@ -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
+46 -57
View File
@@ -6,6 +6,17 @@
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'
@@ -37,6 +48,7 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
const text = await res.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') ?? '').split(';')[0].trim()
if (!cookie) throw new Error('Cookie de session introuvable')
@@ -58,13 +70,12 @@ async function qbTorrentExists(config: Record<string, string | number>, authHead
/**
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
* applique les priorités, puis reprend le torrent.
* 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> {
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
for (let attempt = 0; attempt < 60; attempt++) {
@@ -118,36 +129,13 @@ async function qbApplyFilePriority(
}
}
// Reprendre (torrent mis en pause via paused/stopped à l'ajout)
// On appelle les deux endpoints : resume (qBit ≤ 4.x) et start (qBit ≥ 5.0)
if (resume) {
const resumeBody = new URLSearchParams({ hashes: hash })
await Promise.allSettled([
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeBody, headers }),
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body: resumeBody, headers }),
])
}
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné${resume ? ' + reprise' : ''} pour ${hash.slice(0, 8)}…`)
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch (err) {
if (err instanceof Error && err.message.includes('hors limites')) throw err
}
}
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
if (resume) {
try {
const authH = await qbAuth(config)
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
const body = new URLSearchParams({ hashes: hash })
await Promise.allSettled([
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body, headers }),
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body, headers }),
])
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
}
@@ -189,6 +177,7 @@ const QB: TorrentClientDriver = {
})
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) {
@@ -275,7 +264,7 @@ const QB: TorrentClientDriver = {
const exists = await qbTorrentExists(config, authH, hash)
if (exists) {
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
qbApplyFilePriority(config, hash, options.file_index, false).catch(err =>
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
@@ -292,38 +281,39 @@ const QB: TorrentClientDriver = {
}
// Stratégie d'ajout selon le type de lien :
// .torrent URL → FanKarr télécharge le fichier et l'uploade directement à qBit
// 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)
// magnet link → passage de l'URL brute, ajout normal sans pause
// (les métadonnées arrivent via les pairs, la pause bloquerait tout)
const isMagnet = url.startsWith('magnet:')
const form = new FormData()
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
let res: Response
if (isMagnet) {
form.append('urls', url)
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')
// Pause : métadonnées déjà dans le fichier → liste de fichiers disponible immédiatement
form.append('paused', 'true') // qBit < 5.0
form.append('stopped', 'true') // qBit ≥ 5.0
res = await fetch(`${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH })
}
const 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}`)
if (hash) {
logger.info('qbittorrent', `Torrent ajouté (${isMagnet ? 'magnet, actif' : 'en pause'}) — sélection fichier ${options.file_index} en attente des métadonnées`)
qbApplyFilePriority(config, hash, options.file_index, true).catch(err =>
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 {
@@ -341,7 +331,7 @@ const QB: TorrentClientDriver = {
if (!newT) continue
const resolvedHash = String(newT.hash).toLowerCase()
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`)
await qbApplyFilePriority(config, resolvedHash, fileIndex, true)
await qbApplyFilePriority(config, resolvedHash, fileIndex)
return
} catch {}
}
@@ -351,29 +341,28 @@ const QB: TorrentClientDriver = {
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: authH,
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 authH = await qbAuth(config)
const form = new FormData()
form.append('hashes', hash)
form.append('deleteFiles', deleteFiles ? 'true' : 'false')
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: authH,
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)' : ''}`)
+2 -2
View File
@@ -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=', 'f.path='])
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 {}
}))
@@ -279,7 +279,7 @@ const RT: TorrentClientDriver = {
index : i,
name : String(f[2] ?? ''),
progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0,
priority: 1,
priority: Number(f[3] ?? 1) === 0 ? 0 : 1,
}))
: undefined,
} satisfies TorrentInfo
+1 -1
View File
@@ -166,7 +166,7 @@ const DS: TorrentClientDriver = {
version: '1',
method : 'delete',
id : found.id,
force_complete: deleteFiles ? 'false' : 'false',
force_complete: deleteFiles ? 'true' : 'false',
_sid : sid,
})
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
+152
View File
@@ -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
}
+54 -14
View File
@@ -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>
+47 -3
View File
@@ -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' },
+166 -83
View File
@@ -31,18 +31,81 @@
<!-- Boutons saison -->
<div class="flex items-center gap-2">
<!-- Corbeille saison -->
<!-- Corbeille saison (admin uniquement) -->
<button
v-if="season.organized_count > 0"
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>
<!-- 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>
<!-- ── 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"
@@ -50,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
@@ -190,9 +201,23 @@
</div>
</div>
<!-- Bouton téléchargement épisode (uniquement si non organisé) -->
<!-- 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'"
@@ -200,12 +225,30 @@
>
<component :is="epStateIcon(ep)" />
</button>
<!-- Dropdown quand plusieurs options de torrent -->
<div v-if="epOptionsOpen === ep.id && 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)"
@@ -217,8 +260,8 @@
</div>
</div>
<!-- Actions épisode importé -->
<template v-if="ep.organized && organizedByEpisode[String(ep.id)]">
<!-- Actions épisode importé (admin uniquement) -->
<template v-if="!requestMode && ep.organized && organizedByEpisode[String(ep.id)]">
<!-- Badge rename cliquable -->
<button
v-if="epNeedsRename(ep)"
@@ -310,7 +353,7 @@
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
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
@@ -322,6 +365,9 @@ 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<{
@@ -331,8 +377,24 @@ const emit = defineEmits<{
renameEpisode : [ep: any, season: any]
unimportEpisode: [ep: any, season: any, deleteFile: boolean]
unimportSeason : [season: any, deleteFiles: boolean]
// Mode demande
requestSeason : [seasonNumber: number]
requestEpisode : [seasonNumber: number, episodeId: number, torrent?: any]
}>()
const 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)
@@ -441,7 +503,7 @@ function epProgress(ep: any): ActiveTorrent | null {
// 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 > 0 && file.progress < 1))) {
if (file != null && file.priority !== 0 && (sessionDl || file.progress < 1)) {
return { ...active, progress: Math.round(file.progress * 100) }
}
if (!sessionDl) continue
@@ -470,6 +532,17 @@ function handleEpBtnClick(ep: any) {
}
}
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'
@@ -602,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 {
+15
View File
@@ -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)
}
+55 -30
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
})
+79 -11
View File
@@ -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()
+118
View File
@@ -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>
+285
View File
@@ -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>
+238 -5
View File
@@ -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">
@@ -50,6 +50,24 @@
<!-- 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
@@ -85,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
@@ -99,14 +137,56 @@
: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, 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 -->
@@ -140,7 +220,7 @@
:organized="organizedByEpisode"
:initial-path="mediaPath"
@close="manualImportOpen = false"
@imported="load(); fetchOrganized(); manualImportOpen = false"
@imported="load(); fetchOrganized()"
/>
</div>
@@ -150,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'
@@ -158,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[]>([])
@@ -173,6 +376,7 @@ 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[]>([])
@@ -268,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' })
@@ -514,8 +742,13 @@ async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
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(() => {
+10 -6
View File
@@ -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(() => {
+3
View File
@@ -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' },
+70 -23
View File
@@ -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 -->
@@ -207,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({
+191
View File
@@ -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>
+177
View File
@@ -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 &lt;token&gt;</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>
+3
View File
@@ -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')
+429
View File
@@ -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
View File
@@ -1 +1 @@
3.14.0
3.18.0