Compare commits

...
240 Commits
Author SHA1 Message Date
Quentin Robert 9b22b1f377 Merge pull request #124 from Masutayunikon/release-please--branches--main
chore(main): release 3.18.1
2026-05-27 17:00:13 +02:00
Quentin Robert d1357e7a79 chore(main): release 3.18.1 2026-05-27 16:59:45 +02:00
MasutaYunikon e15e7beb1b Merge remote-tracking branch 'origin/main' 2026-05-27 16:59:01 +02:00
MasutaYunikon 8b4b198697 fix(client): suppresion de alldebrid
- Supprime le driver AllDebrid et son enregistrement
- Synology : extrait le vrai hash BT depuis additional.detail.hash
- Synology : ajoute les statuts finishing/extracting → seeding
- Synology : fix remove() pour matcher par hash BT ou ID Synology
- Synology : add() passe en POST form-encoded
2026-05-27 16:57:28 +02:00
github-actions[bot] 13700c554a chore: update version.txt to v3.18.0 2026-05-25 23:21:02 +00:00
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
Quentin Robert a84337c928 Merge pull request #111 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.0
2026-05-07 19:23:50 +02:00
Quentin Robert cface2944e chore(main): release 3.14.0 2026-05-07 15:30:06 +02:00
MasutaYunikon 1e92934f53 fix: renaming dans le managements des series 2026-05-07 15:29:29 +02:00
MasutaYunikon bf3f30356e fix: renaming dans le managements des series 2026-05-07 15:21:27 +02:00
MasutaYunikon bfabf9b793 fix: rtorrent priority default to 1 2026-05-07 15:08:30 +02:00
MasutaYunikon 1f50f9ae72 fix: on ne vois plus le telechargement de fichier qui ont une priorité basse (ne pas télécharger) 2026-05-07 15:07:17 +02:00
MasutaYunikon 7da5a8d4e8 fix: cookie 2026-05-07 14:57:30 +02:00
MasutaYunikon 2b3fd79a92 fix: ajout de log pour le cookie 2026-05-07 14:55:08 +02:00
MasutaYunikon aa8b500f57 feat(qbittorrent): support cookie name (≥5.2.0) 2026-05-07 14:51:55 +02:00
MasutaYunikon 702847a5c2 feat(qbittorrent): support API key authentication (≥5.2.0) 2026-05-07 13:50:18 +02:00
MasutaYunikon 5cc1aa29fe Merge remote-tracking branch 'origin/main' 2026-05-07 01:19:00 +02:00
MasutaYunikon 105cc05bdf fix: le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode 2026-05-07 01:18:50 +02:00
github-actions[bot] 50b8b2992b chore: update version.txt to v3.13.2 2026-05-06 22:43:17 +00:00
Quentin Robert f4c9713815 Merge pull request #110 from Masutayunikon/release-please--branches--main
chore(main): release 3.13.2
2026-05-07 00:42:54 +02:00
Quentin Robert 7154dc87d2 chore(main): release 3.13.2 2026-05-07 00:42:35 +02:00
MasutaYunikon 2c9bef8883 Merge remote-tracking branch 'origin/main' 2026-05-07 00:41:59 +02:00
MasutaYunikon 0d5d44a484 fix: le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode 2026-05-07 00:41:50 +02:00
github-actions[bot] d5953a948c chore: update version.txt to v3.13.1 2026-05-06 21:37:40 +00:00
Quentin Robert 132198a3a9 Merge pull request #109 from Masutayunikon/release-please--branches--main
chore(main): release 3.13.1
2026-05-06 23:37:13 +02:00
Quentin Robert add5ec5a3e chore(main): release 3.13.1 2026-05-06 23:35:45 +02:00
MasutaYunikon 260fd10bf0 Merge branch 'main' of github.com:Masutayunikon/FanKarr 2026-05-06 23:34:53 +02:00
MasutaYunikon 95d2b270dc fix: ajout du champ wiki dans l'api 2026-05-06 23:33:35 +02:00
github-actions[bot] 1aa6c57e58 chore: update version.txt to v3.13.0 2026-05-06 21:29:35 +00:00
Quentin Robert bde377dd37 Merge pull request #108 from Masutayunikon/release-please--branches--main
chore(main): release 3.13.0
2026-05-06 23:29:06 +02:00
Quentin Robert 834dc12b05 chore(main): release 3.13.0 2026-05-06 23:13:38 +02:00
MasutaYunikon 35655d7784 fix: progression 2026-05-06 23:13:03 +02:00
MasutaYunikon 434da54ede fix(qbittorrent): FanKarr télécharge le .torrent et l'uploade directement à qBit
qBittorrent (Docker/runtipi) n'a pas forcément accès à nyaa.si depuis son réseau.
FanKarr télécharge lui-même le fichier .torrent et l'envoie en upload direct à qBit,
qui n'a donc plus besoin d'accès internet pour les métadonnées.
2026-05-06 23:00:12 +02:00
MasutaYunikon d299b76bd3 fix: ajout de log 2026-05-06 22:57:10 +02:00
MasutaYunikon 9f90b6ab6c fix(qbittorrent): pause uniquement pour les .torrent, pas pour les magnets
Les fichiers .torrent contiennent les métadonnées → pause/stopped OK, fichiers disponibles immédiatement.
Les magnets récupèrent les métadonnées via les pairs → sans pause pour permettre la connexion.
2026-05-06 22:47:26 +02:00
MasutaYunikon 2a77a38655 feat(torrent-clients): ajout du driver Real-Debrid
feat(organize): vérification de complétion par fichier avant d'importer
- Récupération de la progression par fichier (index + name + progress 0–1) dans qBittorrent, Transmission et rTorrent
- Ajout de getFiles() sur le driver qBittorrent et interface TorrentClientDriver
- Le worker filtre les fichiers incomplets (< 100%) avant move/copy/hardlink

fix(organize): retry EBUSY sur unlink — 5 tentatives × 500 ms (Windows)

fix(organize): suppression des entrées orphelines lors de la migration d'IDs
- Quand l'ancien ID est orphelin et le nouvel ID déjà présent, l'orphelin est supprimé au lieu d'être conservé

fix(qbittorrent): suppression de paused/stopped à l'ajout du torrent
- Le torrent est ajouté normalement pour permettre la récupération immédiate des métadonnées
- Premier poll de qbApplyFilePriority réduit à 100 ms

feat(serie): badges épisodes et chapitres extraits du synopsis
- Regex sur ep.plot pour extraire les plages "Ép. 628–634" et "Ch. 700–706"
- La phrase de couverture est retirée du synopsis affiché
- Badges violet (épisodes) et teal (chapitres) à côté des badges MULTI/VOSTFR

feat(serie): lien wiki dans le header de la série si serie.wiki est défini

fix(serie): header mobile — items-start + aspect-[2/3] sur le poster
2026-05-06 22:20:22 +02:00
MasutaYunikon 39463bfbcb fix: utilisation de formated_name depuis les paths
feat: ajout de la mise a jour des id's
2026-05-06 16:41:53 +02:00
MasutaYunikon 83437def32 feat: ajout de la prise en charges des codex 2026-05-06 00:22:07 +02:00
github-actions[bot] 7f6bbd6323 chore: update version.txt to v3.12.1 2026-04-29 23:18:28 +00:00
Quentin Robert 82a82b1e84 Merge pull request #106 from Masutayunikon/release-please--branches--main
chore(main): release 3.12.1
2026-04-30 01:18:03 +02:00
Quentin Robert 85bce309e9 chore(main): release 3.12.1 2026-04-30 00:48:31 +02:00
MasutaYunikon 8b98b0edf9 fix(UI/UX): ajout d'un modal de confirmation lors de l'activation des nfo 2026-04-30 00:47:56 +02:00
github-actions[bot] 8c765bbf39 chore: update version.txt to v3.12.0 2026-04-29 19:00:35 +00:00
Quentin Robert 0fd10576fe Merge pull request #105 from Masutayunikon/release-please--branches--main
chore(main): release 3.12.0
2026-04-29 21:00:05 +02:00
Quentin Robert 744e0336c5 chore(main): release 3.12.0 2026-04-29 20:59:26 +02:00
MasutaYunikon 040a8d7b33 fix(UI/UX): meilleur comprehension des parametres avancés des clients 2026-04-29 20:58:26 +02:00
MasutaYunikon 39d2f996ae fix: spinner infinie apres supression 2026-04-29 20:15:01 +02:00
MasutaYunikon 97c573cc1d fix: spinner infinie apres supression 2026-04-29 19:12:23 +02:00
MasutaYunikon f6ef818e60 fix(UI/UX): chevron pour ouvrir la description, bouton desimporter de la serie passer à supprimer et affichage de toute la description de la serie lors d'un clique 2026-04-29 18:59:01 +02:00
MasutaYunikon f5094c68c6 fix: qbitorrent header 2026-04-29 18:34:51 +02:00
MasutaYunikon 9834023522 fix: qbitorrent 2026-04-29 18:29:18 +02:00
MasutaYunikon c267562aec chore: retrigger release-please after tag fix 2026-04-29 18:07:46 +02:00
MasutaYunikon a68b9cff95 chore: remove .claude folder and add to .gitignore 2026-04-29 18:04:38 +02:00
MasutaYunikon a604b0d0cc chore: retrigger release-please 2026-04-29 18:03:02 +02:00
MasutaYunikon 24466228ef feat: désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission
- SerieSeasonCard: remplace le menu burger épisode par corbeille directe, badge Rename cliquable,
  bouton synopsis (ℹ) avec affichage inline, bouton téléchargement masqué si épisode importé
- SerieSeasonCard: modal désimport épisode (avec checkbox suppression fichier) via Teleport
- SerieSeasonCard: bouton corbeille saison + modal désimport saison (avec checkbox suppression fichier)
- SerieView: bouton Désimporter série → modal (remplace dropdown), gère l'emit unimportSeason depuis card
- import.ts: nouvel endpoint DELETE /organized/:serieId/seasons/:seasonId
- qbittorrent: fallback hash pour .torrent sans magnet (snapshot → poll → diff → priorité fichier)
- transmission: fix sélection fichier (trApplyFilePriority async, fileStats→files, files-wanted via torrent-set)
2026-04-28 22:51:03 +02:00
MasutaYunikon fcb1b88870 feat: désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission
- SerieSeasonCard: remplace le menu burger épisode par corbeille directe, badge Rename cliquable,
  bouton synopsis (ℹ) avec affichage inline, bouton téléchargement masqué si épisode importé
- SerieSeasonCard: modal désimport épisode (avec checkbox suppression fichier) via Teleport
- SerieSeasonCard: bouton corbeille saison + modal désimport saison (avec checkbox suppression fichier)
- SerieView: bouton Désimporter série → modal (remplace dropdown), gère l'emit unimportSeason depuis card
- import.ts: nouvel endpoint DELETE /organized/:serieId/seasons/:seasonId
- qbittorrent: fallback hash pour .torrent sans magnet (snapshot → poll → diff → priorité fichier)
- transmission: fix sélection fichier (trApplyFilePriority async, fileStats→files, files-wanted via torrent-set)
2026-04-28 22:46:13 +02:00
MasutaYunikon 3fb4119c79 feat: désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission
- SerieSeasonCard: remplace le menu burger épisode par corbeille directe, badge Rename cliquable,
  bouton synopsis (ℹ) avec affichage inline, bouton téléchargement masqué si épisode importé
- SerieSeasonCard: modal désimport épisode (avec checkbox suppression fichier) via Teleport
- SerieSeasonCard: bouton corbeille saison + modal désimport saison (avec checkbox suppression fichier)
- SerieView: bouton Désimporter série → modal (remplace dropdown), gère l'emit unimportSeason depuis card
- import.ts: nouvel endpoint DELETE /organized/:serieId/seasons/:seasonId
- qbittorrent: fallback hash pour .torrent sans magnet (snapshot → poll → diff → priorité fichier)
- transmission: fix sélection fichier (trApplyFilePriority async, fileStats→files, files-wanted via torrent-set)
2026-04-28 20:03:47 +02:00
github-actions[bot] eb4cb30efd chore: update version.txt to v3.11.5 2026-04-26 21:50:59 +00:00
MasutaYunikon 53c226eba8 Merge pull request #102 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.5
2026-04-26 23:50:39 +02:00
MasutaYunikon 3b74d93b74 chore(main): release 3.11.5 2026-04-26 23:50:03 +02:00
MasutaYunikon ff54ab4ef6 Merge remote-tracking branch 'origin/main' 2026-04-26 23:49:38 +02:00
MasutaYunikon a7c90b63df fix: le folder picker affiche tous les disques depuis le bouton homes sous windows 2026-04-26 23:49:28 +02:00
github-actions[bot] 872962c435 chore: update version.txt to v3.11.4 2026-04-26 21:11:56 +00:00
MasutaYunikon b9c4627a77 Merge pull request #101 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.4
2026-04-26 23:11:37 +02:00
MasutaYunikon eff7a288cf chore(main): release 3.11.4 2026-04-26 23:08:13 +02:00
MasutaYunikon 94d419cf01 Merge remote-tracking branch 'origin/main' 2026-04-26 23:07:49 +02:00
MasutaYunikon 4760d576f3 fix: ajout de la mise a jour des noms toutes les 1h 2026-04-26 23:07:39 +02:00
github-actions[bot] 800c9f3f02 chore: update version.txt to v3.11.3 2026-04-26 18:29:57 +00:00
MasutaYunikon 6c7def3184 Merge pull request #100 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.3
2026-04-26 20:29:34 +02:00
MasutaYunikon 962279db29 chore(main): release 3.11.3 2026-04-26 20:27:48 +02:00
MasutaYunikon bffe286f40 fix: dockerfile node version to 22 2026-04-26 20:27:27 +02:00
github-actions[bot] abc96dd648 chore: update version.txt to v3.11.2 2026-04-26 14:35:36 +00:00
MasutaYunikon 6142dbdb25 Merge pull request #99 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.2
2026-04-26 16:35:15 +02:00
MasutaYunikon 01040ada26 chore(main): release 3.11.2 2026-04-26 16:34:33 +02:00
MasutaYunikon ee2d83993a Merge remote-tracking branch 'origin/main' 2026-04-26 16:34:09 +02:00
MasutaYunikon 0103960645 fix: ajout de undici dans le package.json 2026-04-26 16:34:00 +02:00
github-actions[bot] b8e30e13f2 chore: update version.txt to v3.11.1 2026-04-26 14:16:42 +00:00
MasutaYunikon b8554af71b Merge pull request #98 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.1
2026-04-26 16:16:22 +02:00
MasutaYunikon e130e945d5 chore(main): release 3.11.1 2026-04-26 15:57:11 +02:00
MasutaYunikon 7c4496ae1d Merge remote-tracking branch 'origin/main' 2026-04-26 15:56:44 +02:00
MasutaYunikon cfd0914b9b fix: le setup plex peux maintenant etre utilisé sur un serveur local (probleme de certificat) 2026-04-26 15:56:35 +02:00
github-actions[bot] 53a6f0abfc chore: update version.txt to v3.11.0 2026-04-23 20:06:51 +00:00
MasutaYunikon 0c2c9ca456 Merge pull request #95 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.0
2026-04-23 22:06:23 +02:00
MasutaYunikon 74a6d0a895 chore(main): release 3.11.0 2026-04-23 22:05:02 +02:00
MasutaYunikon 01e02502c0 Merge remote-tracking branch 'origin/main' 2026-04-23 22:04:28 +02:00
MasutaYunikon e1e68ef2bf fix: renommage d'episodes qui ne fonctionne pas en 1 par 1
feat: ajout de log pour le bouton tout renommer

feat: ajout du support multifile sur utorrent

feat: ajout d'un bouton pour supprimer les nfo / png
2026-04-23 22:04:19 +02:00
github-actions[bot] ea3bf622e9 chore: update version.txt to v3.10.0 2026-04-22 13:36:02 +00:00
MasutaYunikon da92b54fc9 Merge pull request #91 from Masutayunikon/release-please--branches--main
chore(main): release 3.10.0
2026-04-22 15:35:37 +02:00
MasutaYunikon 664812ec56 chore(main): release 3.10.0 2026-04-22 15:33:57 +02:00
MasutaYunikon 86df3054dd fix: fallback de noms si les noms de torrents se ressemble il fallback sur les noms dans nyaa 2026-04-22 15:33:11 +02:00
MasutaYunikon 43de4f8071 feat: update readme 2026-04-22 15:30:33 +02:00
MasutaYunikon e685fade30 fix: coherence des badges 2026-04-22 14:04:53 +02:00
MasutaYunikon 77cc97f0f0 fix: utilisation de svg pour les drapeau 2026-04-22 14:02:03 +02:00
MasutaYunikon 39738c0c96 fix: changer les badge de language pour un truc plus explicite 2026-04-22 14:00:05 +02:00
MasutaYunikon 16bb46fb55 feat: ajout du langage 2026-04-22 13:56:50 +02:00
MasutaYunikon 12511eb39f fix: dropdown overflow 2026-04-22 13:42:12 +02:00
MasutaYunikon c594af7f43 fix: dropdown overflow 2026-04-22 13:20:06 +02:00
MasutaYunikon 10a9b75685 fix: utilisation des noms de dossier du torrent pour l'affichage plutot que le nom sur nyaa (notament pour gto kai qui a plusieurs integral et qui est plus explicite qque le nom nyaa) 2026-04-22 13:13:15 +02:00
MasutaYunikon 806121f95c fix: bouton tous telecharger a la place de integral, multi torrents sur le bouton saisons et tous telecharger 2026-04-22 11:07:21 +02:00
MasutaYunikon 9ee4face73 fix: les fichiers non selectionner sur un torrent ne sont plus en "attente d'import" sur l'affichage de la serie 2026-04-21 23:51:58 +02:00
MasutaYunikon f1c40865da fix: les fichiers non selectionner sur un torrent ne produise plus d'erreurs d'import 2026-04-21 23:49:38 +02:00
MasutaYunikon 245e9e38aa fix: affichage de la bar de progression et confirmation sur le bouton pour les episodes en doublons et dropdown 2026-04-21 23:24:27 +02:00
MasutaYunikon a423c08559 fix: affichage de la bar de progression et confirmation sur le bouton pour les episodes en doublons 2026-04-21 23:20:05 +02:00
MasutaYunikon 497ad7bdc8 fix: le dropdown apparait sur le bouton de telechargement et pas les 3 petit points pour les multiples 2026-04-21 23:13:23 +02:00
MasutaYunikon f897116e30 feat: ajout d'une variable d'environment pour augmenter la durée du token ou le desactiver avec "never" ou 0
feat: ajout du .env dans les dossiers du binaires pour pouvoir changer leur valeurs

fix: utilisation du fichier torrent avec le magnet pour les bug de metadonnées

fix: prise en charge de multiple torrent sur les telechargement d'episodes
2026-04-21 23:08:41 +02:00
MasutaYunikon 79b6a8bbb4 fix: ne retire pas les fichiers quand on en ajoute sur le torrent 2026-04-21 15:58:01 +02:00
MasutaYunikon 7e53bab0fc feat: utilisation des bar de telechargement par fichier dans le torrent plutot que celui du torrent 2026-04-21 15:53:39 +02:00
MasutaYunikon 4955a730b7 fix: qbit utilisation de /start au lieu de /resume pour la v5 2026-04-21 15:33:32 +02:00
MasutaYunikon b3ff17ab11 fix: les torrents sont stopé lors de la selection de fichier pour eviter qu'il commence le telechargement des autres fichiers avant de set les indexs 2026-04-21 15:30:00 +02:00
MasutaYunikon f19cc027ee fix: les torrents sont stopé lors de la selection de fichier pour eviter qu'il commence le telechargement des autres fichiers avant de set les indexs 2026-04-21 15:22:06 +02:00
MasutaYunikon ed66c2c0a2 fix: bouton global de serie 2026-04-21 10:58:29 +02:00
MasutaYunikon e6ca1d2a36 fix: n'affiche plus tous les épisodes en train de se telecharger si seulement un fichier est selectionner 2026-04-21 10:42:02 +02:00
MasutaYunikon c9534c2011 fix: n'affiche plus tous les épisodes en train de se telecharger si seulement un fichier est selectionner 2026-04-21 10:37:40 +02:00
MasutaYunikon 1b2036b57b fix: ajout des boutons pour les episodes sans torrent 2026-04-21 10:27:48 +02:00
MasutaYunikon 247694ab3e feat: ajout des telechargments par episodes / saisons 2026-04-21 10:17:53 +02:00
MasutaYunikon 215379eca4 Merge remote-tracking branch 'origin/main' 2026-04-20 22:30:15 +02:00
MasutaYunikon 7661b65df4 fix: improve container detection to support Podman and other runtimes
Replace single /.dockerenv check with a robust isInContainer() helper
that covers Docker, Podman (/.containerenv), cgroup-based detection
(containerd, LXC) and /proc/1/environ as a general fallback.
2026-04-20 22:30:07 +02:00
github-actions[bot] 996064413f chore: update version.txt to v3.9.2 2026-04-16 19:18:54 +00:00
MasutaYunikon 1c43385484 Merge pull request #88 from Masutayunikon/release-please--branches--main
chore(main): release 3.9.2
2026-04-16 21:18:27 +02:00
MasutaYunikon d2d21258d4 chore(main): release 3.9.2 2026-04-16 18:50:18 +02:00
MasutaYunikon 3847ba1c58 Merge remote-tracking branch 'origin/main' 2026-04-16 18:49:51 +02:00
MasutaYunikon 3a65a4bb39 fix: le bouton "tous télécharger" ne s'affiche plus si la series de contient que des integrales comme torrent 2026-04-16 18:49:21 +02:00
github-actions[bot] 6ab9b4ad1f chore: update version.txt to v3.9.1 2026-04-16 07:39:08 +00:00
MasutaYunikon f5127637ca Merge pull request #87 from Masutayunikon/release-please--branches--main
chore(main): release 3.9.1
2026-04-16 09:38:44 +02:00
MasutaYunikon f995fd14a6 chore(main): release 3.9.1 2026-04-16 09:22:08 +02:00
MasutaYunikon 9296d64571 Merge remote-tracking branch 'origin/main' 2026-04-16 09:21:35 +02:00
MasutaYunikon f00556ab51 fix: le badge de rename utilise la meme logique que coté serveur et n'apparait plus sur les vue series si il ne devrais pas 2026-04-16 09:21:26 +02:00
github-actions[bot] 1c88e13a97 chore: update version.txt to v3.9.0 2026-04-15 07:21:09 +00:00
MasutaYunikon 5fed1d523d Merge pull request #86 from Masutayunikon/release-please--branches--main
chore(main): release 3.9.0
2026-04-15 09:20:42 +02:00
MasutaYunikon c963847c31 chore(main): release 3.9.0 2026-04-15 09:17:33 +02:00
MasutaYunikon bb21addebb fix: move plex settings en haut des boutons 2026-04-15 09:17:00 +02:00
MasutaYunikon bf2274b761 refactor: refactor sur server et les pages qui commencer à faire beaucoup trop de ligne 2026-04-15 09:11:33 +02:00
MasutaYunikon 609ddc777f fix: plex catcher 2026-04-14 20:57:57 +02:00
MasutaYunikon 353bd009ed fix: Deux corrections :
oauth/start — X-Plex-Client-Identifier ajouté dans le body du POST (Plex l'exige dans le body pour la création du pin, pas juste dans les headers). forwardUrl pointe maintenant vers https://plex.tv au lieu d'être vide — sans ça Plex ne valide pas le pin côté serveur.
     oauth/poll — X-Plex-Client-Identifier=fankarr passé en query param sur l'URL du GET. Plex exige que le même clientID que celui utilisé lors de la création soit présent pour récupérer le token.
2026-04-14 20:55:53 +02:00
MasutaYunikon 2070c1113d feat: ajout setup plex 2026-04-14 20:48:21 +02:00
MasutaYunikon 203df7576c fix: ajout d'un loader sur certaines pages pour pas avoir des trucs par default qui s'affiche 2026-04-14 20:30:18 +02:00
MasutaYunikon 4e8487967c fix: le bouton tout telecharger apparait sur les packs de saisons et les integrals + autres et pas que sur les series avec des episodes telechargable 2026-04-14 20:23:27 +02:00
MasutaYunikon 1b1dbd3ec8 fix: utorrent prend desormais le dossier cible correctement 2026-04-14 20:16:13 +02:00
MasutaYunikon 7f0e363cfe fix: wrong file copy 2026-04-14 19:06:48 +02:00
MasutaYunikon d6f4433541 feat: ajout du bouton suppresion de torrent en cours
feat: ajout d'un bouton supprimé l'importation dans le menu d'importation d'une serie

feat: ajout de badge quand le fichier est introuvable

feat: suppression des series supprimé

feat: bouton tous télécharger sur les saisons et series
2026-04-14 19:05:11 +02:00
MasutaYunikon 5e79699ed9 fix: suppression de dest_filename du fichier d'organisation qui est faux et ne sert à rien 2026-04-14 18:37:54 +02:00
MasutaYunikon e41edd2f5e fix: ajout de la page management dans le layout 2026-04-14 17:40:27 +02:00
MasutaYunikon b881fdd557 fix: cast string 2026-04-14 17:36:43 +02:00
MasutaYunikon e648a81245 Merge remote-tracking branch 'origin/main' 2026-04-14 17:29:10 +02:00
MasutaYunikon 699c130edd feat: ajout de la possibilité de renommer des imports dans le bon format 2026-04-14 17:29:01 +02:00
github-actions[bot] acf802e7e1 chore: update version.txt to v3.8.1 2026-04-11 11:35:22 +00:00
MasutaYunikon f40e8c37e7 Merge pull request #85 from Masutayunikon/release-please--branches--main
chore(main): release 3.8.1
2026-04-11 13:35:04 +02:00
MasutaYunikon 2d2043d7ae chore(main): release 3.8.1 2026-04-11 13:34:39 +02:00
MasutaYunikon 788878ca6c Merge remote-tracking branch 'origin/main' 2026-04-11 13:34:12 +02:00
MasutaYunikon f96565a301 fix: la version dev se met uniquement en dev 2026-04-11 13:34:04 +02:00
github-actions[bot] 462ec87e89 chore: update version.txt to v3.8.0 2026-04-11 11:27:30 +00:00
85 changed files with 10397 additions and 2348 deletions

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
# FanKarr — Configuration (binaire bun)
# Copier ce fichier en .env dans le même répertoire que le binaire fankarr
# ── Authentification ──────────────────────────────────────────────────────────
# Clé secrète JWT (générée automatiquement si absente, mais recommandé de la fixer)
# JWT_SECRET=changez-moi-avec-une-chaine-aleatoire-longue
# Durée de validité du token de connexion
# Exemples : "1d", "7d", "30d", "1y", "never" ou "0" pour pas d'expiration
# AUTH_TOKEN_EXPIRY=30d
# ── Serveur ───────────────────────────────────────────────────────────────────
# Port d'écoute (défaut : 9898)
# PORT=9898
# ── Données ───────────────────────────────────────────────────────────────────
# Répertoire de données (défaut : ./config/ à côté du binaire)
# Utile pour pointer vers un volume Docker ou un répertoire partagé
# DATA_DIR=/opt/fankarr/data
+64
View File
@@ -0,0 +1,64 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 300" width="1200" height="300">
<defs>
<radialGradient id="iconGlow" cx="477" cy="140" r="100" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#e8a030" stop-opacity="0.22"/>
<stop offset="100%" stop-color="#e8a030" stop-opacity="0"/>
</radialGradient>
<radialGradient id="bgVignette" cx="50%" cy="50%" r="60%">
<stop offset="0%" stop-color="#111827" stop-opacity="0"/>
<stop offset="100%" stop-color="#070a10" stop-opacity="0.6"/>
</radialGradient>
</defs>
<!-- Background -->
<rect width="1200" height="300" fill="#0b0e17" rx="16"/>
<rect width="1200" height="300" fill="url(#bgVignette)" rx="16"/>
<rect width="1200" height="300" fill="none" stroke="#1c2636" stroke-width="1" rx="16"/>
<!-- Glow behind kanji box -->
<circle cx="477" cy="140" r="100" fill="url(#iconGlow)"/>
<!-- Kanji icon box -->
<rect x="432" y="95" width="90" height="90" rx="14"
fill="#0f1520"
stroke="#e8a030" stroke-width="1.2" stroke-opacity="0.5"/>
<!-- Kanji 改 (fankai) -->
<text
x="477" y="140"
font-family="'Yu Mincho', 'MS Mincho', 'Hiragino Mincho Pro', 'Noto Serif CJK JP', 'Source Han Serif', serif"
font-size="54"
font-weight="700"
fill="#e8a030"
text-anchor="middle"
dominant-baseline="central"
>改</text>
<!-- FanKarr -->
<text
x="542" y="140"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Helvetica, Arial, sans-serif"
font-size="68"
font-weight="700"
fill="#ffffff"
dominant-baseline="central"
letter-spacing="-1"
>FanKarr</text>
<!-- Thin separator -->
<line x1="490" y1="204" x2="710" y2="204"
stroke="#e8a030" stroke-width="0.6" stroke-opacity="0.3"/>
<!-- Subtitle -->
<text
x="600" y="228"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Helvetica, Arial, sans-serif"
font-size="14"
font-weight="300"
font-style="italic"
fill="#4a6a84"
text-anchor="middle"
letter-spacing="2"
>par des fans, pour des fans</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 KiB

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+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 }}
+3
View File
@@ -1,3 +1,6 @@
# Claude Code
.claude/
# Logs
logs
*.log
View File
Whitespace-only changes.
+372
View File
@@ -1,5 +1,377 @@
# Changelog
## [3.18.1](https://github.com/Masutayunikon/FanKarr/compare/v3.18.0...v3.18.1) (2026-05-27)
### Bug Fixes
* **client:** suppresion de alldebrid ([8b4b198](https://github.com/Masutayunikon/FanKarr/commit/8b4b198697ba3296444759c522bb0139be69e364))
## [3.18.0](https://github.com/Masutayunikon/FanKarr/compare/v3.17.1...v3.18.0) (2026-05-25)
### Features
* **client:** ajout deluge ([045213d](https://github.com/Masutayunikon/FanKarr/commit/045213d363a75bf56bdb5a05e3965a040ead2893))
### Bug Fixes
* **client:** alldebrid ([045213d](https://github.com/Masutayunikon/FanKarr/commit/045213d363a75bf56bdb5a05e3965a040ead2893))
## [3.17.1](https://github.com/Masutayunikon/FanKarr/compare/v3.17.0...v3.17.1) (2026-05-24)
### Bug Fixes
* **api:** la recherche ne retourne plus les series sans torrents ([7b18631](https://github.com/Masutayunikon/FanKarr/commit/7b1863180f28cfaaccff69cb949fa6116a2c8cc6))
* **client:** alldebrid utilise maintenant les routes 4.1 ([7b18631](https://github.com/Masutayunikon/FanKarr/commit/7b1863180f28cfaaccff69cb949fa6116a2c8cc6))
* **readme:** update ([edec048](https://github.com/Masutayunikon/FanKarr/commit/edec048e30e1a5a528f1b2888f3db1708eeb15f9))
* **readme:** update ([bf9b18e](https://github.com/Masutayunikon/FanKarr/commit/bf9b18ec0002e84b040738f7229ed4570f5dcd32))
## [3.17.0](https://github.com/Masutayunikon/FanKarr/compare/v3.16.0...v3.17.0) (2026-05-23)
### Features
* ajout du parametre auto download ([0ac4812](https://github.com/Masutayunikon/FanKarr/commit/0ac48126aa3f69ded8ad0b7e83b1324360b25def))
* **auth:** migration multi-utilisateurs, rôles admin/user et token API par compte ([dabd1d4](https://github.com/Masutayunikon/FanKarr/commit/dabd1d429ee78b597d1e403c45645f0c89cb7ccc))
* **client:** ajour de alldebrid client ([6ce59b9](https://github.com/Masutayunikon/FanKarr/commit/6ce59b9e0458ecf5b3bc55d703c132a4ac39db66))
* **client:** ajout de alldebrid dans le tableau de comparatif ([f3d535d](https://github.com/Masutayunikon/FanKarr/commit/f3d535d5774ac7b25da6e948c481e32dcf63a5db))
* **demande:** ajout des routes pour recuperer les saisons et episodes depuis le plugin jellyfin ([1176523](https://github.com/Masutayunikon/FanKarr/commit/1176523d6165465a1f07fcc6251e35c239c896f7))
* **demandes:** ajout d'un bouton supprimé pour les admin et supprime les demandes "disponible" si la serie a été supprimé apres ([858a5b3](https://github.com/Masutayunikon/FanKarr/commit/858a5b34bf36bbeec3b18e87623e9b009e69ebcd))
* **demandes:** ajout des demandes par épisodes ([473d97e](https://github.com/Masutayunikon/FanKarr/commit/473d97e39283d5a0f28dd674ca837d3b61673831))
* **demandes:** telechargement auto lors de l'aprobation ([473d97e](https://github.com/Masutayunikon/FanKarr/commit/473d97e39283d5a0f28dd674ca837d3b61673831))
* **jellyfin:** synchronisation des comptes, SSO token exchange et API publique v1 ([dabd1d4](https://github.com/Masutayunikon/FanKarr/commit/dabd1d429ee78b597d1e403c45645f0c89cb7ccc))
* passer les demandes en disponible une fois l'import fait ([423ba6a](https://github.com/Masutayunikon/FanKarr/commit/423ba6a777322077257839ddc39ea03430846517))
* **requests:** demandes de séries avec fusion des saisons, vue admin et widget dashboard ([dabd1d4](https://github.com/Masutayunikon/FanKarr/commit/dabd1d429ee78b597d1e403c45645f0c89cb7ccc))
* **search:** ajout des requetes deja demandé si existante sur la serie ([4a6510a](https://github.com/Masutayunikon/FanKarr/commit/4a6510adde991bba85d20d7bb420a9f7ad6a2f0c))
* **search:** ajout des requetes deja demandé si existante sur la serie ([b7c7e9d](https://github.com/Masutayunikon/FanKarr/commit/b7c7e9d33dd6190cb97a82a685d414601c353e6e))
* **search:** ajout des requetes deja demandé si existante sur la serie ([ec1d170](https://github.com/Masutayunikon/FanKarr/commit/ec1d1705e76934d149a1a4afce5b36613cf0e22d))
* **search:** ajout description ([16b3f74](https://github.com/Masutayunikon/FanKarr/commit/16b3f741e7dbf10daff2f1bb8b1785ff67594515))
* **search:** retourne maintenant l'année et le rating ([99c8d86](https://github.com/Masutayunikon/FanKarr/commit/99c8d86c6e63d362067e18a3a5135ccfa49aff34))
* **users:** gestion des comptes et système d'invitations avec page d'administration ([dabd1d4](https://github.com/Masutayunikon/FanKarr/commit/dabd1d429ee78b597d1e403c45645f0c89cb7ccc))
### Bug Fixes
* ajout cors type ([0bac6da](https://github.com/Masutayunikon/FanKarr/commit/0bac6da9686edc45d79cde48387d5e3f89772646))
* ajout du parametre auto download ([921f6eb](https://github.com/Masutayunikon/FanKarr/commit/921f6eb8d28bbeb6cc4f5f72a6c1c79a14da03ba))
* argument type ([5d06a23](https://github.com/Masutayunikon/FanKarr/commit/5d06a231985f540bb66014c5ac8726f8740b3fde))
* button contrast and font size on card overlay ([bd5b16e](https://github.com/Masutayunikon/FanKarr/commit/bd5b16e17c3a4f2005c058399b2c30038f350f55))
* copy ([0544a51](https://github.com/Masutayunikon/FanKarr/commit/0544a515b654f9bb9183e2fb9d6a4b99de5af87d))
* cors, alldebrid ([911ca86](https://github.com/Masutayunikon/FanKarr/commit/911ca86a0d1025d41870fd1e9cb39ca038df28cb))
* demandes d'épisode qui demande toutes la serie ([62c5b2e](https://github.com/Masutayunikon/FanKarr/commit/62c5b2e622aa872236cf7d620a811c90606222bd))
* demandes d'épisode qui demande toutes la serie ([73678be](https://github.com/Masutayunikon/FanKarr/commit/73678be2c895a3487081b4e811810c8ac36cf1fb))
* **demandes:** multiples torrent et fix episode qui ne s'ajoute pas ([85a2180](https://github.com/Masutayunikon/FanKarr/commit/85a218042de55ae72efab2ce6f36084e14efa108))
* **demandes:** titres torrent ([e6b0bea](https://github.com/Masutayunikon/FanKarr/commit/e6b0bea455413e99155c11003bf055ac244c259a))
* **import:** supprime les entrees organiser quand on supprime un fichier ([6ce59b9](https://github.com/Masutayunikon/FanKarr/commit/6ce59b9e0458ecf5b3bc55d703c132a4ac39db66))
* **log:** changement pour une notification plus explicit lors du test client ([6ce59b9](https://github.com/Masutayunikon/FanKarr/commit/6ce59b9e0458ecf5b3bc55d703c132a4ac39db66))
* search retourne une image mtn ([5478fa6](https://github.com/Masutayunikon/FanKarr/commit/5478fa6d80314e2b0aaf745ef2d667b42e083d26))
* **serie:** 403 pour les non admin ([f3d535d](https://github.com/Masutayunikon/FanKarr/commit/f3d535d5774ac7b25da6e948c481e32dcf63a5db))
* **settings:** bug ou les parametre se sauvegarder en rentrant sur lla page avancé ([f3d535d](https://github.com/Masutayunikon/FanKarr/commit/f3d535d5774ac7b25da6e948c481e32dcf63a5db))
* **settings:** la clé api n'apparait que dans un menu pour les admin ([f3d535d](https://github.com/Masutayunikon/FanKarr/commit/f3d535d5774ac7b25da6e948c481e32dcf63a5db))
* **start:** url cliquable au demarrage dans le terminal ([6ce59b9](https://github.com/Masutayunikon/FanKarr/commit/6ce59b9e0458ecf5b3bc55d703c132a4ac39db66))
* **supression:** supprime maintenant le dossier de la serie et l'ajoute a organized.json ([161f4f5](https://github.com/Masutayunikon/FanKarr/commit/161f4f5051a327421cede35b1d714286588e4f5b))
* telechargement depuis les demandes de saison et integral ([7f4121d](https://github.com/Masutayunikon/FanKarr/commit/7f4121d9e4aab53a5baf105b46c987d69d4909a8))
* update request state after submit + override theme hover on modal buttons ([41addf9](https://github.com/Masutayunikon/FanKarr/commit/41addf9e4fa437f31c478d7074ce86b05d79c689))
## [3.16.0](https://github.com/Masutayunikon/FanKarr/compare/v3.15.0...v3.16.0) (2026-05-11)
### Features
* **log:** + de logs sur les erreurs d'import (chemin tenté et fichier du torrent chercher) ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
* **rss:** ajout du rss sur les series ([086ca30](https://github.com/Masutayunikon/FanKarr/commit/086ca3053f5e563d8c3c2bb82379ff45d77a5499))
* **UI/UX:** affichage de la serie et des episodes sur la page download du torrent ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
### Bug Fixes
* **client:** fix de plusieurs torrent et refonte du tableau de compatibilité ([086ca30](https://github.com/Masutayunikon/FanKarr/commit/086ca3053f5e563d8c3c2bb82379ff45d77a5499))
* **docker:** ajout du fichier de versions pour la navbar dans docker lors du build ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
* **download:** le telechargement de saison ne propose plus tout les torrents des episodes de la saisons si la serie n'a que des torrent par episodes ([c9040ea](https://github.com/Masutayunikon/FanKarr/commit/c9040ea68c52e65a656824ccfd101a04a2251edd))
* **import:** les episodes avec une association changé n'apparait plus sur la page serie ([8972679](https://github.com/Masutayunikon/FanKarr/commit/8972679e2588b6e5cd3e9fd53b0223e3d1a6e56c))
* **import:** les imports se font maintenant sur les series sans torrents qui n'avais pas de fichier de catalogue depuis le scraper ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
* **import:** on ne peux plus importer plusieurs fichiers sur le meme episodes, celui deja importé ce retire de l'importation dans le modal ([ad829c0](https://github.com/Masutayunikon/FanKarr/commit/ad829c0a99b29ade7eba4673b1218c0ab768a2d7))
* **import:** si on change un fichier son association alors il est bien retirer de l'import ([ad829c0](https://github.com/Masutayunikon/FanKarr/commit/ad829c0a99b29ade7eba4673b1218c0ab768a2d7))
* les torrents n'utilise plus la pause ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
* **rss:** le rss est maintenant sur les ajout apres l'activation de la surveillance ([a5e2aa0](https://github.com/Masutayunikon/FanKarr/commit/a5e2aa0da1b3af445a102d1acd77a3a08017689a))
* **UI/UX:** la bar de progression de saison a été retirer et les bars de progression s'affiche meme si le fichier est à 0% dans le torrent ([f07b608](https://github.com/Masutayunikon/FanKarr/commit/f07b608288afd4b01079367b428123b72182b714))
* **UI/UX:** retour sur le modal d'import et refresh ([a5e2aa0](https://github.com/Masutayunikon/FanKarr/commit/a5e2aa0da1b3af445a102d1acd77a3a08017689a))
* **UI/UX:** retour visuel lors de l'import d'une serie/episode ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
* utilisation de URLSearchParams pour les appels autre que pour le add du fichier .torrent ([42c9795](https://github.com/Masutayunikon/FanKarr/commit/42c979536b51e8ed263b23559b7c4a2d4d2b2902))
## [3.15.0](https://github.com/Masutayunikon/FanKarr/compare/v3.14.6...v3.15.0) (2026-05-10)
### Features
* ajout build docker pour arm32 ([950a2ab](https://github.com/Masutayunikon/FanKarr/commit/950a2abc49fe0b7b5bcc0f47d781a45a01838ec3))
## [3.14.6](https://github.com/Masutayunikon/FanKarr/compare/v3.14.5...v3.14.6) (2026-05-08)
### Bug Fixes
* pnpm build ([fcb176d](https://github.com/Masutayunikon/FanKarr/commit/fcb176d519b1bff49c40b79192e71b6062671998))
## [3.14.5](https://github.com/Masutayunikon/FanKarr/compare/v3.14.4...v3.14.5) (2026-05-08)
### Bug Fixes
* pnpm build ([d04ca79](https://github.com/Masutayunikon/FanKarr/commit/d04ca79e0057e3d6cf92e49822d812266f629dba))
## [3.14.4](https://github.com/Masutayunikon/FanKarr/compare/v3.14.3...v3.14.4) (2026-05-08)
### Bug Fixes
* lock file for build ([ef527c2](https://github.com/Masutayunikon/FanKarr/commit/ef527c2072a93650f9159de12ae875a671fb845c))
## [3.14.3](https://github.com/Masutayunikon/FanKarr/compare/v3.14.2...v3.14.3) (2026-05-08)
### Bug Fixes
* build binaries node version ([6d8ea36](https://github.com/Masutayunikon/FanKarr/commit/6d8ea36260cb76e0592bbee3ce5450d2424b9209))
## [3.14.2](https://github.com/Masutayunikon/FanKarr/compare/v3.14.1...v3.14.2) (2026-05-07)
### Bug Fixes
* build binaries node version ([9ebaca9](https://github.com/Masutayunikon/FanKarr/commit/9ebaca92f18213fd6a79bf55f22cfb8358c4f7e0))
## [3.14.1](https://github.com/Masutayunikon/FanKarr/compare/v3.14.0...v3.14.1) (2026-05-07)
### Bug Fixes
* eslint build ([25ab373](https://github.com/Masutayunikon/FanKarr/commit/25ab373539c20942202e82cf678e5b7577ff0027))
## [3.14.0](https://github.com/Masutayunikon/FanKarr/compare/v3.13.2...v3.14.0) (2026-05-07)
### Features
* **qbittorrent:** support API key authentication (≥5.2.0) ([702847a](https://github.com/Masutayunikon/FanKarr/commit/702847a5c2eb5956f54a8475fefc80a08fca70f1))
* **qbittorrent:** support cookie name (≥5.2.0) ([aa8b500](https://github.com/Masutayunikon/FanKarr/commit/aa8b500f57efb97aae10eb0bd8e254388a8aff42))
### Bug Fixes
* ajout de log pour le cookie ([2b3fd79](https://github.com/Masutayunikon/FanKarr/commit/2b3fd79a92bbc7d7ed2142b7a23e03799efe0a28))
* cookie ([7da5a8d](https://github.com/Masutayunikon/FanKarr/commit/7da5a8d4e853f7e92770f20352478a76ee91642d))
* le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode ([105cc05](https://github.com/Masutayunikon/FanKarr/commit/105cc05bdf38127089869ea201165ac1d3479275))
* on ne vois plus le telechargement de fichier qui ont une priorité basse (ne pas télécharger) ([1f50f9a](https://github.com/Masutayunikon/FanKarr/commit/1f50f9ae72ec9958bfb92b2e6936c699e19272f3))
* renaming dans le managements des series ([1e92934](https://github.com/Masutayunikon/FanKarr/commit/1e92934f531c82b9da90c8597ec08cd162b5a9ee))
* renaming dans le managements des series ([bf3f303](https://github.com/Masutayunikon/FanKarr/commit/bf3f30356e086d310b7213b267356bc20976589c))
* rtorrent priority default to 1 ([bfabf9b](https://github.com/Masutayunikon/FanKarr/commit/bfabf9b7937f055579add331cd918a13f05084bf))
## [3.13.2](https://github.com/Masutayunikon/FanKarr/compare/v3.13.1...v3.13.2) (2026-05-06)
### Bug Fixes
* le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode ([0d5d44a](https://github.com/Masutayunikon/FanKarr/commit/0d5d44a4840df27d8b23bde9935d12c664d5ad6c))
## [3.13.1](https://github.com/Masutayunikon/FanKarr/compare/v3.13.0...v3.13.1) (2026-05-06)
### Bug Fixes
* ajout du champ wiki dans l'api ([95d2b27](https://github.com/Masutayunikon/FanKarr/commit/95d2b270dcb3052b65f5918ec770799b43316cd6))
## [3.13.0](https://github.com/Masutayunikon/FanKarr/compare/v3.12.1...v3.13.0) (2026-05-06)
### Features
* ajout de la mise a jour des id's ([39463bf](https://github.com/Masutayunikon/FanKarr/commit/39463bfbcb74c7241420504ba51bd91108df20e4))
* ajout de la prise en charges des codex ([83437de](https://github.com/Masutayunikon/FanKarr/commit/83437def32618c8a5b9ca4364db992762b9359f8))
* **organize:** vérification de complétion par fichier avant d'importer ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **serie:** badges épisodes et chapitres extraits du synopsis ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **serie:** lien wiki dans le header de la série si serie.wiki est défini ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **torrent-clients:** ajout du driver Real-Debrid ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
### Bug Fixes
* ajout de log ([d299b76](https://github.com/Masutayunikon/FanKarr/commit/d299b76bd3fc8a8411e71f9f05318901df8372ef))
* **organize:** retry EBUSY sur unlink — 5 tentatives × 500 ms (Windows) ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **organize:** suppression des entrées orphelines lors de la migration d'IDs ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* progression ([35655d7](https://github.com/Masutayunikon/FanKarr/commit/35655d7784025c9b2d7f992b24181d62b8488130))
* **qbittorrent:** FanKarr télécharge le .torrent et l'uploade directement à qBit ([434da54](https://github.com/Masutayunikon/FanKarr/commit/434da54ede8ece06c1fbcf4d66fe6ecdf87557a5))
* **qbittorrent:** pause uniquement pour les .torrent, pas pour les magnets ([9f90b6a](https://github.com/Masutayunikon/FanKarr/commit/9f90b6ab6c0aa97e33244d827370492abcfe9a13))
* **qbittorrent:** suppression de paused/stopped à l'ajout du torrent ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **serie:** header mobile — items-start + aspect-[2/3] sur le poster ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* utilisation de formated_name depuis les paths ([39463bf](https://github.com/Masutayunikon/FanKarr/commit/39463bfbcb74c7241420504ba51bd91108df20e4))
## [3.12.1](https://github.com/Masutayunikon/FanKarr/compare/v3.12.0...v3.12.1) (2026-04-29)
### Bug Fixes
* **UI/UX:** ajout d'un modal de confirmation lors de l'activation des nfo ([8b98b0e](https://github.com/Masutayunikon/FanKarr/commit/8b98b0edf926ba6b542501afdf524050d72f043b))
## [3.12.0](https://github.com/Masutayunikon/FanKarr/compare/v3.11.5...v3.12.0) (2026-04-29)
### Features
* désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission ([2446622](https://github.com/Masutayunikon/FanKarr/commit/24466228ef5305c36574f58fb2590aee8ee86e13))
* désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission ([fcb1b88](https://github.com/Masutayunikon/FanKarr/commit/fcb1b88870bebb1bf0b4e484b2462e56ab3975fc))
* désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission ([3fb4119](https://github.com/Masutayunikon/FanKarr/commit/3fb4119c79908c1023f3cbf9999b524eb1bc267e))
### Bug Fixes
* qbitorrent ([9834023](https://github.com/Masutayunikon/FanKarr/commit/98340235226394c4583159f36cce74e316eedf26))
* qbitorrent header ([f5094c6](https://github.com/Masutayunikon/FanKarr/commit/f5094c68c6834f816b0b6d019e5d5637400e16ab))
* spinner infinie apres supression ([39d2f99](https://github.com/Masutayunikon/FanKarr/commit/39d2f996aecb9f07dcd8c1ec7911090276908a2f))
* spinner infinie apres supression ([97c573c](https://github.com/Masutayunikon/FanKarr/commit/97c573cc1d4fb76787ba5d1df4fda30faec5fbf2))
* **UI/UX:** chevron pour ouvrir la description, bouton desimporter de la serie passer à supprimer et affichage de toute la description de la serie lors d'un clique ([f6ef818](https://github.com/Masutayunikon/FanKarr/commit/f6ef818e60875a1ac5d859480c1e0e53082c0858))
* **UI/UX:** meilleur comprehension des parametres avancés des clients ([040a8d7](https://github.com/Masutayunikon/FanKarr/commit/040a8d7b339090d819c6e3dc049f1ab9d8b221f5))
## [3.11.5](https://github.com/Masutayunikon/FanKarr/compare/v3.11.4...v3.11.5) (2026-04-26)
### Bug Fixes
* le folder picker affiche tous les disques depuis le bouton homes sous windows ([7149022](https://github.com/Masutayunikon/FanKarr/commit/7149022c8701f79d194c675da87c6cb2d5793a35))
## [3.11.4](https://github.com/Masutayunikon/FanKarr/compare/v3.11.3...v3.11.4) (2026-04-26)
### Bug Fixes
* ajout de la mise a jour des noms toutes les 1h ([0fe5c8c](https://github.com/Masutayunikon/FanKarr/commit/0fe5c8ccb7ecdc1643b5e69de3957c39dfd8085e))
## [3.11.3](https://github.com/Masutayunikon/FanKarr/compare/v3.11.2...v3.11.3) (2026-04-26)
### Bug Fixes
* dockerfile node version to 22 ([5983494](https://github.com/Masutayunikon/FanKarr/commit/598349407750c729579bd639680ebc6a8ebcc12f))
## [3.11.2](https://github.com/Masutayunikon/FanKarr/compare/v3.11.1...v3.11.2) (2026-04-26)
### Bug Fixes
* ajout de undici dans le package.json ([459e602](https://github.com/Masutayunikon/FanKarr/commit/459e602c1df1c610466be5015a660624b1189620))
## [3.11.1](https://github.com/Masutayunikon/FanKarr/compare/v3.11.0...v3.11.1) (2026-04-26)
### Bug Fixes
* le setup plex peux maintenant etre utilisé sur un serveur local (probleme de certificat) ([6907584](https://github.com/Masutayunikon/FanKarr/commit/69075842be396b149949b158922a9dc84dbc7ccf))
## [3.11.0](https://github.com/Masutayunikon/FanKarr/compare/v3.10.0...v3.11.0) (2026-04-23)
### Features
* ajout d'un bouton pour supprimer les nfo / png ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
* ajout de log pour le bouton tout renommer ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
* ajout du support multifile sur utorrent ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
### Bug Fixes
* renommage d'episodes qui ne fonctionne pas en 1 par 1 ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
## [3.10.0](https://github.com/Masutayunikon/FanKarr/compare/v3.9.2...v3.10.0) (2026-04-22)
### Features
* ajout d'une variable d'environment pour augmenter la durée du token ou le desactiver avec "never" ou 0 ([dc5387c](https://github.com/Masutayunikon/FanKarr/commit/dc5387c69a308bdf99f0b77f9769462ffda13db5))
* ajout des telechargments par episodes / saisons ([d6e3c99](https://github.com/Masutayunikon/FanKarr/commit/d6e3c9963c9db5fedc579377cbad485f25ab0537))
* ajout du .env dans les dossiers du binaires pour pouvoir changer leur valeurs ([dc5387c](https://github.com/Masutayunikon/FanKarr/commit/dc5387c69a308bdf99f0b77f9769462ffda13db5))
* ajout du langage ([94e7b66](https://github.com/Masutayunikon/FanKarr/commit/94e7b668e13c8406768a84e7a821e2fd7fdc96fc))
* update readme ([01e70ed](https://github.com/Masutayunikon/FanKarr/commit/01e70ed9c00f8c614a6a9947466e17a5128f52db))
* utilisation des bar de telechargement par fichier dans le torrent plutot que celui du torrent ([d03f0ac](https://github.com/Masutayunikon/FanKarr/commit/d03f0ac397acd117b75a92b6763441a667f4e6cb))
### Bug Fixes
* affichage de la bar de progression et confirmation sur le bouton pour les episodes en doublons ([23be797](https://github.com/Masutayunikon/FanKarr/commit/23be797f97b1e49723140cdd34d0d217a0e819a3))
* affichage de la bar de progression et confirmation sur le bouton pour les episodes en doublons et dropdown ([95d4b01](https://github.com/Masutayunikon/FanKarr/commit/95d4b015eeb0298f77cfe5828f7a14d15fb77dd5))
* ajout des boutons pour les episodes sans torrent ([94048e4](https://github.com/Masutayunikon/FanKarr/commit/94048e458248a43e50c566e01d985105ee850ec3))
* bouton global de serie ([93a3505](https://github.com/Masutayunikon/FanKarr/commit/93a35055467cfab2503f6b0dfefcce8cd3f53559))
* bouton tous telecharger a la place de integral, multi torrents sur le bouton saisons et tous telecharger ([02dd4c8](https://github.com/Masutayunikon/FanKarr/commit/02dd4c866ad4c396b324c5185e2b9458d8864b58))
* changer les badge de language pour un truc plus explicite ([94a1ed1](https://github.com/Masutayunikon/FanKarr/commit/94a1ed1fe3cb9e49f90c8fbc200aaac969895dff))
* coherence des badges ([5d84737](https://github.com/Masutayunikon/FanKarr/commit/5d84737a28c2d0c49e8acce6ccd7a7bf915e1309))
* dropdown overflow ([5baf707](https://github.com/Masutayunikon/FanKarr/commit/5baf7072207eca1f62a42c13602c2fdc85d6e54f))
* dropdown overflow ([f820ea0](https://github.com/Masutayunikon/FanKarr/commit/f820ea0cf252cbc11c527f1393e332b9acd274fe))
* fallback de noms si les noms de torrents se ressemble il fallback sur les noms dans nyaa ([59fb769](https://github.com/Masutayunikon/FanKarr/commit/59fb7693a4726fc31223373559404f63b6963340))
* improve container detection to support Podman and other runtimes ([7d6c86f](https://github.com/Masutayunikon/FanKarr/commit/7d6c86f888b4b7c5696a5de21bce12d1864035a2))
* le dropdown apparait sur le bouton de telechargement et pas les 3 petit points pour les multiples ([eaf733f](https://github.com/Masutayunikon/FanKarr/commit/eaf733f2e5caccf10a2f30a0e10db00831d6d635))
* les fichiers non selectionner sur un torrent ne produise plus d'erreurs d'import ([2a08d7d](https://github.com/Masutayunikon/FanKarr/commit/2a08d7dbf5a8a91323801e9fa3722dfb02a7cd5e))
* les fichiers non selectionner sur un torrent ne sont plus en "attente d'import" sur l'affichage de la serie ([7799f7d](https://github.com/Masutayunikon/FanKarr/commit/7799f7de86d0a207c960f72fa8e8db84de86d2a8))
* les torrents sont stopé lors de la selection de fichier pour eviter qu'il commence le telechargement des autres fichiers avant de set les indexs ([45d340d](https://github.com/Masutayunikon/FanKarr/commit/45d340d01ab8507423b187447d0f0e36a75d21d4))
* les torrents sont stopé lors de la selection de fichier pour eviter qu'il commence le telechargement des autres fichiers avant de set les indexs ([cdfb330](https://github.com/Masutayunikon/FanKarr/commit/cdfb330dd6ac20d2bfe02d196a8d94f0996c763c))
* n'affiche plus tous les épisodes en train de se telecharger si seulement un fichier est selectionner ([c4bfa2b](https://github.com/Masutayunikon/FanKarr/commit/c4bfa2ba87167bf20c066b3f682bd70aa9b18467))
* n'affiche plus tous les épisodes en train de se telecharger si seulement un fichier est selectionner ([1e4f735](https://github.com/Masutayunikon/FanKarr/commit/1e4f73522dd216d80c57839bd35c15dcc906bf90))
* ne retire pas les fichiers quand on en ajoute sur le torrent ([5c1ae3e](https://github.com/Masutayunikon/FanKarr/commit/5c1ae3e7beb59dcc2769bddb0fa63266e10bcbab))
* prise en charge de multiple torrent sur les telechargement d'episodes ([dc5387c](https://github.com/Masutayunikon/FanKarr/commit/dc5387c69a308bdf99f0b77f9769462ffda13db5))
* qbit utilisation de /start au lieu de /resume pour la v5 ([98a3d81](https://github.com/Masutayunikon/FanKarr/commit/98a3d81c4882ed4e8db1047b2b8c5edddbe037b2))
* utilisation de svg pour les drapeau ([c11287b](https://github.com/Masutayunikon/FanKarr/commit/c11287bd3fd88badc91d35b5ec825b09ff5237a1))
* utilisation des noms de dossier du torrent pour l'affichage plutot que le nom sur nyaa (notament pour gto kai qui a plusieurs integral et qui est plus explicite qque le nom nyaa) ([4069035](https://github.com/Masutayunikon/FanKarr/commit/4069035956472bf179a3a9bb8bd317d6cce8dc13))
* utilisation du fichier torrent avec le magnet pour les bug de metadonnées ([dc5387c](https://github.com/Masutayunikon/FanKarr/commit/dc5387c69a308bdf99f0b77f9769462ffda13db5))
## [3.9.2](https://github.com/Masutayunikon/FanKarr/compare/v3.9.1...v3.9.2) (2026-04-16)
### Bug Fixes
* le bouton "tous télécharger" ne s'affiche plus si la series de contient que des integrales comme torrent ([d21270a](https://github.com/Masutayunikon/FanKarr/commit/d21270ae802ee43242365331922393b5cbf689fa))
## [3.9.1](https://github.com/Masutayunikon/FanKarr/compare/v3.9.0...v3.9.1) (2026-04-16)
### Bug Fixes
* le badge de rename utilise la meme logique que coté serveur et n'apparait plus sur les vue series si il ne devrais pas ([9764d2d](https://github.com/Masutayunikon/FanKarr/commit/9764d2db8ed2531af3feb6ceba947eaf116c1e76))
## [3.9.0](https://github.com/Masutayunikon/FanKarr/compare/v3.8.1...v3.9.0) (2026-04-15)
### Features
* ajout d'un bouton supprimé l'importation dans le menu d'importation d'une serie ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
* ajout de badge quand le fichier est introuvable ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
* ajout de la possibilité de renommer des imports dans le bon format ([67444d4](https://github.com/Masutayunikon/FanKarr/commit/67444d418d65cf0d0323c8955adb8d9e136a8797))
* ajout du bouton suppresion de torrent en cours ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
* ajout setup plex ([2123e73](https://github.com/Masutayunikon/FanKarr/commit/2123e73b7fec9c637b349e63da9c9a88dd9fccd6))
* bouton tous télécharger sur les saisons et series ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
* suppression des series supprimé ([286106e](https://github.com/Masutayunikon/FanKarr/commit/286106ef1eed2803cf14549af7a172b13b9c2b88))
### Bug Fixes
* ajout d'un loader sur certaines pages pour pas avoir des trucs par default qui s'affiche ([d241be3](https://github.com/Masutayunikon/FanKarr/commit/d241be30d126f5b975aca72d2c21ab74e7215fa4))
* ajout de la page management dans le layout ([5dc0531](https://github.com/Masutayunikon/FanKarr/commit/5dc0531bfd50ac9ca5a59126670757179ed8a226))
* cast string ([16d1bfe](https://github.com/Masutayunikon/FanKarr/commit/16d1bfe7e7766987876db80f84e6c0dc31217403))
* Deux corrections : ([5398a4f](https://github.com/Masutayunikon/FanKarr/commit/5398a4fe12b1ac235884bb9c6218174b0c438ac1))
* le bouton tout telecharger apparait sur les packs de saisons et les integrals + autres et pas que sur les series avec des episodes telechargable ([e105587](https://github.com/Masutayunikon/FanKarr/commit/e105587147e03e39e2c3b985a114434c2512417a))
* move plex settings en haut des boutons ([96af23d](https://github.com/Masutayunikon/FanKarr/commit/96af23d258e14dacf371b043b92b554c2b22f80c))
* plex catcher ([ba0dc8d](https://github.com/Masutayunikon/FanKarr/commit/ba0dc8d21bca098b0203f9553d81a47048fad452))
* suppression de dest_filename du fichier d'organisation qui est faux et ne sert à rien ([3c69eba](https://github.com/Masutayunikon/FanKarr/commit/3c69eba440f4f83180bd5d3f25a2ad0499b91940))
* utorrent prend desormais le dossier cible correctement ([cc68fef](https://github.com/Masutayunikon/FanKarr/commit/cc68fef88d6e13339867f6b6b47667a141a86091))
* wrong file copy ([fa7da76](https://github.com/Masutayunikon/FanKarr/commit/fa7da7681193e89214a521c5bd16106f0ca03f46))
## [3.8.1](https://github.com/Masutayunikon/FanKarr/compare/v3.8.0...v3.8.1) (2026-04-11)
### Bug Fixes
* la version dev se met uniquement en dev ([79e5478](https://github.com/Masutayunikon/FanKarr/commit/79e547859720f36330d95036de813240da034bbd))
## [3.8.0](https://github.com/Masutayunikon/FanKarr/compare/v3.7.1...v3.8.0) (2026-04-11)
+6 -4
View File
@@ -1,7 +1,7 @@
FROM node:20-slim AS base
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack enable && corepack prepare pnpm@10 --activate
COPY . /app
WORKDIR /app
@@ -15,7 +15,8 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
# ── Production ─────────────────────────────────────────────────
FROM node:20-slim
FROM node:22-slim
ARG BUILD_VERSION=dev
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -25,7 +26,8 @@ COPY --from=build /app/dist/client /app/public
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN mkdir -p /config
RUN echo "$BUILD_VERSION" > /app/version.txt
EXPOSE 3001
ENV NODE_ENV=production
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["/entrypoint.sh"]
+256 -69
View File
@@ -1,58 +1,87 @@
<div align="center">
# Fankarr
# FanKarr
![Version](https://img.shields.io/github/v/release/masutayunikon/fankarr?style=flat-square&color=e8513a&label=version)
![Docker Image Size](https://img.shields.io/docker/image-size/masutayunikon/fankarr/latest?style=flat-square&color=1e2d3d&label=image)
![Docker Pulls](https://img.shields.io/docker/pulls/masutayunikon/fankarr?style=flat-square&color=1e2d3d)
![GitHub Actions](https://img.shields.io/github/actions/workflow/status/masutayunikon/fankarr/containerize.yml?style=flat-square&label=build)
![License](https://img.shields.io/github/license/masutayunikon/fankarr?style=flat-square&color=5a7a94)
![Node](https://img.shields.io/badge/node-20-5a7a94?style=flat-square)
![Vue](https://img.shields.io/badge/vue-3-42b883?style=flat-square)
![Bun](https://img.shields.io/badge/bun-ready-f9f1e1?style=flat-square)
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
![Banner](.github/images/banner.svg)
</div>
---
## Aperçu
<!-- [img] : vue catalogue complète — grille des affiches avec les barres d'état en bas de chaque affiche (couleurs : dispo / en cours / importé), barre de recherche visible -->
![Catalogue](.github/images/catalog.png)
> Vue catalogue : toutes les séries Fankai avec leur état d'organisation. Les badges indiquent si les fichiers sont déjà présents dans votre médiathèque Jellyfin.
> Vue catalogue : toutes les séries Fankai. Une barre en bas de chaque affiche indique l'état — disponible, en cours de téléchargement ou déjà importé dans votre médiathèque.
---
<!-- [img] : vue série — détail des saisons et épisodes avec badges VOSTFR/MULTI + drapeaux, boutons de téléchargement avec dropdown multi-sources, barres de progression par épisode -->
![Série](.github/images/serie.png)
> Vue série : épisodes avec badge de langue (VOSTFR / MULTI), progression par fichier, et dropdown de sélection quand plusieurs sources sont disponibles.
---
<!-- [img] : vue téléchargements — liste des torrents actifs avec barres de progression, état d'organisation, badges erreur au hover -->
![Téléchargements](.github/images/downloads.png)
> Vue téléchargements : suivi en temps réel, état d'organisation par torrent et détail des erreurs au survol.
---
## Fonctionnalités
- **Catalogue complet** des séries Fankai avec affiches, statut et badges d'état
- **Téléchargement en un clic** vers qBittorrent — par épisode, par saison ou intégrale
- **Organisation automatique** des fichiers téléchargés vers votre médiathèque (hardlink ou déplacement)
- **Suivi par épisode** — badge ✓ organisé sur chaque épisode et compteur par saison
- **Notifications** toast automatiques quand une organisation se termine
- **Page téléchargements** avec statut d'organisation et badge erreurs avec détail au hover
- **Logs centralisés** avec filtre par niveau/source, rotation automatique et clear depuis l'UI
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
### 📥 Téléchargement
- **Catalogue complet** des séries Fankai avec affiches et état de disponibilité
- **Téléchargement en un clic** vers votre client torrent — par épisode, par saison ou en intégrale
- **Dropdown intelligent** sur chaque bouton quand plusieurs sources sont disponibles (qualités différentes, packs différents)
- **"Tout télécharger"** unifié — sélectionne automatiquement les épisodes non couverts par l'intégrale choisie et complète avec les packs individuels
- **Progression par fichier** — barre de téléchargement au niveau de l'épisode, même pour les fichiers dans un pack intégrale
- **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
- **Compatible Runtipi / Docker**
---
![Serie](.github/images/serie.png)
> Vue série : détail des épisodes avec leur état de disponibilité torrent et d'organisation dans Jellyfin.
---
![Download](.github/images/download.png)
> Vue téléchargements : suivi en temps réel de l'organisation avec détail des erreurs au survol.
---
![Logs](.github/images/log.png)
> Page logs : historique complet des événements avec filtres et gestion de la taille.
- **Multi-client torrent** — qBittorrent, Transmission, Synology Download Station, µTorrent, rTorrent, Real-Debrid, AllDebrid
- **Compatible Docker / Runtipi / Binaire autonome**
---
@@ -60,9 +89,9 @@ Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
Les données du catalogue sont récupérées depuis le repo [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
Ce repo contient un pipeline de scripts Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, puis les croise avec l'API metadata de Fankai pour identifier chaque épisode. Les scripts tournent automatiquement **toutes les 6 heures** via GitHub Actions et publient le fichier `torrent_final.json` dans le repo.
Ce repo contient un pipeline Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, les croise avec l'API metadata de Fankai pour identifier chaque épisode, et publie le résultat automatiquement **toutes les 6 heures** via GitHub Actions. Les données sont organisées par série dans un dossier `/serie` et des fichiers d'index à la racine du repo.
Fankarr récupère ce fichier au démarrage et via le bouton **Mettre à jour** dans les paramètres. Aucun scraping ne se fait en local.
FanKarr récupère ces données au démarrage et via le bouton **Mettre à jour** dans les paramètres. Aucun scraping ne se fait en local.
---
@@ -70,7 +99,7 @@ Fankarr récupère ce fichier au démarrage et via le bouton **Mettre à jour**
### Docker Compose (recommandé)
**Prérequis** : Docker + Docker Compose, qBittorrent accessible en réseau.
**Prérequis** : Docker + Docker Compose, un client torrent accessible en réseau.
```yaml
services:
@@ -93,83 +122,239 @@ services:
docker compose up -d
```
> **⚠️ Note sur les hardlinks** : Pour que le mode `hardlink` fonctionne, vos dossiers de téléchargement complets et votre médiathèque doivent être sur le **même filesystem**. La solution recommandée est de monter un seul volume racine (ex: `/votre/chemin:/media`) et de configurer vos chemins à l'intérieur — comme le font Radarr et Sonarr.
> **⚠️ Note sur les hardlinks** : Pour que le mode `hardlink` fonctionne, vos dossiers de téléchargement complets et votre médiathèque doivent être sur le **même filesystem**. La solution recommandée est de monter un seul volume racine (ex: `/votre/chemin:/media`) et de configurer vos chemins à l'intérieur — comme Radarr et Sonarr le font.
>
> Vous pouvez tout à fait monter plusieurs volumes si vous le souhaitez, mais dans ce cas les hardlinks ne seront pas possibles entre deux volumes différents et Fankarr basculera automatiquement en copie.
> Plusieurs volumes sont possibles mais les hardlinks ne fonctionneront pas entre deux volumes différents — FanKarr basculera automatiquement en copie.
---
### Binaire autonome (Windows / Linux / macOS)
Téléchargez l'archive correspondant à votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
| Système | Archive |
| --------------------- | ------------------------- |
| Windows x64 | `fankarr-windows-x64.zip` |
| Linux x64 | `fankarr-linux-x64.zip` |
| macOS (Apple Silicon) | `fankarr-macos.zip` |
| Système | Archive |
| -------------------------------- | -------------------------------- |
| Windows x64 | `fankarr-windows-x64.zip` |
| Windows Legacy (anciens CPU) | `fankarr-windows-legacy.zip` |
| Linux x64 | `fankarr-linux-x64.zip` |
| macOS (Apple Silicon) | `fankarr-macos.zip` |
Extrayez l'archive — elle contient le binaire **et** le dossier `public/` qui doivent rester ensemble :
Extrayez l'archive — tous les fichiers doivent rester ensemble dans le même répertoire :
```
fankarr-windows-x64/
├── fankarr.exe ← lancer ce fichier
└── public/ ← ne pas déplacer séparément
├── fankarr.exe ← lancer ce fichier
├── organize-worker.js ← worker d'organisation (ne pas déplacer)
├── .env ← configuration optionnelle (voir Variables d'environnement)
└── public/ ← assets frontend (ne pas déplacer)
```
**Linux / macOS** : rendre le binaire exécutable avant le premier lancement :
> Le fichier `.env` permet de configurer le port, la durée des sessions, etc. sans passer par des variables d'environnement système. Il est chargé automatiquement au démarrage si présent à côté du binaire.
**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
```
Fankarr sera accessible sur `http://localhost:9898`. Les données (config, logs, base de données) sont sauvegardées dans un dossier `data/` créé automatiquement à côté du binaire.
FanKarr sera accessible sur `http://localhost:9898`. La configuration, les logs et la base de données sont sauvegardés dans un dossier `config/` créé automatiquement à côté du binaire.
---
### Variables d'environnement
| Variable | Défaut | Description |
| ---------------- | ------------ | ----------------------------------------------------------------------- |
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
| `PORT` | `9898` | Port d'écoute du serveur |
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `/config/secret.key` si absent |
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
| Variable | Défaut | Description |
| ------------------ | ------------- | ------------------------------------------------------------------------------- |
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
| `PORT` | `9898` | Port d'écoute du serveur |
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `/config/secret.key` si absent |
| `AUTH_TOKEN_EXPIRY`| `30d` | Durée de validité du token de connexion (ex: `7d`, `1y`, `never`) |
| `DATA_DIR` | `./config` | Répertoire de stockage des données (binaire uniquement) |
| `GITHUB_RAW_URL` | repo scraper | URL de base du repo scraper si vous hébergez votre propre instance |
---
### Premier démarrage
1. Ouvrir `http://localhost:9898`
2. Créer un mot de passe à la première connexion
3. Aller dans **Paramètres** → ajouter votre client qBittorrent
4. Vérifier les chemins `completePath` et `mediaPath`
5. Choisir le mode d'organisation : `hardlink` (recommandé) ou `move`
6. Retourner au catalogue et télécharger
3. Aller dans **Paramètres** → configurer votre client torrent
4. Renseigner le chemin vers le dossier des **torrents terminés** et celui de votre **médiathèque**
5. Choisir le mode d'organisation : `hardlink` (recommandé), `move` ou `copy`
6. Optionnel : lancer le **scan** pour référencer les fichiers déjà présents sur le disque
7. Retourner au catalogue et télécharger
<!-- [img] : page paramètres — formulaire de configuration client torrent, chemins, mode d'organisation, toggles autoImport / NFO / désimport auto -->
![Paramètres](.github/images/settings.png)
---
## Organisation des fichiers
Fankarr organise automatiquement les fichiers terminés toutes les **30 secondes** vers la structure attendue par Jellyfin :
FanKarr organise les fichiers terminés vers la structure attendue par Jellyfin / Plex :
```
/media/Kai/
└── Black Lagoon Henshū/
└── Saison 1/
├── Black Lagoon Henshū S01E01.mkv
├── Black Lagoon Henshū S01E02.mkv
└── ...
Black Lagoon Henshū/
├── Saison 1/
│ ├── Black Lagoon Henshū.S01E01.MULTI.1080p.x264-FANKAI.mkv
│ ├── Black Lagoon Henshū.S01E02.MULTI.1080p.x264-FANKAI.mkv
│ ├── Black Lagoon Henshū.S01E03.MULTI.1080p.x264-FANKAI.mkv
│ └── Black Lagoon Henshū.S01E04.MULTI.1080p.x264-FANKAI.mkv
├── Saison 2/
│ ├── Black Lagoon Henshū.S02E05.MULTI.1080p.x264-FANKAI.mkv
│ ├── Black Lagoon Henshū.S02E06.MULTI.1080p.x264-FANKAI.mkv
│ └── Black Lagoon Henshū.S02E07.MULTI.1080p.x264-FANKAI.mkv
└── Saison 3/
└── Black Lagoon Henshū.S03E08.MULTI.1080p.x264-FANKAI.mkv
```
Le mode **hardlink** est recommandé si vos dossiers `complete` et `media` sont sur le même filesystem — les fichiers ne sont pas déplacés, qBittorrent continue de seeder.
Le mode **hardlink** est recommandé si vos dossiers de téléchargement et de médiathèque sont sur le même filesystem — les fichiers ne sont pas déplacés et le client torrent continue de seeder sans interruption.
L'organisation se déclenche :
- **Automatiquement** dès la fin d'un téléchargement, et toutes les 5 minutes en arrière-plan pour les torrents déjà en seeding
- **Manuellement** depuis le bouton **Importer** dans la vue téléchargements
Le **scan médiathèque** permet de référencer des fichiers déjà présents sur le disque sans les déplacer — utile si vous avez déjà une bibliothèque existante.
---
## Logs
<!-- [img] : page logs — liste des événements avec filtres niveau/source, bouton clear, affichage compact -->
![Logs](.github/images/logs.png)
> Tous les événements sont horodatés et filtrables par source (`organize`, `api`, `torrent`, …) et niveau (`info`, `warn`, `error`, `debug`).
---
## 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 :
FanKarr est disponible dans l'appstore personnalisé. Pour l'installer :
1. Dans Runtipi, aller dans **Paramètres → App Stores**
2. Ajouter l'URL : `https://github.com/Masutayunikon/runtipi-appstore`
3. Fankarr apparaît dans le catalogue → **Installer**
3. FanKarr apparaît dans le catalogue → **Installer**
---
@@ -177,9 +362,10 @@ Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
| Couche | Technologie |
| ----------------- | ---------------------------------------- |
| Frontend | Vue 3 + Vite + Tailwind CSS |
| Frontend | Vue 3 + Vite + Tailwind CSS v4 |
| Backend | Express 5 + TypeScript |
| Auth | JWT + bcrypt |
| Worker | Node Worker Threads (organisation async) |
| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) |
| Packaging binaire | Bun (self-contained, pas de Node requis) |
@@ -188,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
+3
View File
@@ -20,18 +20,21 @@
"@vueuse/core": "^14.2.1",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"lucide-vue-next": "^1.0.0",
"node-fetch": "^3.3.2",
"pinia": "^3.0.4",
"tailwindcss": "^4.2.1",
"undici": "^8.1.0",
"vue": "^3.5.25",
"vue-router": "^5.0.3"
},
"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",
+37
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
@@ -38,6 +41,9 @@ importers:
tailwindcss:
specifier: ^4.2.1
version: 4.2.1
undici:
specifier: ^8.1.0
version: 8.1.0
vue:
specifier: ^3.5.25
version: 3.5.29(typescript@5.9.3)
@@ -51,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
@@ -509,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==}
@@ -755,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==}
@@ -1123,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'}
@@ -1343,6 +1363,10 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@8.1.0:
resolution: {integrity: sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==}
engines: {node: '>=22.19.0'}
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
@@ -1744,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':
@@ -2038,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: {}
@@ -2394,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:
@@ -2645,6 +2680,8 @@ snapshots:
undici-types@7.16.0: {}
undici@8.1.0: {}
unpipe@1.0.0: {}
unplugin-utils@0.3.1:
+136 -64
View File
@@ -1,100 +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'
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
const SALT_ROUNDS = 10
interface AuthData {
username : string
passwordHash: string
// ── Typage Express étendu ─────────────────────────────────────
declare global {
namespace Express {
interface Request { user?: User }
}
}
// ── Helpers fichier ───────────────────────────────────────────
function readAuth(): AuthData | null {
try {
if (!fs.existsSync(DATA_PATH)) return null
return JSON.parse(fs.readFileSync(DATA_PATH, 'utf-8'))
} catch { return null }
// ── Durée JWT ─────────────────────────────────────────────────
const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d'
const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never'
const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY
function signToken(user: User): string {
const payload = { id: user.id, username: user.username, role: user.role }
return NO_EXPIRY
? jwt.sign(payload, JWT_SECRET)
: jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any })
}
function writeAuth(data: AuthData): void {
fs.mkdirSync(path.dirname(DATA_PATH), { recursive: true })
fs.writeFileSync(DATA_PATH, JSON.stringify(data, null, 2))
function setCookieAndRespond(res: Response, user: User): void {
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 = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true })
try {
const user = createUser(username, password, 'admin')
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 = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
logger.info('auth', `Connexion réussie pour "${username}"`)
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
@@ -102,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)
}
+55 -2
View File
@@ -4,10 +4,63 @@ import path from 'path'
export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
&& path.dirname((process as any).execPath) !== process.cwd()
// ── Chargement anticipé du fichier .env pour les binaires bun ─────────────
// Doit s'exécuter avant tout autre module qui lit process.env
// (secret.ts, auth.ts…) car config.ts est la dépendance la plus profonde.
;(() => {
if (!_isBunBinary) return
const binDir = path.dirname((process as any).execPath)
const envPath = path.join(binDir, '.env')
if (!fs.existsSync(envPath)) return
try {
const lines = fs.readFileSync(envPath, 'utf-8').split('\n')
for (const raw of lines) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
const eq = line.indexOf('=')
if (eq === -1) continue
const key = line.slice(0, eq).trim()
let val = line.slice(eq + 1).trim()
// Supprimer les guillemets optionnels
if ((val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1)
}
// Ne pas écraser les variables déjà définies (CLI/système prioritaires)
if (key && !(key in process.env)) process.env[key] = val
}
console.log(`[config] .env chargé depuis ${envPath}`)
} catch (err) {
console.warn(`[config] Impossible de charger .env : ${err}`)
}
})()
export const BASE_DIR = _isBunBinary
? path.dirname((process as any).execPath)
: process.cwd()
export const DATA_DIR = fs.existsSync('/.dockerenv')
const isInContainer = (): boolean => {
// Docker
if (fs.existsSync('/.dockerenv')) return true;
// Podman (crée un fichier /.containerenv)
if (fs.existsSync('/.containerenv')) return true;
// Vérification via cgroup (Docker, Podman, LXC, etc.)
try {
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
if (/docker|podman|containerd|lxc/i.test(cgroup)) return true;
} catch {}
// Vérification via /proc/1/environ (fallback général)
try {
const environ = fs.readFileSync('/proc/1/environ', 'utf8');
if (environ.includes('container=')) return true;
} catch {}
return false;
};
export const DATA_DIR = isInContainer()
? '/config'
: path.join(BASE_DIR, 'config')
: path.join(BASE_DIR, 'config');
+160 -858
View File
File diff suppressed because it is too large. Load diff
+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)
}
+65
View File
@@ -0,0 +1,65 @@
import { DATA_DIR } from '../config.js'
import path from 'path'
import fs from 'fs'
const GITHUB_BASE = process.env.GITHUB_BASE
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
interface CacheEntry<T> { data: T; expiresAt: number }
const _cache = new Map<string, CacheEntry<any>>()
export function cacheGet<T>(key: string): T | null {
const entry = _cache.get(key)
if (!entry) return null
if (Date.now() > entry.expiresAt) { _cache.delete(key); return null }
return entry.data as T
}
export function cacheSet<T>(key: string, data: T): void {
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
}
export function cacheClear(): void { _cache.clear() }
export function cacheSize(): number { return _cache.size }
export async function githubGet(urlPath: string): Promise<any> {
const cached = cacheGet(urlPath)
if (cached) return cached
const res = await fetch(`${GITHUB_BASE}/${urlPath}`)
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
const data = await res.json()
cacheSet(urlPath, data)
return data
}
export async function readAvailable(): Promise<number[]> {
try { return await githubGet('available.json') as number[] }
catch { return [] }
}
export async function readInfohashMap(): Promise<Record<string, string>> {
try { return await githubGet('infohash_map.json') as Record<string, string> }
catch { return {} }
}
export async function readSerieData(serieId: number): Promise<any | null> {
try { return await githubGet(`series/${serieId}.json`) }
catch { return null }
}
export async function loadEnrichedSeriesData(): Promise<any[]> {
const ids = await readAvailable()
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
return allSd.filter(Boolean)
}
export function loadOrganized(): Record<string, Record<string, any>> {
try {
const p = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {}
return {}
}
+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' }
}
}
+99
View File
@@ -0,0 +1,99 @@
import fs from 'fs'
import path from 'path'
import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { logger } from '../logger.js'
import { getGitlabTitle } from '../gitlab-map.js'
import { loadEnrichedSeriesData, loadOrganized } from './github-cache.js'
export const GITLAB_API_NFO = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
export const GITLAB_RAW_NFO = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
export interface NfoUpdateNotif {
serieTitle: string; commitSha: string; commitMsg: string
updatedAt: string; status: 'updated' | 'error'; error?: string
}
export const recentNfoUpdates: NfoUpdateNotif[] = []
const MAX_NFO_NOTIFS = 30
const NFO_COMMITS_PATH = path.join(DATA_DIR, 'nfo_commits.json')
export function loadNfoCommits(): Record<string, string> {
try { if (!fs.existsSync(NFO_COMMITS_PATH)) return {}; return JSON.parse(fs.readFileSync(NFO_COMMITS_PATH, 'utf-8')) }
catch { return {} }
}
export function saveNfoCommits(commits: Record<string, string>) {
fs.mkdirSync(path.dirname(NFO_COMMITS_PATH), { recursive: true })
fs.writeFileSync(NFO_COMMITS_PATH, JSON.stringify(commits, null, 2), 'utf-8')
}
export async function getLatestGitlabCommit(gitlabTitle: string): Promise<{ sha: string; message: string } | null> {
try {
const res = await fetch(`${GITLAB_API_NFO}/commits?path=${encodeURIComponent('pack/' + gitlabTitle)}&per_page=1&ref_name=main`, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) return null
const data: any[] = await res.json()
if (!data.length) return null
return { sha: data[0].id, message: data[0].title ?? data[0].message ?? '' }
} catch { return null }
}
export async function downloadNfoFolder(gitlabTitle: string, destRoot: string): Promise<void> {
let files: any[] = [], page = 1
while (true) {
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
if (!Array.isArray(batch) || batch.length === 0) break
files.push(...batch); if (batch.length < 100) break; page++
}
let downloaded = 0, skipped = 0
for (const entry of files.filter((f: any) => f.type === 'blob')) {
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
const destPath = path.join(destRoot, relativePath)
try {
const res = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) { skipped++; continue }
fs.mkdirSync(path.dirname(destPath), { recursive: true })
fs.writeFileSync(destPath, Buffer.from(await res.arrayBuffer()))
downloaded++
} catch { skipped++ }
}
logger.info('nfo-update', `"${gitlabTitle}" — ${downloaded} fichiers mis à jour, ${skipped} ignorés`)
}
export async function checkNfoUpdates() {
const { mediaPath, nfoSupport } = readSettings()
if (!nfoSupport) return
const organized = loadOrganized()
if (Object.keys(organized).length === 0) return
const seriesData = await loadEnrichedSeriesData()
const knownHashes = new Set(Object.keys(organized))
const serieTitles = new Set<string>()
for (const sd of seriesData) {
const allTorrents = [...(sd.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => s.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => (s.episodes ?? []).flatMap((e: any) => e.torrents ?? []))]
if (allTorrents.some((t: any) => knownHashes.has(t.infohash?.toLowerCase()))) {
const rawTitle = sd.title ?? sd.show_title ?? ''
if (rawTitle) serieTitles.add(rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim())
}
}
if (serieTitles.size === 0) return
const commits = loadNfoCommits()
let hasChanges = false
for (const serieTitle of serieTitles) {
const gitlabTitle = getGitlabTitle(serieTitle)
const latest = await getLatestGitlabCommit(gitlabTitle)
if (!latest || commits[gitlabTitle] === latest.sha) continue
logger.info('nfo-update', `MAJ NFO détectée pour "${gitlabTitle}" (${latest.sha.slice(0, 8)})`)
try {
await downloadNfoFolder(gitlabTitle, path.join(mediaPath, serieTitle))
commits[gitlabTitle] = latest.sha
hasChanges = true
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'updated' })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('nfo-update', `Échec MAJ NFO "${gitlabTitle}" : ${msg}`)
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'error', error: msg })
}
if (recentNfoUpdates.length > MAX_NFO_NOTIFS) recentNfoUpdates.pop()
}
if (hasChanges) saveNfoCommits(commits)
}
+12
View File
@@ -0,0 +1,12 @@
export interface OrganizeNotif {
hash: string; name: string; done: number; skipped: number; errors: number
errorFiles: { file: string; error: string }[]; at: string
}
export const recentOrganized: OrganizeNotif[] = []
const MAX_NOTIFS = 20
export function pushNotif(n: OrganizeNotif) {
recentOrganized.unshift(n)
if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop()
}
+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 }
}
+186
View File
@@ -0,0 +1,186 @@
import path from 'path'
const FANKAI_API = 'https://metadata.fankai.fr'
export async function fankaiGet(endpoint: string): Promise<any> {
const res = await fetch(`${FANKAI_API}${endpoint}`)
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
return res.json()
}
export function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
if (!url) return null
const stripped = url.replace(/^https?:\/\//, '')
return `https://wsrv.nl/?url=${stripped}&w=${w}&q=${q}`
}
export function normalizeSerie(serie: any): any {
return { ...serie, poster_image: imgProxy(serie.poster_image ?? serie.images?.poster ?? null, 300), fanart_image: imgProxy(serie.fanart_image ?? serie.images?.fanart ?? null, 1280, 60) }
}
export function normalizeSeason(season: any): any {
return { ...season, poster_image: imgProxy(season.poster_image ?? season.images?.poster ?? null, 300) }
}
export function normalizeEpisode(ep: any): any {
return { ...ep, thumb_image: imgProxy(ep.thumb_image ?? ep.thumbnail ?? null, 400) }
}
export function extractTorrentsFromSerieData(sd: any): any[] {
const result: any[] = []
if (!sd) return result
for (const t of sd.torrents ?? []) {
result.push({ ...t, raw: t.title, type: 'pack_integrale', serie_id: sd.id, serie_title: sd.title, _serieLevel: true, _serieData: sd, _torrentIdx: (sd.torrents ?? []).indexOf(t) })
}
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) {
result.push({ ...t, raw: t.title, type: 'pack_saison', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, _seasonLevel: true, _season: season, _torrentIdx: (season.torrents ?? []).indexOf(t) })
}
for (const ep of season.episodes ?? []) {
for (const t of ep.torrents ?? []) {
result.push({ ...t, raw: t.title, type: 'episode', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, episode_id: ep.id, episode_number: ep.episode_number, _episodeLevel: true, _episode: ep, _torrentIdx: (ep.torrents ?? []).indexOf(t) })
}
}
}
return result
}
/**
* Résout les noms d'un épisode en préférant les valeurs du path entry
* correspondant au hash donné (spécifique au torrent/encoding),
* avec fallback sur les champs de niveau épisode.
*
* Utilisation :
* const { formatted_name, nfo_filename, original_filename } = resolveEpNaming(ep, hash)
*/
export function resolveEpNaming(ep: any, hash?: string | null): {
formatted_name : string | null
nfo_filename : string | null
original_filename: string | null
} {
const h = hash?.toLowerCase() ?? null
const pathEntry = h
? (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === h)
: null
return {
formatted_name : pathEntry?.formatted_name ?? ep.formatted_name ?? null,
nfo_filename : pathEntry?.nfo_filename ?? ep.nfo_filename ?? null,
original_filename: pathEntry?.original_filename ?? ep.original_filename ?? null,
}
}
/**
* Calcule le nom attendu d'un fichier épisode selon le mode (NFO ou formaté).
* Source unique de vérité partagée entre organized-summary, organized/:id, rename-episode et rename-all.
*/
export function computeExpectedName(
ep : any,
orgEntry: any,
hash : string | null,
nfoSupport: boolean,
): { currentName: string; expectedName: string; needsRename: boolean } {
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, hash)
const expectedName = nfoSupport
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
return { currentName, expectedName, needsRename: expectedName !== currentName }
}
/**
* Dédoublonne une liste d'épisodes par episode_number.
* Quand plusieurs variantes existent (ex. x264 + x265), on garde la première
* comme épisode principal et on fusionne les torrents des autres dans sa liste.
*/
export function deduplicateEpisodes(episodes: any[]): any[] {
const map = new Map<number, any>()
for (const ep of episodes) {
const key = ep.episode_number
if (!map.has(key)) {
map.set(key, { ...ep, torrents: [...(ep.torrents ?? [])] })
} else {
const primary = map.get(key)!
const seenHashes = new Set(primary.torrents.map((t: any) => t.infohash).filter(Boolean))
for (const t of ep.torrents ?? []) {
if (!t.infohash || !seenHashes.has(t.infohash)) {
primary.torrents.push(t)
if (t.infohash) seenHashes.add(t.infohash)
}
}
primary.torrent = primary.torrents[0] ?? null
primary.available = primary.available || ep.available
primary.organized = primary.organized || ep.organized
}
}
return Array.from(map.values())
}
export function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] {
const resolved: any[] = []
const h = hash.toLowerCase()
for (const season of sd.seasons ?? []) {
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
for (const ep of season.episodes ?? []) {
const paths: any[] = ep.paths ?? []
let filePath: string | null = null
for (const p of paths) {
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
else if (p?.infohash?.toLowerCase() === h) { filePath = p.path.replace(/\\/g, '/'); break }
}
if (!filePath) continue
const filename = filePath.split('/').pop() ?? filePath
resolved.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, season_id: season.id, filename, original_filename: filename })
}
}
return resolved
}
export function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, any>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
if (!serieData) return 'none'
const allTorrents = extractTorrentsFromSerieData(serieData)
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
// Dédoublonnage : épisodes uniques par (season_number, episode_number)
// pour éviter de compter x264 + x265 comme deux épisodes distincts
const uniqueEpKeys = new Set<string>()
const epIdToKey = new Map<number, string>()
for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) {
const key = `${season.season_number}:${ep.episode_number}`
uniqueEpKeys.add(key)
epIdToKey.set(ep.id, key)
}
}
const organizedEpKeys = new Set<string>()
for (const t of allTorrents) {
const hash = t.infohash?.toLowerCase()
if (!hash) continue
const orgFiles = organized[hash] ?? {}
if (Object.keys(orgFiles).length === 0) continue
for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) {
const isOrganized = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
if (isOrganized) {
const key = epIdToKey.get(ep.episode_id)
if (key) organizedEpKeys.add(key)
}
}
}
const manualOrg = organized['manual'] ?? {}
for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (manualOrg[String(ep.id)]) {
const key = epIdToKey.get(ep.id)
if (key) organizedEpKeys.add(key)
}
}
}
if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none'
if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete'
return 'partial'
}
+105 -26
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>>
@@ -113,13 +114,15 @@ function buildFileMap(
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
for (const ep of season.episodes ?? []) {
const paths: any[] = ep.paths ?? []
let matchedPath: string | null = null
let matchedPath : string | null = null
let matchedPathObj: any = null
for (const p of paths) {
if (typeof p === 'string') {
if (!matchedPath) matchedPath = p.replace(/\\/g, '/')
} else if (p?.infohash?.toLowerCase() === h) {
matchedPath = p.path.replace(/\\/g, '/')
matchedPath = p.path.replace(/\\/g, '/')
matchedPathObj = p
break
}
}
@@ -127,8 +130,12 @@ function buildFileMap(
if (!matchedPath) continue
const filename = matchedPath.split('/').pop() ?? matchedPath
const fmtName = ep.formatted_name?.trim()
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
// Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
const fmtName = rawFmt?.trim()
? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
: null
if (!nfoSupport && !fmtName) {
@@ -137,8 +144,8 @@ function buildFileMap(
const srcExt = path.extname(filename)
const resolvedName = nfoSupport
? (ep.nfo_filename ?? ep.original_filename ?? filename)
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
? (rawNfo ?? rawOriginal ?? filename)
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
const destName = swapExtension(resolvedName, srcExt)
map.set(filename, {
@@ -247,6 +254,26 @@ function seasonFolder(n: number): string {
return n === 0 ? 'Specials' : `Saison ${n}`
}
/**
* Supprime un fichier avec retry en cas d'EBUSY (Windows : fichier encore ouvert par qBit/autre).
* 5 tentatives × 500 ms = 2,5 s max avant de remonter l'erreur.
*/
async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
await fsp.unlink(filePath)
return
} catch (err: any) {
if (err?.code === 'EBUSY' && attempt < maxAttempts - 1) {
warn(`EBUSY sur "${path.basename(filePath)}" — retry ${attempt + 1}/${maxAttempts - 1}`)
await new Promise(r => setTimeout(r, 500))
} else {
throw err
}
}
}
}
function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true }
catch (err) {
@@ -264,7 +291,13 @@ function sanitizeDirName(name: string): string {
}
// ─── Organise un torrent ──────────────────────────────────────
async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) {
async function organizeTorrent(
hash : string,
name : string,
savePath : string,
seriesData : any[],
completedFileNames: Set<string> | null = null,
) {
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
@@ -289,20 +322,29 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
const ep = torrent._episode
const season = torrent._season
const paths: any[] = ep.paths ?? []
let filePath: string | null = null
let filePath : string | null = null
let matchedPathObj: any = null
for (const p of paths) {
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { filePath = p.path.replace(/\\/g, '/'); break }
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) {
filePath = p.path.replace(/\\/g, '/')
matchedPathObj = p
break
}
}
const filename = filePath?.split('/').pop() ?? name
const fmtName = ep.formatted_name?.trim()
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
// Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
const fmtName = rawFmt?.trim()
? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
: null
const srcExt = path.extname(filename)
const resolvedName = nfoSupport
? (ep.nfo_filename ?? ep.original_filename ?? filename)
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
? (rawNfo ?? rawOriginal ?? filename)
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
const destName = swapExtension(resolvedName, srcExt)
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
@@ -310,6 +352,12 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 }
// Vérification de complétion pour épisode unique
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
warn(`Skip (incomplet / priorité 0) : ${filename}`)
return { ...result, total: 1, skipped: 1 }
}
const candidates = filePath ? [
path.join(savePath, filePath),
path.join(completePath, filePath),
@@ -324,8 +372,8 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) {
error(`Fichier source introuvable : ${filename}`)
return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] }
error(`Fichier source introuvable : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
return { ...result, total: 1, errors: [{ file: filename, error: `Source introuvable (chemins essayés : ${candidates.join(', ')})` }] }
}
if (!fs.existsSync(dest)) {
@@ -339,9 +387,9 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
markOrganized(hash, ep.id, {
at: new Date().toISOString(), season: season.season_number ?? 1,
episode: ep.episode_number, episode_id: ep.id,
src_filename: filename, dest_filename: destName, dest_path: dest,
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir,
})
await fsp.unlink(src)
await unlinkWithRetry(src)
log(`${destName} → Saison ${season.season_number}`)
return { ...result, total: 1, done: 1 }
}
@@ -350,7 +398,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
markOrganized(hash, ep.id, {
at: new Date().toISOString(), season: season.season_number ?? 1,
episode: ep.episode_number, episode_id: ep.id,
src_filename: filename, dest_filename: destName, dest_path: dest,
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir,
})
log(`${destName} → Saison ${season.season_number}`)
return { ...result, total: 1, done: 1 }
@@ -370,6 +418,14 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) {
if (isOrganized(hash, episode_id)) { result.skipped++; continue }
// Vérification de complétion : si on a les infos de progression par fichier,
// on skip les fichiers pas encore à 100% (ex: priorité 0 ou en cours de téléchargement).
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
warn(`Skip (incomplet / priorité 0) : ${filename}`)
result.skipped++
continue
}
const candidates = [
path.join(savePath, fullPath),
path.join(completePath, fullPath),
@@ -380,8 +436,9 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) {
error(`Fichier source introuvable : ${filename}`)
result.errors.push({ file: filename, error: 'Source introuvable' })
// Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0).
warn(`Skip (fichier absent) : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
result.skipped++
continue
}
@@ -392,7 +449,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
})
result.skipped++
continue
@@ -407,16 +464,16 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
})
} else {
await fsp.copyFile(src, dest)
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
})
await fsp.unlink(src)
await unlinkWithRetry(src)
}
result.done++
debug(`${nfo_filename} → Saison ${season_number}`)
@@ -462,8 +519,30 @@ parentPort?.on('message', async (msg: any) => {
}
try {
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
// Construire l'ensemble des fichiers 100% complets à partir des infos de progression.
// On ne filtre QUE si le client fournit des noms de fichiers (qBittorrent, Transmission).
// Si aucun fichier n'a de nom (rtorrent…), on passe null → pas de filtrage.
let completedFileNames: Set<string> | null = null
if (Array.isArray(t.files) && t.files.length > 0) {
// Vérifier qu'au moins un fichier a un nom non vide
const hasNames = t.files.some((f: any) => f.name && String(f.name).trim())
if (hasNames) {
completedFileNames = new Set(
t.files
.filter((f: any) => (f.progress ?? 0) >= 1 && f.name && String(f.name).trim())
.map((f: any) => {
// f.name est un chemin relatif ex: "Pack S1/Episode1.mkv"
// on ne garde que le basename pour comparer au filename du fileMap
const parts = String(f.name).replace(/\\/g, '/').split('/')
return (parts[parts.length - 1] ?? '').toLowerCase()
})
.filter(Boolean)
)
}
}
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, serieId: serieData.id ?? null, ...result })
} catch (err) {
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
}
+210 -10
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>>
@@ -100,10 +101,17 @@ export async function scanMediaPath(
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
if (!srcFilename) continue
idx(srcFilename, hash)
if (ep.nfo_filename) idx(ep.nfo_filename, hash, ep.nfo_filename)
// nfo_filename et formatted_name du path en priorité sur ceux de l'épisode
const pathNfo = p.nfo_filename ?? ep.nfo_filename
if (pathNfo) idx(pathNfo, hash, pathNfo)
const pathFmt = p.formatted_name ?? ep.formatted_name
if (pathFmt?.trim()) {
const fmtBase = pathFmt.replace(/[<>:"/\\|?*]/g, '').trim()
idx(fmtBase, hash, fmtBase)
}
}
// 2. original_filename, nfo_filename et formatted_name — indexés même si paths est vide
// 2. original_filename, nfo_filename et formatted_name épisode — fallback
const fallbackHash = ep.paths?.[0]?.infohash?.toLowerCase() ?? 'manual'
if (ep.original_filename) idx(ep.original_filename, fallbackHash, ep.original_filename)
if (ep.nfo_filename) idx(ep.nfo_filename, fallbackHash, ep.nfo_filename)
@@ -160,10 +168,6 @@ export async function scanMediaPath(
}
const { hash, srcFilename, episodeId, season, episode } = match
// Le destFilename prend l'extension réelle du fichier sur le disque
const realExt = path.extname(entry.name)
const destBase = match.destFilename.replace(/\.[^.]+$/, '')
const destFilename = destBase + realExt
presentFiles.add(`${hash}:${episodeId}`)
if (organized[hash]?.[String(episodeId)]) continue
@@ -175,7 +179,7 @@ export async function scanMediaPath(
episode,
episode_id : episodeId,
src_filename : srcFilename,
dest_filename: destFilename,
dest_filename: entry.name, // vrai nom sur disque
dest_path : full,
}
result.added++
@@ -200,11 +204,28 @@ export async function scanMediaPath(
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
if (result.added > 0 || removed > 0) {
// Désimport automatique des fichiers manquants sur le disque
const { autoUnimportMissing } = readSettings() as any
let autoRemoved = 0
if (autoUnimportMissing) {
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
const destPath = entry?.dest_path
if (destPath && !fs.existsSync(destPath)) {
delete (organized[hash] as any)[episodeId]
autoRemoved++
logger.info('organize', `Désimport auto : fichier introuvable "${destPath}"`)
}
}
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
}
if (result.added > 0 || removed > 0 || autoRemoved > 0) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
}
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
return result
}
@@ -214,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) {
@@ -289,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,
@@ -314,6 +336,184 @@ export async function autoOrganizeAll(
worker.postMessage({ type: 'run', torrents, seriesData })
}
// ── Migration des IDs épisodes ────────────────────────────────
//
// Les IDs d'épisodes, saisons et séries peuvent changer entre deux passages
// du scraper. On utilise (infohash, season_number, episode_number) comme clé
// stable pour retrouver le nouvel ID et mettre à jour organized.json.
export async function migrateOrganizedEpisodeIds(
organizedPath: string,
seriesData : any[]
): Promise<{ updated: number; orphaned: number }> {
// 1. Construire les tables de lookup depuis le catalogue actuel
// hashEpMap : infohash → Map<"S:E", new_episode_id>
const hashEpMap = new Map<string, Map<string, number>>()
const validIds = new Set<number>()
for (const sd of seriesData) {
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
validIds.add(ep.id)
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const h = p.infohash.toLowerCase()
const key = `${season.season_number}:${ep.episode_number}`
if (!hashEpMap.has(h)) hashEpMap.set(h, new Map())
hashEpMap.get(h)!.set(key, ep.id)
}
}
}
}
// 2. Charger organized.json
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch { return { updated: 0, orphaned: 0 } }
let updated = 0
let orphaned = 0
let changed = false
for (const [hash, episodes] of Object.entries(organized)) {
if (hash === 'manual') continue
const epMap = hashEpMap.get(hash.toLowerCase())
const newEpisodes: Record<string, any> = {}
for (const [oldIdStr, entry] of Object.entries(episodes as Record<string, any>)) {
const oldId = Number(oldIdStr)
if (validIds.has(oldId)) {
// ID encore valide, aucun changement
newEpisodes[oldIdStr] = entry
continue
}
// ID orphelin — chercher le nouvel ID via (hash, saison, épisode)
const key = `${entry.season}:${entry.episode}`
const newId = epMap?.get(key)
if (newId && newId !== oldId) {
// Vérifier qu'on n'écrase pas une entrée déjà présente avec le nouvel ID
if (newEpisodes[String(newId)] || (episodes as any)[String(newId)]) {
// Le nouvel ID est déjà présent → l'ancien est un orphelin inutile, on le supprime simplement
changed = true
logger.info('organize', `Migration ID : ep ${oldId} orphelin supprimé (${newId} déjà présent) (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
} else {
newEpisodes[String(newId)] = { ...entry, episode_id: newId }
updated++
changed = true
logger.info('organize', `Migration ID : ep ${oldId} → ${newId} (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
}
} else {
// Introuvable — on conserve pour ne pas perdre l'entrée
orphaned++
newEpisodes[oldIdStr] = entry
logger.warn('organize', `Migration ID : ep ${oldId} introuvable dans le catalogue (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
}
}
organized[hash] = newEpisodes
// Nettoyer les hash vides
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
if (changed) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`)
} else {
logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`)
}
return { updated, orphaned }
}
// ── Sync des noms de fichiers après mise à jour du scraper ────
export async function syncFilenameChanges(
seriesData : any[],
organizedPath: string,
): Promise<{ renamed: number; errors: number }> {
const { nfoSupport } = readSettings()
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch { return { renamed: 0, errors: 0 } }
let renamed = 0
let errors = 0
let changed = false
for (const sd of seriesData) {
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
let orgHash: string | null = null
for (const [hash, eps] of Object.entries(organized)) {
if ((eps as any)[String(ep.id)]) {
orgEntry = (eps as any)[String(ep.id)]
orgHash = hash
break
}
}
if (!orgEntry || !orgHash) continue
const currentName = orgEntry.dest_path
? path.basename(orgEntry.dest_path)
: orgEntry.dest_filename
const srcExt = path.extname(currentName)
// Priorité aux valeurs du path entry correspondant au hash organisé
const matchedPathEntry = (ep.paths ?? []).find((p: any) =>
typeof p === 'object' && p.infohash?.toLowerCase() === orgHash
)
const rawFmt = matchedPathEntry?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathEntry?.nfo_filename ?? ep.nfo_filename
const expectedName: string = nfoSupport
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
if (expectedName === currentName) continue
const oldPath = orgEntry.dest_path
if (!oldPath || !fs.existsSync(oldPath)) continue
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
if (fs.existsSync(newPath)) {
logger.warn('organize', `Sync rename : "${expectedName}" existe déjà — skip`)
continue
}
fs.renameSync(oldPath, newPath)
;(organized[orgHash] as any)[String(ep.id)] = {
...orgEntry,
dest_filename: expectedName,
dest_path : newPath,
at : new Date().toISOString(),
}
renamed++
changed = true
logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`)
} catch (err) {
errors++
logger.error('organize', `Sync rename échoué pour ep ${ep.id} : ${err instanceof Error ? err.message : err}`)
}
}
}
}
if (changed)
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
if (renamed > 0 || errors > 0)
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
return { renamed, errors }
}
// ── Import manuel ─────────────────────────────────────────────
export async function organizeTorrent(
+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
}
+81
View File
@@ -0,0 +1,81 @@
import { Router } from 'express'
import path from 'path'
import fs from 'fs'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { dispatchDownload, dispatchList, dispatchRemove } from '../torrent-clients/index.js'
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
import { extractTorrentsFromSerieData, buildResolvedEpisodes } from '../lib/serie-helpers.js'
import { recentOrganized } from '../lib/notifs.js'
const router = Router()
router.post('/download', requireAuth, async (req, res) => {
// Préférer le fichier .torrent au magnet : métadonnées immédiates, pas de dépendance DHT/trackers
const torrent_url: string | null = req.body.torrent_url ?? null
const magnet : string | null = req.body.magnet ?? null
const url = torrent_url ?? magnet
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
const file_index: number | null = req.body.file_index != null ? Number(req.body.file_index) : null
const file_path : string | null = req.body.file_path ?? null
const infohash : string | null = req.body.infohash ?? null
logger.info('api', `Téléchargement demandé via ${torrent_url ? '.torrent' : 'magnet'}${file_path ? ` (${file_path})` : ''}`)
const results = await dispatchDownload(url, { file_index, file_path, infohash, magnet })
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
})
router.delete('/torrent/:hash', requireAuth, async (req, res) => {
const hash = String(req.params.hash)
const deleteFiles = req.query.deleteFiles === 'true'
if (!hash) { res.status(400).json({ error: 'hash requis' }); return }
try {
const result = await dispatchRemove(hash, deleteFiles)
res.json(result)
} catch (err) {
logger.error('api', `Suppression torrent échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
router.get('/downloads', requireAuth, async (_req, res) => {
try {
const { category } = readSettings()
const infohashMap = await readInfohashMap()
const torrents = await dispatchList(category ?? 'fankai', infohashMap)
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const enriched = await Promise.all(torrents.map(async (t: any) => {
const orgFiles = organized[t.hash] ?? {}
let totalFiles = 1
let serieName: string | undefined
let episodes: any[] = []
try {
const availableIds = await readAvailable()
for (const id of availableIds) {
const sd = await readSerieData(id)
if (!sd) continue
const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
if (match) {
const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
totalFiles = resolved.length || 1
serieName = sd.title ?? sd.show_title
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 ?? [], serieName, episodes }
}))
res.json(enriched)
} catch (err) {
logger.error('api', `GET /api/downloads échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
export default router
+509
View File
@@ -0,0 +1,509 @@
import { Router } from 'express'
import path from 'path'
import fs from 'fs'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { getGitlabTitle } from '../gitlab-map.js'
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js'
import { resolveEpNaming, computeExpectedName } from '../lib/serie-helpers.js'
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
import { dispatchRemove } from '../torrent-clients/index.js'
import { readRequests, deleteRequest } from '../requests.js'
const router = Router()
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
function loadOrganizedJson(): Record<string, Record<string, any>> {
try { if (fs.existsSync(ORGANIZED_PATH)) return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {}
return {}
}
function saveOrganizedJson(data: Record<string, Record<string, any>>) {
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
// ── Import manuel ──────────────────────────────────────────────
router.post('/manual-import', requireAuth, async (req, res) => {
const { serie_id, items } = req.body
if (!serie_id || !Array.isArray(items) || items.length === 0) {
res.status(400).json({ error: 'serie_id et items requis' }); return
}
const { mediaPath, organizeMode, nfoSupport } = readSettings()
const organized = loadOrganizedJson()
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()
const episodeIndex = new Map<number, { ep: any; season: any }>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
episodeIndex.set(ep.id, { ep, season })
}
}
const done: number[] = []
const errors: { file: string; error: string }[] = []
for (const item of items) {
const { file_path, episode_id, hash } = item
if (!file_path || !episode_id) { errors.push({ file: file_path ?? '?', error: 'Paramètres manquants' }); continue }
const found = episodeIndex.get(Number(episode_id))
if (!found) { errors.push({ file: file_path, error: `Épisode ${episode_id} introuvable` }); continue }
const { ep, season } = found
const srcFilename = path.basename(file_path)
const srcExt = path.extname(srcFilename)
const { formatted_name: rawFmt, nfo_filename: rawNfo, original_filename: rawOrig } = resolveEpNaming(ep, hash)
const fmtName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null
const resolvedName = nfoSupport
? (rawNfo ?? rawOrig ?? srcFilename)
: (fmtName ?? rawNfo ?? rawOrig ?? srcFilename)
const resolvedExt = path.extname(resolvedName)
const destFilename = resolvedExt && resolvedExt !== srcExt
? resolvedName.slice(0, -resolvedExt.length) + srcExt
: resolvedName
const seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}`
const destDir = path.join(mediaPath, serieTitle, seasonFolder)
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}"`)
} else if (!isInSeriePath && !fs.existsSync(destPath)) {
if (organizeMode === 'hardlink') {
try { fs.linkSync(file_path, destPath) }
catch { await fs.promises.copyFile(file_path, destPath) }
} else if (organizeMode === 'move') {
try { fs.renameSync(file_path, destPath) }
catch { await fs.promises.copyFile(file_path, destPath); await fs.promises.unlink(file_path) }
} else {
await fs.promises.copyFile(file_path, destPath)
}
logger.info('api', `Import manuel : "${srcFilename}" → "${destPath}"`)
}
} else {
logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`)
}
const torrentHash = String(hash || '').toLowerCase() || 'manual'
if (!organized[torrentHash]) organized[torrentHash] = {}
organized[torrentHash][String(episode_id)] = {
at: new Date().toISOString(), season: season.season_number,
episode: ep.episode_number, episode_id: ep.id,
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
}
done.push(episode_id)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
errors.push({ file: srcFilename, error: msg })
logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
}
}
saveOrganizedJson(organized)
if (nfoSupport && done.length > 0) {
const gitlabTitle = getGitlabTitle(serieTitle)
;(async () => {
try {
let files: any[] = [], page = 1
while (true) {
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
if (!Array.isArray(batch) || batch.length === 0) break
files.push(...batch); if (batch.length < 100) break; page++
}
for (const entry of files.filter((f: any) => f.type === 'blob')) {
const rel = entry.path.replace(`pack/${gitlabTitle}/`, '')
const dest = path.join(mediaPath, serieTitle, rel)
if (fs.existsSync(dest)) continue
const raw = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${rel.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
if (!raw.ok) continue
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.writeFileSync(dest, Buffer.from(await raw.arrayBuffer()))
}
logger.info('api', `NFO téléchargés pour "${gitlabTitle}" (import manuel)`)
} catch (err) {
logger.warn('api', `Échec téléchargement NFO pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
}
})()
}
res.json({ ok: true, done: done.length, errors })
})
// ── Récap organisés d'une série ────────────────────────────────
router.get('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = Number(req.params.serieId)
try {
const sd = await readSerieData(serieId)
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const organized = loadOrganizedJson()
const { nfoSupport } = readSettings()
const result: Record<string, any> = {}
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let entry: any = null
let entryHash = 'manual'
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const h = p.infohash.toLowerCase()
const e = (organized[h] ?? {})[String(ep.id)]
if (e) { entry = e; entryHash = h; break }
}
if (!entry && organized['manual']?.[String(ep.id)]) {
entry = organized['manual'][String(ep.id)]
}
if (!entry) continue
const { needsRename } = computeExpectedName(ep, entry, entryHash, nfoSupport)
result[String(ep.id)] = { ...entry, hash: entryHash, needs_rename: needsRename }
}
}
res.json(result)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Rename épisode ─────────────────────────────────────────────
router.post('/rename-episode', requireAuth, async (req, res) => {
const { serie_id, episode_id, torrent_hash } = req.body
if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return }
const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serie_id))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
let foundEp: any = null
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (ep.id === Number(episode_id)) { foundEp = { ep, season }; break }
}
if (foundEp) break
}
if (!foundEp) { res.status(404).json({ error: 'Épisode introuvable' }); return }
const { ep, season } = foundEp
const hash = torrent_hash?.toLowerCase() ?? 'manual'
let orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)]
let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
if (!orgEntry) {
for (const [h, eps] of Object.entries(organized)) {
if (eps[String(episode_id)]) { orgEntry = eps[String(episode_id)]; entryHash = h; break }
}
}
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
const { currentName, expectedName: newName, needsRename } = computeExpectedName(ep, orgEntry, entryHash, nfoSupport)
if (!needsRename) { res.json({ ok: true, renamed: false, message: 'Nom déjà correct' }); return }
const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), newName)
try {
if (!fs.existsSync(oldPath)) throw new Error('Fichier source introuvable sur le disque')
if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
fs.renameSync(oldPath, newPath)
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() }
saveOrganizedJson(organized)
logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Rename échoué pour ep ${episode_id} : ${msg}`)
res.status(500).json({ error: msg })
}
})
// ── Désimport série complète ───────────────────────────────────
router.delete('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const episodeIds = new Set<string>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id))
}
let removed = 0
const errors: string[] = []
const emptyHashes: string[] = []
let serieFolder: string | null = null
for (const [hash, episodes] of Object.entries(organized)) {
for (const epId of Object.keys(episodes)) {
if (!episodeIds.has(epId)) continue
const entry = episodes[epId]
if (!serieFolder && entry) {
const dir = entry.dest_dir ?? path.dirname(entry.dest_path)
serieFolder = path.dirname(dir)
}
if (deleteFile && entry?.dest_path && fs.existsSync(entry.dest_path)) {
try { fs.unlinkSync(entry.dest_path) } catch { errors.push(entry.dest_path) }
}
delete organized[hash][epId]
removed++
}
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
}
saveOrganizedJson(organized)
if (deleteFile) {
for (const hash of emptyHashes) {
if (hash === 'manual') continue
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
// Supprimer le dossier de la série s'il existe encore
if (serieFolder && fs.existsSync(serieFolder)) {
try {
fs.rmSync(serieFolder, { recursive: true, force: true })
logger.info('api', `Dossier série supprimé : ${serieFolder}`)
} catch (err) {
logger.warn('api', `Impossible de supprimer le dossier série "${serieFolder}" : ${err instanceof Error ? err.message : err}`)
}
}
}
// Supprimer les demandes "disponible" liées à cette série
const completedRequests = readRequests().filter(r => r.serieId === Number(serieId) && r.status === 'completed')
for (const r of completedRequests) {
try { deleteRequest(r.id) } catch {}
}
if (completedRequests.length > 0)
logger.info('api', `Désimport série ${serieId} — ${completedRequests.length} demande(s) "disponible" supprimée(s)`)
logger.info('api', `Désimport série ${serieId} — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
res.json({ ok: true, removed, errors })
})
// ── Désimport saison ───────────────────────────────────────────
router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
const seasonId = Number(req.params.seasonId)
const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const season = sd.seasons?.find((s: any) => s.id === seasonId)
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.id)) ?? [])
let removed = 0
const errors: string[] = []
const emptyHashes: string[] = []
for (const [hash, episodes] of Object.entries(organized)) {
for (const epId of Object.keys(episodes)) {
if (!episodeIds.has(epId)) continue
const entry = episodes[epId]
if (deleteFile && entry?.dest_path && fs.existsSync(entry.dest_path)) {
try { fs.unlinkSync(entry.dest_path) } catch { errors.push(entry.dest_path) }
}
delete organized[hash][epId]
removed++
}
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
}
saveOrganizedJson(organized)
if (deleteFile) {
for (const hash of emptyHashes) {
if (hash === 'manual') continue
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
}
logger.info('api', `Désimport saison ${seasonId} (série ${serieId}) — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
res.json({ ok: true, removed, errors })
})
// ── Désimport épisode ──────────────────────────────────────────
router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
const episodeId = String(req.params.episodeId)
const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
let foundHash: string | null = null
let foundEntry: any = null
for (const [hash, episodes] of Object.entries(organized)) {
if (episodes[episodeId]) { foundHash = hash; foundEntry = episodes[episodeId]; break }
}
if (!foundHash || !foundEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
try {
if (deleteFile && foundEntry.dest_path && fs.existsSync(foundEntry.dest_path)) {
fs.unlinkSync(foundEntry.dest_path)
logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`)
}
delete organized[foundHash][episodeId]
const torrentEmpty = Object.keys(organized[foundHash]).length === 0
if (torrentEmpty) delete organized[foundHash]
saveOrganizedJson(organized)
// Si plus aucun épisode de ce torrent n'est importé → retirer le torrent du client
if (deleteFile && torrentEmpty && foundHash !== 'manual') {
dispatchRemove(foundHash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${foundHash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
logger.info('api', `Désimport ep ${episodeId}${deleteFile && torrentEmpty ? ` + torrent ${foundHash.slice(0, 8)}… retiré du client` : ''}`)
res.json({ ok: true })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Désimport échoué : ${msg}`)
res.status(500).json({ error: msg })
}
})
// ── Récap global des imports ───────────────────────────────────
router.get('/organized-summary', requireAuth, async (_req, res) => {
try {
const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const seriesData = await loadEnrichedSeriesData()
const result: any[] = []
for (const sd of seriesData) {
const rawTitle = sd.title ?? sd.show_title ?? ''
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
const episodes: any[] = []
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
let orgHash: string | null = null
// Même stratégie que organized/:serieId : on cherche via ep.paths d'abord
// pour garantir le bon hash → bon formatted_name dans resolveEpNaming
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const h = p.infohash.toLowerCase()
const e = (organized[h] ?? {})[String(ep.id)]
if (e) { orgEntry = e; orgHash = h; break }
}
if (!orgEntry && organized['manual']?.[String(ep.id)]) {
orgEntry = organized['manual'][String(ep.id)]; orgHash = 'manual'
}
if (!orgEntry) continue
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
episodes.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, title: ep.title, current_name: currentName, expected_name: expectedName, dest_path: orgEntry.dest_path, torrent_hash: orgHash, needs_rename: needsRename, file_exists: fileExists })
}
}
if (episodes.length > 0) {
result.push({ serie_id: sd.id, serie_title: rawTitle, serie_title_clean: serieTitle, total: episodes.length, needs_rename: episodes.filter(e => e.needs_rename).length, episodes })
}
}
res.json({ series: result, nfo_support: nfoSupport })
} catch (err) {
logger.error('api', `organized-summary échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Rename en masse ────────────────────────────────────────────
router.post('/rename-all', requireAuth, async (req, res) => {
const { serie_id } = req.body
const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const seriesData = await loadEnrichedSeriesData()
const done: number[] = []
const errors: { episode_id: number; error: string }[] = []
for (const sd of seriesData) {
if (serie_id && sd.id !== Number(serie_id)) continue
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
let orgHash: string | null = null
for (const [hash, eps] of Object.entries(organized)) {
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
}
if (!orgEntry || !orgHash) continue
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
if (!needsRename) continue
const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
if (!fs.existsSync(oldPath)) {
const msg = `Fichier introuvable : "${oldPath}"`
logger.error('api', `Rename masse ep ${ep.id} — ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue
}
if (fs.existsSync(newPath)) {
const msg = `Fichier existant : "${expectedName}"`
logger.warn('api', `Rename masse ep ${ep.id} — ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue
}
fs.renameSync(oldPath, newPath)
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() }
done.push(ep.id)
logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Rename masse ep ${ep.id} — exception : ${msg}`)
errors.push({ episode_id: ep.id, error: msg })
}
}
}
}
saveOrganizedJson(organized)
res.json({ ok: true, done: done.length, errors })
})
// ── Purge NFO / images des dossiers série ─────────────────────
router.post('/purge-nfo', requireAuth, (req, res) => {
const { mediaPath } = readSettings()
if (!mediaPath || !fs.existsSync(mediaPath)) {
res.status(400).json({ error: 'Chemin médiathèque non configuré ou introuvable' }); return
}
const NFO_EXTS = new Set(['.nfo', '.png', '.jpg', '.jpeg', '.tbn', '.xml'])
let deleted = 0
const errors: string[] = []
function walk(dir: string) {
let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(full)
} else if (entry.isFile() && NFO_EXTS.has(path.extname(entry.name).toLowerCase())) {
try {
fs.unlinkSync(full)
deleted++
logger.debug('api', `Purge NFO : supprimé "${full}"`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Purge NFO : impossible de supprimer "${full}" : ${msg}`)
errors.push(full)
}
}
}
}
walk(mediaPath)
logger.info('api', `Purge NFO — ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`)
res.json({ ok: true, deleted, errors })
})
export default router
+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
+18
View File
@@ -0,0 +1,18 @@
import { Router } from 'express'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { checkNfoUpdates, recentNfoUpdates } from '../lib/nfo.js'
const router = Router()
router.get('/nfo-updates/recent', requireAuth, (_req, res) => {
res.json(recentNfoUpdates)
})
router.post('/nfo-updates/check', requireAuth, async (_req, res) => {
logger.info('nfo-update', 'Vérification manuelle des MAJ NFO lancée')
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif manuelle échouée : ${err instanceof Error ? err.message : err}`))
res.json({ ok: true, message: 'Vérification lancée en arrière-plan' })
})
export default router
+50
View File
@@ -0,0 +1,50 @@
import { Router } from 'express'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
import { recentOrganized, pushNotif } from '../lib/notifs.js'
import { DATA_DIR } from '../config.js'
import path from 'path'
const router = Router()
router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
try {
const seriesData = await loadEnrichedSeriesData()
const organizedPath = path.join(DATA_DIR, 'organized.json')
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
res.json({ ok: true, ...result })
} catch (err) {
logger.error('api', `Migration IDs échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
router.get('/organize/recent', requireAuth, (_req, res) => {
res.json(recentOrganized)
})
router.post('/organize/recent/clear', requireAuth, (_req, res) => {
recentOrganized.length = 0
logger.info('api', 'Historique des imports effacé')
res.json({ ok: true })
})
router.post('/organize', requireAuth, async (req, res) => {
const { hash, name, save_path } = req.body
if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
try {
const seriesData = await loadEnrichedSeriesData()
const result = await organizeTorrent(hash, name, save_path, seriesData)
if (result.done > 0 || result.errors.length > 0) {
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
}
res.json(result)
} catch (err) {
logger.error('api', `Import manuel de "${name}" échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
export default router
+198
View File
@@ -0,0 +1,198 @@
import { Router } from 'express'
import { Agent, fetch as undiciFetch } from 'undici'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
const router = Router()
const PLEX_TV_API = 'https://plex.tv/api/v2'
// ── Fetch avec bypass SSL pour les serveurs locaux ────────────────────────────
function getServerFetch(serverUrl: string) {
return (url: string, opts: any = {}) =>
undiciFetch(url, {
...opts,
dispatcher: new Agent({ connect: { rejectUnauthorized: false } }),
})
}
// ── Fetch vers plex.tv (certificat valide, pas besoin de bypass) ──────────────
async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
const res = await fetch(url, {
...options,
headers: {
'Accept' : 'application/json',
'X-Plex-Client-Identifier': 'fankarr',
'X-Plex-Product' : 'FanKarr',
'X-Plex-Version' : '1.0',
...(options.headers ?? {}),
},
})
if (!res.ok) throw new Error(`Plex ${res.status}: ${await res.text()}`)
const text = await res.text()
return text ? JSON.parse(text) : {}
}
function mapServers(resources: any[]) {
return (Array.isArray(resources) ? resources : [])
.filter((r: any) => r.product === 'Plex Media Server')
.map((r: any) => ({
name : r.name,
owned : r.owned,
connections: (r.connections ?? []).map((c: any) => ({ uri: c.uri, local: c.local, relay: c.relay })),
}))
}
// POST /api/plex/connect — auth plex.tv + liste serveurs
router.post('/plex/connect', requireAuth, async (req, res) => {
const { username, password, code } = req.body
if (!username || !password) { res.status(400).json({ error: 'Email et mot de passe requis' }); return }
try {
const params = new URLSearchParams({ login: username, password })
if (code) params.set('verificationCode', code)
const authData = await plexFetch(`${PLEX_TV_API}/users/signin`, {
method : 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body : params.toString(),
})
const token = authData.authToken ?? authData.user?.authToken
if (!token) throw new Error('Token non reçu')
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
const servers = mapServers(resources)
logger.info('plex', `Auth réussie pour ${username} — ${servers.length} serveur(s)`)
res.json({ ok: true, token, servers })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
if (msg.includes('401')) {
res.status(401).json({ error: '2FA requis ou identifiants incorrects', requires2FA: true })
} else {
logger.error('plex', `Auth échouée : ${msg}`)
res.status(401).json({ error: 'Authentification échouée — vérifiez vos identifiants' })
}
}
})
// POST /api/plex/oauth/start — démarre le flow OAuth
router.post('/plex/oauth/start', requireAuth, async (_req, res) => {
try {
const data = await plexFetch(`${PLEX_TV_API}/pins`, {
method : 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body : new URLSearchParams({ strong: 'true', 'X-Plex-Client-Identifier': 'fankarr' }).toString(),
})
const pinId = data.id
const pinCode = data.code
if (!pinId || !pinCode) throw new Error('Pin non reçu')
const authUrl = `https://app.plex.tv/auth#?clientID=fankarr&code=${pinCode}&context%5Bdevice%5D%5Bproduct%5D=FanKarr&forwardUrl=https%3A%2F%2Fplex.tv`
logger.info('plex', `OAuth démarré — pinId ${pinId}`)
res.json({ ok: true, pinId, pinCode, authUrl })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
logger.error('plex', `OAuth start échoué : ${msg}`)
res.status(500).json({ error: msg })
}
})
// GET /api/plex/oauth/poll/:pinId — vérifie si l'OAuth est complété
router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
const pinId = String(req.params.pinId)
try {
const data = await plexFetch(`${PLEX_TV_API}/pins/${pinId}?X-Plex-Client-Identifier=fankarr`)
const token = data.authToken
if (!token) { res.json({ ok: false, pending: true }); return }
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
const servers = mapServers(resources)
logger.info('plex', `OAuth complété — ${servers.length} serveur(s)`)
res.json({ ok: true, pending: false, token, servers })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
res.status(500).json({ error: msg })
}
})
// POST /api/plex/setup — enregistre l'agent Fankai + crée la bibliothèque
router.post('/plex/setup', requireAuth, async (req, res) => {
const { token, serverUrl, libraryName, libraryPath } = req.body
if (!token || !serverUrl || !libraryName || !libraryPath) {
res.status(400).json({ error: 'token, serverUrl, libraryName, libraryPath requis' }); return
}
// Fetch avec SSL bypass pour les appels vers le serveur Plex local
const serverFetch = getServerFetch(serverUrl)
const headers: Record<string, string> = { 'X-Plex-Token': token, 'Accept': 'application/json' }
const TARGET_AGENT_URI = 'https://metadata.fankai.fr/plex'
const steps: { step: string; ok: boolean; message: string }[] = []
let agentIdentifier = 'tv.plex.agents.custom.fankai'
let groupId: string | null = null
let agentSetupOk = false
// ── Étape 1 : Enregistrer le provider Fankai ─────────────
try {
const providersRes = await serverFetch(`${serverUrl}/media/providers/metadata`, { headers })
if (providersRes.ok) {
const data = await providersRes.json() as any
const providers: any[] = data?.MediaContainer?.MetadataAgentProvider ?? []
let provider = providers.find((p: any) => p.uri === TARGET_AGENT_URI)
if (!provider) {
const r = await serverFetch(`${serverUrl}/media/providers/metadata?uri=${encodeURIComponent(TARGET_AGENT_URI)}`, { method: 'POST', headers })
if (r.ok) provider = ((await r.json()) as any)?.MediaContainer?.MetadataAgentProvider?.[0]
}
if (provider) {
agentIdentifier = provider.identifier ?? agentIdentifier
steps.push({ step: 'provider', ok: true, message: `Provider enregistré (${agentIdentifier})` })
}
const groupsRes = await serverFetch(`${serverUrl}/media/providers/metadata/group`, { headers })
if (groupsRes.ok) {
const gdata = await groupsRes.json() as any
const groups: any[] = gdata?.MediaContainer?.MetadataAgentProviderGroup ?? []
let group = groups.find((g: any) => g.primaryIdentifier === agentIdentifier)
if (!group) {
const r = await serverFetch(
`${serverUrl}/media/providers/metadata/group?title=Fankai&primaryIdentifier=${encodeURIComponent(agentIdentifier)}`,
{ method: 'POST', headers },
)
if (r.ok) group = ((await r.json()) as any)?.MediaContainer?.MetadataAgentProviderGroup?.[0]
}
if (group) {
groupId = String(group.id ?? '')
steps.push({ step: 'group', ok: true, message: `Groupe d'agents créé (ID: ${groupId})` })
agentSetupOk = true
}
}
} else {
const status = providersRes.status
steps.push({ step: 'agent', ok: false, message: `Agent non configuré (HTTP ${status} — Plex < 1.43 ?) — setup manuel requis` })
logger.warn('plex', `Setup agent Fankai — HTTP ${status} depuis ${serverUrl}`)
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
steps.push({ step: 'agent', ok: false, message: `Agent non configuré — setup manuel requis` })
logger.warn('plex', `Setup agent Fankai échoué : ${msg}`)
}
// ── Étape 2 : Créer la bibliothèque ──────────────────────
try {
const params = new URLSearchParams({
type : 'show',
name : libraryName,
agent : agentIdentifier,
scanner : 'Plex TV Series',
language: 'fr-FR',
location: libraryPath,
})
if (groupId) params.set('metadataAgentProviderGroupId', groupId)
const libRes = await serverFetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers })
if (!libRes.ok) throw new Error(`HTTP ${libRes.status}: ${await libRes.text()}`)
steps.push({ step: 'library', ok: true, message: `Bibliothèque "${libraryName}" créée` })
logger.info('plex', `Bibliothèque "${libraryName}" créée sur ${serverUrl}`)
res.json({ ok: true, agentSetupOk, steps, manualSetup: !agentSetupOk })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
steps.push({ step: 'library', ok: false, message: `Création bibliothèque échouée : ${msg}` })
logger.error('plex', `Création bibliothèque échouée : ${msg}`)
res.status(500).json({ ok: false, steps, error: msg })
}
})
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
+195
View File
@@ -0,0 +1,195 @@
import { Router } from 'express'
import path from 'path'
import fs from 'fs'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { dispatchList } from '../torrent-clients/index.js'
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
import {
fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode,
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
deduplicateEpisodes,
} from '../lib/serie-helpers.js'
const router = Router()
router.get('/series', requireAuth, async (_req, res) => {
try {
const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()])
const availableSet = new Set<number>(availableIds)
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const activeTorrents = new Set<string>()
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
const serieDataMap = new Map<number, any>()
await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
res.json({ series: seriesRaw.map((serie: any) => {
const serieData = serieDataMap.get(serie.id) ?? null
const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) }
})})
} catch (err) {
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
router.get('/series/:id', requireAuth, async (req, res) => {
const id = Number(req.params.id)
try {
const [serieRaw, seasonsData, serieData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`), readSerieData(id)])
const serie = { ...normalizeSerie(serieRaw), wiki: serieData?.wiki ?? null }
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
return { ...normalizeSeason(season), episodes }
}))
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const availableEpisodeIds = new Set<number>()
const episodeTorrentMap : Record<number, any[]> = {}
const seasonTorrentMapBySn : Record<number, any[]> = {}
const integraleTorrents : any[] = []
const organizedEpisodeIds = new Set<number>()
const manualOrg = organized['manual'] ?? {}
if (serieData) {
for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
}
}
}
if (serieData) {
// Lookup rapide hash → torrent pack (intégrale ou saison)
const torrentByHash = new Map<string, { torrent_url: string; magnet: string; type: string; raw: string; fankai: boolean; torrent_name: string | null }>()
for (const t of (serieData.torrents ?? []))
if (t.infohash) torrentByHash.set(t.infohash.toLowerCase(), { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title ?? '', fankai: t.fankai ?? true, torrent_name: t.torrent_name ?? null })
for (const sd_s of (serieData.seasons ?? []))
for (const t of (sd_s.torrents ?? []))
if (t.infohash) torrentByHash.set(t.infohash.toLowerCase(), { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title ?? '', fankai: t.fankai ?? true, torrent_name: t.torrent_name ?? null })
for (const t of (serieData.torrents ?? [])) {
integraleTorrents.push({ ...t, raw: t.title })
const resolved = buildResolvedEpisodes(serieData, t.infohash)
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
for (const ep of resolved) {
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
}
}
for (const season of serieData.seasons ?? []) {
for (const t of (season.torrents ?? [])) {
if (!seasonTorrentMapBySn[season.season_number]) seasonTorrentMapBySn[season.season_number] = []
seasonTorrentMapBySn[season.season_number].push({ torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, type: 'pack_saison', raw: t.title, torrent_name: t.torrent_name ?? null, manual: t.manual ?? false })
const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
for (const ep of resolved) {
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
}
}
for (const ep of season.episodes ?? []) {
for (const t of (ep.torrents ?? [])) {
if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = []
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
episodeTorrentMap[ep.id].push({ torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, type: 'episode', raw: t.title, torrent_name: t.torrent_name ?? null, manual: t.manual ?? false, fankai: t.fankai ?? true, file_index: pathEntry?.file_index ?? null, file_path: pathEntry?.path ?? null, formatted_name: pathEntry?.formatted_name ?? ep.formatted_name ?? null })
availableEpisodeIds.add(ep.id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
const isOrg = orgFiles[String(ep.id)] !== undefined
if (!isOrg) {
const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
const filename = match ? match.path.split('/').pop() : null
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
} else {
organizedEpisodeIds.add(ep.id)
}
}
// Fallback : si l'épisode n'a pas de torrent individuel,
// on crée un torrent synthétique depuis ep.paths (fichier dans un pack).
// On ajoute TOUTES les entrées valides (un pack par path unique).
if (!episodeTorrentMap[ep.id] || episodeTorrentMap[ep.id].length === 0) {
const seenPackHashes = new Set<string>()
for (const pathEntry of (ep.paths ?? [])) {
if (typeof pathEntry !== 'object' || !pathEntry.infohash) continue
const hashKey = pathEntry.infohash.toLowerCase()
if (seenPackHashes.has(hashKey)) continue
const pack = torrentByHash.get(hashKey)
if (!pack) continue
seenPackHashes.add(hashKey)
if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = []
episodeTorrentMap[ep.id].push({ torrent_url: pack.torrent_url, magnet: pack.magnet, infohash: hashKey, type: pack.type, raw: pack.raw, torrent_name: pack.torrent_name ?? null, manual: false, fankai: pack.fankai, file_index: pathEntry.file_index ?? null, file_path: pathEntry.path ?? null, formatted_name: pathEntry.formatted_name ?? ep.formatted_name ?? null })
availableEpisodeIds.add(ep.id)
}
}
}
}
}
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
const rawEps = season.episodes.map((ep: any) => {
const epTorrents = episodeTorrentMap[ep.id] ?? []
const epTorrent = epTorrents[0] ?? null
let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null
if (fankai === null && serieData) {
outer: for (const sd_season of serieData.seasons ?? []) {
for (const sdEp of sd_season.episodes ?? []) {
if (sdEp.id !== ep.id) continue
for (const p of sdEp.paths ?? []) {
const ih = typeof p === 'object' ? p.infohash?.toLowerCase() : null
if (!ih) continue
for (const t of serieData.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } }
for (const s of serieData.seasons ?? []) { for (const t of s.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } } }
}
}
}
}
const resolvedFankai = fankai ?? true
return {
...ep,
available: availableEpisodeIds.has(ep.id),
torrent: epTorrent ? { ...epTorrent, fankai: resolvedFankai } : null,
torrents: epTorrents.map((t: any) => ({ ...t, fankai: resolvedFankai ?? t.fankai ?? true })),
fankai,
organized: organizedEpisodeIds.has(ep.id),
}
})
// Dédoublonnage : plusieurs variantes (x264/x265…) du même episode_number
// sont fusionnées en un seul épisode avec la liste de torrents combinée
const eps = deduplicateEpisodes(rawEps)
const total = eps.filter((e: any) => e.available).length
const orgCount = eps.filter((e: any) => e.organized).length
const epTotal = eps.length
const orgState = orgCount === 0
? 'none'
: orgCount >= epTotal
? 'complete'
: total > 0 && orgCount >= total
? 'complete'
: 'partial'
const seasonTorrents = seasonTorrentMapBySn[season.season_number] ?? []
return {
...season,
torrent: seasonTorrents[0] ?? null,
torrents: seasonTorrents,
organized_state: orgState,
organized_count: orgCount,
episodes: eps,
}
})
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) })
} catch (err) {
logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
export default router
+30
View File
@@ -0,0 +1,30 @@
import { Router } from 'express'
import { requireAuth } from '../auth.js'
import { readSettings, writeSettings } from '../settings.js'
import { logger } from '../logger.js'
const router = Router()
router.get('/settings', requireAuth, (_req, res) => {
res.json(readSettings())
})
router.post('/settings', requireAuth, (req, res) => {
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove, autoUnimportMissing, requestAutoDownloadUsers } = req.body
const patch: Record<string, any> = {}
if (mediaPath !== undefined) patch.mediaPath = mediaPath
if (completePath !== undefined) patch.completePath = completePath
if (organizeMode !== undefined) patch.organizeMode = organizeMode
if (category !== undefined) patch.category = category
if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport
if (autoImport !== undefined) patch.autoImport = autoImport
if (devMode !== undefined) patch.devMode = devMode
if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove
if (autoUnimportMissing !== undefined) patch.autoUnimportMissing = autoUnimportMissing
if (requestAutoDownloadUsers !== undefined) patch.requestAutoDownloadUsers = requestAutoDownloadUsers
const updated = writeSettings(patch)
logger.info('api', 'Paramètres mis à jour')
res.json(updated)
})
export default router
+194
View File
@@ -0,0 +1,194 @@
import { Router } from 'express'
import path from 'path'
import fs from 'fs'
import { requireAuth } from '../auth.js'
import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js'
import { DATA_DIR, BASE_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { systemInfo } from '../system.js'
import { scanMediaPath, syncFilenameChanges } from '../organize.js'
import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js'
import { cacheSize } from '../lib/github-cache.js'
import { recentOrganized } from '../lib/notifs.js'
import { workerRunning } from '../organize.js'
const router = Router()
// ── Logs ───────────────────────────────────────────────────────
router.get('/logs', requireAuth, (req, res) => {
const limit = Number(req.query.limit) || 100
const level = (req.query.level as string) || 'all'
const source = (req.query.source as string) || undefined
res.json({ entries: readLogs({ limit, level: level as any, source }), size: logsFileSize() })
})
router.post('/logs/clear', requireAuth, (_req, res) => {
clearLogs()
logger.info('api', 'Logs effacés')
res.json({ ok: true })
})
// ── Système ────────────────────────────────────────────────────
router.get('/system/info', systemInfo)
router.get('/system', requireAuth, (_req, res) => {
res.json({ isDocker: fs.existsSync('/.dockerenv') })
})
router.get('/version', (_req, res) => {
try {
const versionPath = path.join(BASE_DIR, 'version.txt')
const version = fs.existsSync(versionPath) ? fs.readFileSync(versionPath, 'utf-8').trim() : 'dev'
res.json({ version })
} catch {
res.json({ version: 'dev' })
}
})
// ── Parcours fichiers ──────────────────────────────────────────
router.get('/browse', requireAuth, (req, res) => {
const isWindows = process.platform === 'win32'
const dirPath = (req.query.path as string) || '/'
// ── Racine virtuelle Windows : liste les lecteurs disponibles ──
if (isWindows && dirPath === '/') {
const drives: string[] = []
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
const d = `${letter}:\\`
try { fs.readdirSync(d); drives.push(d) } catch {}
}
res.json({ path: '/', parent: null, dirs: drives, drivesRoot: true })
return
}
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b))
// Sur Windows, remonter à '/' quand on est à la racine d'un lecteur (ex: C:\)
let parent: string | null
if (dirPath === '/') {
parent = null
} else if (isWindows && /^[A-Za-z]:\\?$/.test(dirPath)) {
parent = '/'
} else {
const up = path.dirname(dirPath)
parent = up === dirPath ? null : up // dirname de C:\ retourne C:\ → null
}
res.json({ path: dirPath, parent, dirs })
} catch (err) {
logger.warn('api', `Lecture dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
}
})
router.get('/browse-files', requireAuth, (req, res) => {
const dirPath = (req.query.path as string) || '/'
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
try {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
logger.info('api', `Dossier créé : ${dirPath}`)
}
const files: { name: string; path: string; size: number }[] = []
function walk(dir: string) {
let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
catch { return }
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(full)
} else if (entry.isFile() && VIDEO_EXTS.has(path.extname(entry.name).toLowerCase())) {
try {
const stat = fs.statSync(full)
files.push({ name: entry.name, path: full, size: stat.size })
} catch {}
}
}
}
walk(dirPath)
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
res.json({ path: dirPath, files })
} catch (err) {
logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
}
})
// ── Catalogue ──────────────────────────────────────────────────
router.get('/torrents/status', requireAuth, async (_req, res) => {
try {
const available = await readAvailable()
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 })
} catch { res.json({ exists: false, count: 0, empty: true }) }
})
router.post('/update', requireAuth, async (_req, res) => {
try {
cacheClear()
logger.info('api', 'Cache GitHub vidé — rechargement forcé')
const availableIds = await readAvailable()
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
res.json({ ok: true, count: availableIds.length })
;(async () => {
try {
const seriesData = await loadEnrichedSeriesData()
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json'))
if (renamed > 0)
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
} catch (err) {
logger.warn('api', `Sync noms après mise à jour échoué : ${err instanceof Error ? err.message : err}`)
}
})()
} catch (err) {
logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
router.post('/scan', requireAuth, async (_req, res) => {
try {
const { mediaPath } = readSettings()
logger.info('api', 'Scan médiathèque manuel lancé')
const seriesData = await loadEnrichedSeriesData()
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
res.json({ ok: true, ...result })
} catch (err) {
logger.error('api', `Scan médiathèque échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Debug ──────────────────────────────────────────────────────
router.get('/debug/stats', requireAuth, (req, res) => {
const { devMode } = readSettings()
if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
const mem = process.memoryUsage()
const uptimeS = Math.floor(process.uptime())
const h = Math.floor(uptimeS / 3600)
const m = Math.floor((uptimeS % 3600) / 60)
const s = uptimeS % 60
let organizedCount = 0
try {
const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath)) {
const org = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
organizedCount = Object.values(org).reduce((acc: number, episodes: any) => acc + Object.keys(episodes).length, 0)
}
} catch {}
res.json({
memory : { heapUsed: Math.round(mem.heapUsed / 1024 / 1024), heapTotal: Math.round(mem.heapTotal / 1024 / 1024), rss: Math.round(mem.rss / 1024 / 1024) },
cache : { entries: cacheSize(), ttlHours: 6 },
uptime : `${h}h ${m}m ${s}s`,
uptimeSeconds: uptimeS,
worker : { running: workerRunning },
organized: { trackedFiles: organizedCount },
requests : { notifs: recentOrganized.length },
})
})
export default router
+86
View File
@@ -0,0 +1,86 @@
import { Router } from 'express'
import { requireAuth } from '../auth.js'
import {
getDriver, getAvailableClients,
listClients, addClient, removeClient, getClient,
sanitizeClient, updateClient,
} from '../torrent-clients/index.js'
const router = Router()
router.get('/torrent-clients/available', requireAuth, (_req, res) => {
res.json(getAvailableClients())
})
router.get('/torrent-clients', requireAuth, (_req, res) => {
res.json(listClients().map((c: any) => sanitizeClient(c)))
})
router.post('/torrent-clients', requireAuth, (req, res) => {
const { name, type, config } = req.body
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
res.json(sanitizeClient(addClient(name, type, config)))
})
router.delete('/torrent-clients/:uuid', requireAuth, (req, res) => {
const ok = removeClient(String(req.params.uuid))
if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return }
res.json({ ok: true })
})
router.put('/torrent-clients/:uuid', requireAuth, (req, res) => {
const { name, type, config } = req.body
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
const oldClient = getClient(String(req.params.uuid))
if (oldClient) {
const driver = getDriver(type)
if (driver) {
for (const field of driver.definition.fields) {
if (field.type === 'password' && config[field.key] === '••••••••') {
config[field.key] = oldClient.config[field.key]
}
}
}
}
const updated = updateClient(String(req.params.uuid), name, type, config)
if (!updated) { res.status(404).json({ error: 'Client introuvable' }); return }
res.json(sanitizeClient(updated))
})
router.post('/torrent-clients/test-config', requireAuth, async (req, res) => {
const { type, config, uuid } = req.body
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
const driver = getDriver(type)
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
if (uuid) {
const oldClient = getClient(uuid)
if (oldClient) {
for (const field of driver.definition.fields) {
if (field.type === 'password' && config[field.key] === '••••••••') {
config[field.key] = oldClient.config[field.key]
}
}
}
}
res.json(await driver.test(config))
})
router.post('/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type)
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
res.json(await driver.test(client.config))
})
router.get('/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) => {
const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type)
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
res.json(await driver.healthcheck(client.config))
})
export default router
+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 {
+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
+42 -3
View File
@@ -34,6 +34,13 @@ export interface SavedClient {
config: Record<string, string | number>
}
export interface TorrentFileProgress {
index : number
name ?: string // chemin relatif dans le torrent (ex: "Pack S1/Episode1.mkv") — optionnel selon le client
progress: number // 0–1
priority: number // 0 = do not download, 1 = normal, 6 = high, 7 = max
}
export interface TorrentInfo {
hash : string
name : string
@@ -48,15 +55,24 @@ export interface TorrentInfo {
eta : number
save_path : string
category : string
files ?: TorrentFileProgress[]
}
export interface DownloadOptions {
file_index?: number | null
file_path ?: string | null
infohash ?: string | null // hash direct depuis les données API (plus fiable que parser le magnet)
magnet ?: string | null // fallback pour extraire le hash si infohash absent
}
export interface TorrentClientDriver {
definition : TorrentClientDefinition
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }>
add : (config: Record<string, string | number>, url: string) => Promise<void>
add : (config: Record<string, string | number>, url: string, options?: DownloadOptions) => Promise<void>
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
getFiles ?: (config: Record<string, string | number>, hash: string) => Promise<TorrentFileProgress[]>
}
// ─── Registry ─────────────────────────────────────────────────────────────────
@@ -208,7 +224,7 @@ function remapSavePath(savePath: string, remotePath: string, localPath: string):
// ─── Dispatch download ─────────────────────────────────────────────────────────
export async function dispatchDownload(url: string): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
export async function dispatchDownload(url: string, options?: DownloadOptions): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
const clients = loadClients()
if (clients.length === 0) {
@@ -226,7 +242,7 @@ export async function dispatchDownload(url: string): Promise<{ uuid: string; nam
continue
}
try {
await driver.add(client.config, url)
await driver.add(client.config, url, options)
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
results.push({ uuid: client.uuid, name: client.name, ok: true })
} catch (err) {
@@ -328,4 +344,27 @@ export async function dispatchList(
}
return results
}
// ─── Dispatch file progress ────────────────────────────────────────────────────
// Retourne la liste des fichiers d'un torrent avec leur progression (0–1).
// clientUuid optionnel : si fourni, interroge uniquement ce client.
export async function dispatchGetFiles(
hash : string,
clientUuid?: string,
): Promise<TorrentFileProgress[]> {
const clients = clientUuid
? loadClients().filter(c => c.uuid === clientUuid)
: loadClients()
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver?.getFiles) continue
try {
const files = await driver.getFiles(client.config, hash)
if (files.length > 0) return files
} catch {}
}
return []
}
+283 -48
View File
@@ -1,19 +1,40 @@
/**
* qBittorrent Driver
* Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication)
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
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'
if (['pausedDL', 'pausedUP'].includes(state)) return 'paused'
if (['pausedDL', 'pausedUP', 'stoppedDL', 'stoppedUP'].includes(state)) return 'paused'
if (['checkingDL', 'checkingUP', 'checkingResumeData'].includes(state)) return 'checking'
if (state === 'error' || state === 'missingFiles') return 'error'
return 'unknown'
}
// Retourne les headers d'authentification : Bearer (≥5.2.0) ou cookie SID
async function qbAuth(config: Record<string, string | number>): Promise<Record<string, string>> {
if (config.apiKey) {
return { Authorization: `Bearer ${config.apiKey}` }
}
const cookie = await qbLogin(config)
return { Cookie: cookie }
}
async function qbLogin(config: Record<string, string | number>): Promise<string> {
const form = new URLSearchParams()
form.append('username', String(config.username ?? ''))
@@ -26,12 +47,96 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
if (!res.ok) throw new Error(`Login échoué : ${text.trim() || res.status}`)
if (text.trim() === 'Fails.') throw new Error('Identifiants incorrects (vérifiez le nom d\'utilisateur et le mot de passe)')
const cookie = res.headers.get('set-cookie') ?? ''
const sid = cookie.match(/SID=([^;]+)/)?.[1]
if (!sid) throw new Error('Cookie SID introuvable')
return sid
const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0].trim()
if (!cookie) throw new Error('Cookie de session introuvable')
return cookie
}
async function qbTorrentExists(config: Record<string, string | number>, authHeaders: Record<string, string>, hash: string): Promise<boolean> {
try {
const res = await fetch(`${config.url}/api/v2/torrents/info?hashes=${hash}`, {
headers: authHeaders,
})
if (!res.ok) return false
const list = await res.json()
return Array.isArray(list) && list.length > 0
} catch {
return false
}
}
/**
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
* puis applique les priorités (désactive tout sauf la cible).
*/
async function qbApplyFilePriority(
config : Record<string, string | number>,
hash : string,
fileIndex: number,
): Promise<void> {
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
try {
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: authH,
})
if (!res.ok) continue
const files: any[] = await res.json()
if (!Array.isArray(files) || files.length === 0) continue
if (fileIndex >= files.length) {
throw new Error(`Index fichier ${fileIndex} hors limites (torrent a ${files.length} fichiers)`)
}
// Si tous les fichiers sont à priorité normale (état initial du torrent),
// on applique le filtrage complet : on désactive tout sauf la cible.
// Si certains sont déjà à 0 (filtrage déjà effectué pour un épisode précédent),
// on se contente d'activer la nouvelle cible sans toucher aux autres.
const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0)
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
if (isInitialState) {
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
if (unwanted.length > 0) {
const body = new URLSearchParams({ hash, id: unwanted.join('|'), priority: '0' })
const r = await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body, headers })
if (!r.ok) { logger.warn('qbittorrent', `filePrio désactivation échouée (${r.status}) — retry`); continue }
}
}
// Activer le fichier cible (dans tous les cas)
const r2 = await fetch(`${config.url}/api/v2/torrents/filePrio`, {
method: 'POST',
body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }),
headers,
})
if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
// Vérification : le fichier cible est bien à priorité > 0
const verif = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
if (verif.ok) {
const verifiedFiles: any[] = await verif.json()
const target = verifiedFiles[fileIndex]
if (!target || (target.priority ?? 0) === 0) {
logger.warn('qbittorrent', `Fichier ${fileIndex} toujours à priorité 0 après application — retry`)
continue
}
}
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch (err) {
if (err instanceof Error && err.message.includes('hors limites')) throw err
}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
}
const QB: TorrentClientDriver = {
@@ -40,8 +145,9 @@ const QB: TorrentClientDriver = {
label : 'qBittorrent',
fields: [
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'apiKey', label: 'Clé API (≥5.2.0)', type: 'password', placeholder: 'qbt_xxxx…', required: false },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
@@ -51,7 +157,9 @@ const QB: TorrentClientDriver = {
async test(config) {
try {
await qbLogin(config)
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/app/version`, { headers: authH })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
@@ -63,12 +171,13 @@ const QB: TorrentClientDriver = {
async healthcheck(config) {
try {
const sid = await qbLogin(config)
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/app/version`, {
headers: { Cookie: `SID=${sid}` },
headers: authH,
})
if (!res.ok) return { online: false }
const version = (await res.text()).trim()
if (version.startsWith('<')) throw new Error('Réponse HTML reçue — clé API invalide ou non supportée par cette version de qBittorrent')
logger.debug('qbittorrent', `Healthcheck OK — version ${version}`)
return { online: true, version }
} catch (err) {
@@ -78,60 +187,186 @@ const QB: TorrentClientDriver = {
},
async list(config, category) {
const sid = await qbLogin(config)
const authH = await qbAuth(config)
const params = new URLSearchParams()
if (category) params.set('category', category)
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
headers: { Cookie: `SID=${sid}` },
headers: authH,
})
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
const data: any[] = await res.json()
return data.map(t => ({
hash : t.hash,
name : t.name,
state : mapState(t.state),
progress : Math.round(t.progress * 100),
size : t.size,
downloaded: t.downloaded,
uploaded : t.uploaded ?? 0,
ratio : Math.round((t.ratio ?? 0) * 100) / 100,
speed : t.dlspeed,
upspeed : t.upspeed ?? 0,
eta : t.eta ?? -1,
save_path : t.save_path,
category : t.category ?? '',
} satisfies TorrentInfo))
// Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
const fileMap = new Map<string, any[]>()
await Promise.all(data.map(async (t: any) => {
try {
const r = await fetch(`${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
headers: authH,
})
if (r.ok) fileMap.set(t.hash, await r.json())
} catch {}
}))
return data.map(t => {
const fileInfo = fileMap.get(t.hash) ?? []
return {
hash : t.hash,
name : t.name,
state : mapState(t.state),
progress : Math.round(t.progress * 100),
size : t.size,
downloaded: t.downloaded,
uploaded : t.uploaded ?? 0,
ratio : Math.round((t.ratio ?? 0) * 100) / 100,
speed : t.dlspeed,
upspeed : t.upspeed ?? 0,
eta : t.eta ?? -1,
save_path : t.save_path,
category : t.category ?? '',
files : fileInfo.length > 0
? fileInfo.map((f: any, i: number) => ({
index : i,
name : String(f.name ?? ''),
progress: f.progress ?? 0, // 0–1
priority: f.priority ?? 1,
}))
: undefined,
} satisfies TorrentInfo
})
},
async add(config, url) {
const sid = await qbLogin(config)
const form = new FormData()
form.append('urls', url)
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method : 'POST',
body : form,
headers: { Cookie: `SID=${sid}` },
async getFiles(config, hash) {
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: authH,
})
if (!res.ok) return []
const data: any[] = await res.json()
return data.map((f: any, i: number) => ({
index : i,
name : String(f.name ?? ''),
progress: f.progress ?? 0,
priority: f.priority ?? 1,
}))
},
async add(config, url, options?: DownloadOptions) {
const authH = await qbAuth(config)
// Priorité : infohash direct (depuis les données API) > parsing du magnet/URL
const hash = (options?.infohash?.toLowerCase() ?? null)
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| null
if (options?.file_index != null) {
if (hash) {
const exists = await qbTorrentExists(config, authH, hash)
if (exists) {
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
}
// Snapshot des hashes avant ajout (pour retrouver le nouveau si hash inconnu)
let knownHashes: Set<string> = new Set()
if (!hash) {
try {
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: authH })
if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase()))
} catch {}
}
// Stratégie d'ajout selon le type de lien :
// magnet link → URLSearchParams (application/x-www-form-urlencoded), pas de binaire
// .torrent URL → FormData multipart obligatoire pour uploader le blob binaire
// (qBit n'a pas forcément accès à internet / nyaa.si depuis son réseau Docker)
const isMagnet = url.startsWith('magnet:')
let res: Response
if (isMagnet) {
const params = new URLSearchParams()
params.set('urls', url)
if (config.category) params.set('category', String(config.category))
if (config.savePath) params.set('savepath', String(config.savePath))
res = await fetch(`${config.url}/api/v2/torrents/add`, {
method: 'POST', body: params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
})
} else {
// FanKarr télécharge le .torrent et l'envoie directement à qBit
logger.debug('qbittorrent', `Téléchargement .torrent via FanKarr : ${url.slice(0, 120)}`)
const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } })
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent (${torrentRes.status}) : ${url}`)
const torrentBytes = await torrentRes.arrayBuffer()
const form = new FormData()
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent')
res = await fetch(`${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH })
}
const text = await res.text()
if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${text}`)
if (hash) {
logger.info('qbittorrent', `Torrent ajouté — sélection fichier ${options.file_index} en attente des métadonnées`)
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
} else {
// Hash inconnu (pas d'infohash, pas de magnet) → retrouve le nouveau torrent par diff de liste
logger.info('qbittorrent', `Torrent ajouté sans hash connu — recherche dans la liste (sélection fichier ${options.file_index})`)
const fileIndex = options.file_index
;(async () => {
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const ah = await qbAuth(config)
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: ah })
if (!lr.ok) continue
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
if (!newT) continue
const resolvedHash = String(newT.hash).toLowerCase()
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`)
await qbApplyFilePriority(config, resolvedHash, fileIndex)
return
} catch {}
}
logger.warn('qbittorrent', `Impossible de résoudre le hash pour appliquer la priorité fichier ${fileIndex}`)
})().catch(() => {})
}
return
}
// Pas de sélection de fichier → ajout normal (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: params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
logger.info('qbittorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${text}`)
logger.info('qbittorrent', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
const sid = await qbLogin(config)
const form = new FormData()
form.append('hashes', hash)
form.append('deleteFiles', deleteFiles ? 'true' : 'false')
const authH = await qbAuth(config)
const params = new URLSearchParams({ hashes: hash, deleteFiles: deleteFiles ? 'true' : 'false' })
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
method : 'POST',
body : form,
headers: { Cookie: `SID=${sid}` },
body : params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
})
if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
}
export default QB
export default QB
+254
View File
@@ -0,0 +1,254 @@
/**
* Real-Debrid Driver
*
* Real-Debrid est un service debrid cloud : les torrents téléchargent sur leurs serveurs,
* puis les fichiers sont accessibles via des liens directs (HTTPS).
*
* Workflow FanKarr :
* 1. add() → POST /torrents/addMagnet puis selectFiles si file_index fourni
* 2. list() → GET /torrents (filtre par status=downloaded pour state='seeding')
* 3. remove() → DELETE /torrents/delete/{id}
*
* Le save_path de chaque torrent est construit à partir du localPath configuré
* (dossier où l'utilisateur télécharge ses fichiers RD via FUSE, WebDAV ou autre).
*/
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
function rdHeaders(config: Record<string, string | number>) {
return {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/x-www-form-urlencoded',
}
}
async function rdFetch(
config : Record<string, string | number>,
method : 'GET' | 'POST' | 'DELETE',
path : string,
body ?: URLSearchParams,
): Promise<any> {
const res = await fetch(`${RD_BASE}${path}`, {
method,
headers: rdHeaders(config),
body : body,
})
if (res.status === 204) return null
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Real-Debrid HTTP ${res.status} : ${text}`)
}
return res.json()
}
function mapStatus(status: string): TorrentInfo['state'] {
switch (status) {
case 'downloaded': return 'seeding'
case 'downloading':
case 'queued':
case 'compressing':
case 'uploading':
case 'magnet_conversion': return 'downloading'
case 'waiting_files_selection': return 'paused'
case 'magnet_error':
case 'error':
case 'virus':
case 'dead': return 'error'
default: return 'unknown'
}
}
const rdDriver: TorrentClientDriver = {
definition: {
id : 'real-debrid',
label : 'Real-Debrid',
fields: [
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
{ key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/real-debrid', required: false },
],
},
async test(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
logger.info('real-debrid', `Connexion réussie — utilisateur : ${user.username}`)
return { ok: true, message: `Connecté en tant que ${user.username}` }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('real-debrid', `Test de connexion échoué : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
return { online: true, version: `RD — ${user.username}` }
} catch {
return { online: false }
}
},
async add(config, url, options?: DownloadOptions) {
const isMagnet = url.startsWith('magnet:')
const hash = (options?.infohash?.toLowerCase() ?? null)
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| null
// Vérifier si le torrent est déjà présent sur RD
if (hash) {
try {
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const existing = list.find((t: any) => t.hash?.toLowerCase() === hash)
if (existing) {
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur RD`)
if (options?.file_index != null && existing.status === 'waiting_files_selection') {
const files: any[] = await rdFetch(config, 'GET', `/torrents/info/${existing.id}`)
.then((info: any) => info.files ?? [])
const fileId = files[options.file_index]?.id
if (fileId) {
await rdFetch(config, 'POST', `/torrents/selectFiles/${existing.id}`,
new URLSearchParams({ files: String(fileId) }))
}
}
return
}
} catch {}
}
let torrentId: string
if (isMagnet) {
const data = await rdFetch(config, 'POST', '/torrents/addMagnet',
new URLSearchParams({ magnet: url }))
torrentId = data.id
logger.info('real-debrid', `Magnet ajouté → ID ${torrentId}`)
} else {
// URL de fichier .torrent : télécharger puis uploader
const torrentRes = await fetch(url)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${torrentRes.status}`)
const torrentBuf = await torrentRes.arrayBuffer()
const formData = new FormData()
formData.append('torrent', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
const uploadRes = await fetch(`${RD_BASE}/torrents/addTorrent`, {
method : 'PUT',
headers: { Authorization: `Bearer ${config.apiKey}` },
body : formData,
})
if (!uploadRes.ok) throw new Error(`Ajout .torrent échoué : ${uploadRes.status}`)
const data = await uploadRes.json()
torrentId = data.id
logger.info('real-debrid', `.torrent uploadé → ID ${torrentId}`)
}
// Sélection des fichiers
if (options?.file_index != null) {
// Attendre que la liste des fichiers soit disponible (état waiting_files_selection)
let files: any[] = []
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
if (info.status === 'waiting_files_selection' && Array.isArray(info.files) && info.files.length > 0) {
files = info.files
break
}
if (info.status === 'downloading' || info.status === 'downloaded') break
} catch {}
}
if (files.length > 0 && options.file_index < files.length) {
const fileId = files[options.file_index]?.id
if (fileId) {
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: String(fileId) }))
logger.info('real-debrid', `Fichier ${options.file_index} sélectionné sur torrent RD ${torrentId}`)
}
} else if (files.length > 0) {
// Sélectionner tous les fichiers si index hors limites
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: 'all' }))
}
} else {
// Pas de sélection spécifique : tous les fichiers
// Attendre waiting_files_selection pour envoyer "all"
for (let attempt = 0; attempt < 10; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
if (info.status === 'waiting_files_selection') {
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: 'all' }))
break
}
if (info.status !== 'magnet_conversion') break
} catch {}
}
}
},
async list(config, _category?) {
const torrents: any[] = await rdFetch(config, 'GET', '/torrents')
const localBase = String(config.localPath ?? '').replace(/\/+$/, '')
return torrents.map((t: any): TorrentInfo => {
const progress = typeof t.progress === 'number' ? t.progress / 100 : 0
// Construire un save_path "virtuel" à partir du localPath configuré + nom du torrent
const save_path = localBase ? `${localBase}/${t.filename ?? t.id}` : ''
// Les liens sont les fichiers téléchargés (disponibles si status=downloaded)
const files: TorrentFileProgress[] | undefined =
Array.isArray(t.files) && t.files.length > 0
? t.files.map((f: any, i: number) => ({
index : i,
name : String(f.path ?? f.name ?? ''),
progress: f.selected ? progress : 0,
}))
: undefined
return {
hash : String(t.hash ?? t.id ?? '').toLowerCase(),
name : String(t.filename ?? t.id ?? ''),
state : mapStatus(t.status ?? ''),
progress : Math.round(progress * 100),
size : typeof t.bytes === 'number' ? t.bytes : 0,
downloaded: typeof t.bytes === 'number' ? Math.round(t.bytes * progress) : 0,
uploaded : 0,
ratio : 0,
speed : typeof t.speed === 'number' ? t.speed : 0,
upspeed : 0,
eta : -1,
save_path,
category : '',
files,
}
})
},
async remove(config, hash, _deleteFiles?) {
// Retrouver l'ID RD depuis le hash
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur Real-Debrid`)
await rdFetch(config, 'DELETE', `/torrents/delete/${target.id}`)
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… supprimé de Real-Debrid`)
},
async getFiles(config, hash) {
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) return []
const info = await rdFetch(config, 'GET', `/torrents/info/${target.id}`)
const overallProgress = typeof info.progress === 'number' ? info.progress / 100 : 0
if (!Array.isArray(info.files)) return []
return info.files.map((f: any, i: number) => ({
index : i,
name : String(f.path ?? f.name ?? ''),
progress: f.selected ? overallProgress : 0,
}))
},
}
export default rdDriver
+111 -17
View File
@@ -5,7 +5,7 @@
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
// ─── XML-RPC helpers ──────────────────────────────────────────
@@ -118,6 +118,56 @@ async function d_multicall(
return Array.isArray(result) ? result : []
}
async function rtTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
try {
const rows = await d_multicall(config, 'main', ['d.hash'])
return rows.some(r => String(r[0]).toLowerCase() === hash.toLowerCase())
} catch {
return false
}
}
/**
* Attend les métadonnées via polling rapide (300 ms), applique les priorités.
* Le torrent est ajouté avec load.start — il fetch les métadonnées activement via DHT/peers.
* Pendant le fetch (état "métadonnées en cours"), aucun byte de fichier n'est téléchargé.
* On set les priorités dès que les fichiers sont disponibles, avant que le download réel commence.
*/
async function rtApplyFilePriority(
config : Record<string, string | number>,
hash : string,
fileIndex: number,
): Promise<void> {
// 100 tentatives × 300 ms = 30 s max
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 300))
try {
// Récupérer taille ET priorité courante de chaque fichier
const files: any[] = await rpcCall(config, 'f.multicall', [hash, '', 'f.size_bytes=', 'f.priority='])
const fileCount = Array.isArray(files) ? files.length : 0
if (fileCount === 0) continue
// Si tous les fichiers sont à priorité normale (état initial),
// on filtre tout sauf la cible. Sinon on active juste la cible.
const isInitialState = files.every((f: any) => Number(f[1]) > 0)
for (let i = 0; i < fileCount; i++) {
if (i === fileIndex) {
await rpcCall(config, 'f.priority.set', [hash, i, 1])
} else if (isInitialState) {
await rpcCall(config, 'f.priority.set', [hash, i, 0])
}
// Si pas initial state : on ne touche pas aux autres fichiers
}
logger.info('rtorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
}
// ─── State mapping ────────────────────────────────────────────
// rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1)
// + is_open (0/1)
@@ -189,17 +239,29 @@ const RT: TorrentClientDriver = {
const rows = await d_multicall(config, 'main', methods)
// Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
const fileMap = new Map<string, any[]>()
await Promise.all(rows.map(async (r: any) => {
const hash = String(r[0]).toUpperCase()
try {
const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path=', 'f.priority='])
if (Array.isArray(files) && files.length > 0) fileMap.set(hash.toLowerCase(), files)
} catch {}
}))
return rows
.map(r => {
const [hash, name, isOpen, isActive, isChecking, isComplete,
size, downloaded, uploadedTotal, ratioRaw,
dlSpeed, ulSpeed, leftBytes, directory, label] = r
const progress = size > 0 ? Math.min(100, Math.round(((size - leftBytes) / size) * 100)) : 0
const eta = Number(dlSpeed) > 0 && Number(leftBytes) > 0 ? Math.round(Number(leftBytes) / Number(dlSpeed)) : -1
const progress = size > 0 ? Math.min(100, Math.round(((size - leftBytes) / size) * 100)) : 0
const eta = Number(dlSpeed) > 0 && Number(leftBytes) > 0 ? Math.round(Number(leftBytes) / Number(dlSpeed)) : -1
const hashLower = String(hash).toLowerCase()
const fileInfo = fileMap.get(hashLower) ?? []
return {
hash : String(hash).toLowerCase(),
hash : hashLower,
name : String(name),
state : mapState(Number(isOpen), Number(isActive), Number(isChecking), Number(isComplete)),
progress,
@@ -212,6 +274,14 @@ const RT: TorrentClientDriver = {
eta,
save_path : String(directory),
category : String(label ?? ''),
files : fileInfo.length > 0
? fileInfo.map((f: any, i: number) => ({
index : i,
name : String(f[2] ?? ''),
progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0,
priority: Number(f[3] ?? 1) === 0 ? 0 : 1,
}))
: undefined,
} satisfies TorrentInfo
})
.filter(t => {
@@ -220,20 +290,44 @@ const RT: TorrentClientDriver = {
})
},
async add(config, url) {
// load_start charge et démarre le torrent
const method = url.startsWith('magnet:') ? 'load.start' : 'load.start'
async add(config, url, options?: DownloadOptions) {
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
const hash = hashMatch?.[1]?.toUpperCase() ?? null
if (options?.file_index != null && hash) {
const exists = await rtTorrentExists(config, hash)
if (exists) {
// Torrent déjà présent → mettre à jour la priorité directement (async OK)
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
// Nouveau torrent → load.start (fetch métadonnées via DHT/peers actif)
// Pendant le fetch des métadonnées, aucun byte de fichier n'est téléchargé.
// On poll à 300 ms et on set les priorités dès que les fichiers sont disponibles.
const args: any[] = ['', url]
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
if (config.category) args.push(`d.custom1.set=${config.category}`)
await rpcCall(config, 'load.start', args)
logger.info('rtorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
// Pas de sélection de fichier → ajout et démarrage normal
const args: any[] = ['', url]
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
if (config.category) args.push(`d.custom1.set=${config.category}`)
await rpcCall(config, 'load.start', args)
if (config.savePath) {
args.push(`d.directory.set="${config.savePath}"`)
}
if (config.category) {
args.push(`d.custom1.set=${config.category}`)
}
await rpcCall(config, method, args)
logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
},
@@ -250,4 +344,4 @@ const RT: TorrentClientDriver = {
},
}
export default RT
export default RT
+42 -23
View File
@@ -2,17 +2,18 @@
* Synology Download Station Driver
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
// Synology Download Station task status codes
function mapState(status: string): TorrentInfo['state'] {
if (status === 'downloading') return 'downloading'
if (status === 'seeding' || status === 'finished') return 'seeding'
if (status === 'paused') return 'paused'
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
if (status === 'hash_checking') return 'checking'
if (status === 'error') return 'error'
if (status === 'downloading') return 'downloading'
if (status === 'seeding' || status === 'finished') return 'seeding'
if (status === 'finishing' || status === 'extracting') return 'seeding'
if (status === 'paused') return 'paused'
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
if (status === 'hash_checking') return 'checking'
if (status === 'error') return 'error'
return 'unknown'
}
@@ -110,10 +111,19 @@ const DS: TorrentClientDriver = {
return tasks
.map(t => {
const transfer = t.additional?.transfer ?? {}
const detail = t.additional?.detail ?? {}
const size = t.size ?? 0
const dl = transfer.size_downloaded ?? 0
// Synology expose le hash BT dans additional.detail.hash
// Fallback sur dbid_ pour les tâches non-BT (DDL, etc.)
const rawHash = detail.hash
const hash = (typeof rawHash === 'string' && rawHash.length > 0)
? rawHash.toLowerCase()
: `dbid_${t.id}`
return {
hash : t.id,
hash,
name : t.title,
state : mapState(t.status),
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
@@ -124,27 +134,30 @@ const DS: TorrentClientDriver = {
speed : transfer.speed_download ?? 0,
upspeed : transfer.speed_upload ?? 0,
eta : -1,
save_path : t.additional?.detail?.destination ?? '',
save_path : detail.destination ?? '',
category : category ?? '',
} satisfies TorrentInfo
})
},
async add(config, url) {
async add(config, url, options?: DownloadOptions) {
if (options?.file_index != null) logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet')
const sid = await dsLogin(config)
const extra: Record<string, string> = { uri: url }
if (config.savePath) extra.destination = String(config.savePath)
const params = new URLSearchParams({
const body = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'create',
_sid : sid,
...extra,
uri : url,
})
if (config.savePath) body.append('destination', String(config.savePath))
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi`, {
method : 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body : body.toString(),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
@@ -152,20 +165,26 @@ const DS: TorrentClientDriver = {
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
},
async remove(config, hash, deleteFiles = false) {
const sid = await dsLogin(config)
// Synology utilise son ID interne — on cherche via la liste
async remove(config, hash, _deleteFiles?) {
const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', { additional: 'detail' }, sid)
const tasks: any[] = data?.tasks ?? []
// Matcher par hash si résolu, sinon par id
const found = tasks.find(t => t.id?.toLowerCase() === hash.toLowerCase())
const h = hash.toLowerCase()
const found = tasks.find((t: any) => {
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
const synoId = String(t.id ?? '').toLowerCase()
return detailHash === h || synoId === h || `dbid_${synoId}` === h
})
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'delete',
id : found.id,
force_complete: deleteFiles ? 'false' : 'false',
force_complete: 'false',
_sid : sid,
})
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
@@ -174,4 +193,4 @@ const DS: TorrentClientDriver = {
},
}
export default DS
export default DS
+69 -11
View File
@@ -2,7 +2,7 @@
* Transmission Driver
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
function mapState(status: number): TorrentInfo['state'] {
@@ -52,6 +52,44 @@ async function trRequest(
return { ...data, sessionId }
}
async function trGetIdByHash(config: Record<string, string | number>, hash: string): Promise<number | null> {
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
const torrents = data.arguments?.torrents ?? []
return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null
}
/**
* Attend que les métadonnées soient disponibles (polling 500 ms),
* puis applique les priorités : fichier cible voulu, tous les autres ignorés.
*/
async function trApplyFilePriority(
config : Record<string, string | number>,
hash : string,
fileIndex: number,
): Promise<void> {
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const id = await trGetIdByHash(config, hash)
if (id == null) continue
const data = await trRequest(config, 'torrent-get', { ids: [id], fields: ['files'] })
const files = data.arguments?.torrents?.[0]?.files
if (!Array.isArray(files) || files.length === 0) continue
const unwanted = files.map((_: any, i: number) => i).filter((i: number) => i !== fileIndex)
await trRequest(config, 'torrent-set', {
ids : [id],
'files-wanted' : [fileIndex],
'files-unwanted' : unwanted,
})
logger.info('transmission', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
}
const TR: TorrentClientDriver = {
definition: {
id : 'transmission',
@@ -96,6 +134,7 @@ const TR: TorrentClientDriver = {
'hashString', 'name', 'status', 'percentDone', 'totalSize',
'downloadedEver', 'uploadedEver', 'uploadRatio',
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
'files', // bytesCompleted + length par fichier (fileStats n'a pas 'length')
]
const data = await trRequest(config, 'torrent-get', { fields })
const torrents: any[] = data.arguments?.torrents ?? []
@@ -119,22 +158,41 @@ const TR: TorrentClientDriver = {
eta : t.eta ?? -1,
save_path : t.downloadDir,
category : t.labels?.[0] ?? '',
files : Array.isArray(t.files) && t.files.length > 0
? t.files.map((f: any, i: number) => ({
index : i,
name : String(f.name ?? ''),
progress: f.length > 0 ? f.bytesCompleted / f.length : 0,
}))
: undefined,
} satisfies TorrentInfo))
},
async add(config, url) {
async add(config, url, options?: DownloadOptions) {
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
const hash = hashMatch?.[1]?.toLowerCase() ?? null
const args: Record<string, unknown> = { filename: url }
if (config.savePath) args['download-dir'] = String(config.savePath)
if (config.category) args['labels'] = [String(config.category)]
if (config.savePath) {
args['download-dir'] = String(config.savePath)
const res = await trRequest(config, 'torrent-add', args)
if (options?.file_index != null && hash) {
const isDuplicate = !!res.arguments?.['torrent-duplicate']
if (isDuplicate) {
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
} else {
logger.info('transmission', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
}
trApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('transmission', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
if (config.category) {
args['labels'] = [String(config.category)]
}
await trRequest(config, 'torrent-add', args)
logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
logger.info('transmission', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
@@ -148,4 +206,4 @@ const TR: TorrentClientDriver = {
},
}
export default TR
export default TR
+79 -25
View File
@@ -2,7 +2,7 @@
* uTorrent Driver
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
// uTorrent status flags (bitmask) :
@@ -21,6 +21,56 @@ function mapState(status: number): TorrentInfo['state'] {
// uTorrent nécessite un token CSRF + cookie à chaque session
interface UTSession { token: string; cookie: string }
async function utTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
try {
const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getfiles', hash: hash.toUpperCase() }, session)
const files = data?.files?.[1]
return Array.isArray(files) && files.length > 0
} catch { return false }
}
/**
* Attend que les métadonnées soient disponibles (polling), puis applique les priorités :
* tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal).
*/
async function utApplyFilePriority(
config : Record<string, string | number>,
hash : string,
fileIndex: number,
): Promise<void> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const HASH = hash.toUpperCase()
// 100 tentatives × 500 ms = 50 s max
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getfiles', hash: HASH }, session)
const files = data?.files?.[1] as any[][] | undefined
if (!Array.isArray(files) || files.length === 0) continue
// Passer tous les fichiers à priorité 0 (skip)
const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' })
for (let i = 0; i < files.length; i++) skipQs.append('f', String(i))
await fetch(`${config.url}/gui/?${skipQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
// Activer le fichier cible (priorité 2 = normal)
const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) })
await fetch(`${config.url}/gui/?${selectQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
logger.info('utorrent', `Fichier ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : fichiers non disponibles pour ${hash.slice(0, 8)}…`)
}
async function utGetSession(config: Record<string, string | number>): Promise<UTSession> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const headers : Record<string, string> = {
@@ -141,44 +191,50 @@ const UT: TorrentClientDriver = {
})
},
async add(config, url) {
async add(config, url, options?: DownloadOptions) {
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
const hash = hashMatch?.[1]?.toLowerCase() ?? null
if (options?.file_index != null && hash) {
const exists = await utTorrentExists(config, hash)
if (exists) {
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
utApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
}
const session = await utGetSession(config)
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const form = new FormData()
form.append('torrent_url', url)
const qs = new URLSearchParams({
token : session.token,
action: 'add-url',
s : url,
})
if (config.savePath) {
qs.set('download_dir', '1')
qs.set('path', String(config.savePath))
}
const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url })
if (config.savePath) qs.set('path', String(config.savePath))
const res = await fetch(`${config.url}/gui/?${qs}`, {
method : 'GET',
headers: {
'Authorization': `Basic ${auth}`,
'Cookie' : session.cookie,
},
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`)
if (options?.file_index != null && hash) {
logger.info('utorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
utApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
} else {
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
}
// Appliquer le label/catégorie si défini
if (config.category) {
// On ne peut pas set le label au moment de l'ajout, il faut attendre
// que le torrent soit chargé — on le fait via setprops après un délai
setTimeout(async () => {
try {
const s2 = await utGetSession(config)
const data = await utRequest(config, { list: '1' }, s2)
const torrents: any[][] = data?.torrents ?? []
// Trouver le torrent le plus récemment ajouté sans label
const target = torrents.find(t => !t[11])
if (target) {
await utRequest(config, {
@@ -191,8 +247,6 @@ const UT: TorrentClientDriver = {
} catch {}
}, 3000)
}
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
+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
}
+16 -12
View File
@@ -14,7 +14,7 @@
<!-- Chemin éditable -->
<div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
<button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" title="Retour à la racine">
<button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" :title="drivesRoot ? 'Lecteurs' : 'Retour à la racine'">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
@@ -85,12 +85,13 @@ import { ref, watch } from 'vue'
const props = defineProps<{ initialPath?: string }>()
const emit = defineEmits<{ select: [path: string]; cancel: [] }>()
const current = ref(props.initialPath || '/')
const inputPath = ref(current.value)
const dirs = ref<string[]>([])
const parent = ref<string | null>(null)
const loading = ref(false)
const error = ref('')
const current = ref(props.initialPath || '/')
const inputPath = ref(current.value)
const dirs = ref<string[]>([])
const parent = ref<string | null>(null)
const loading = ref(false)
const error = ref('')
const drivesRoot = ref(false) // true quand la liste affiche les lecteurs Windows
// FIX : la regex /\/+$/ dans un @click inline fait planter le parser Vue
function selectCurrent() {
@@ -98,8 +99,10 @@ function selectCurrent() {
}
function joinPath(base: string, name: string): string {
// Lecteur Windows complet (ex: "C:\") → chemin absolu, pas de jointure
if (/^[A-Za-z]:\\$/.test(name)) return name
if (base === '/') return `/${name}`
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base
const cleanBase = base.replace(/[\\/]+$/, '')
return `${cleanBase}/${name}`
}
@@ -114,10 +117,11 @@ async function navigateTo(p: string) {
return
}
const data = await res.json()
current.value = data.path
inputPath.value = data.path
parent.value = data.parent
dirs.value = data.dirs
current.value = data.path
inputPath.value = data.path
parent.value = data.parent
dirs.value = data.dirs
drivesRoot.value = data.drivesRoot ?? false
} catch {
error.value = 'Impossible de lire ce dossier'
} finally {
+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>
@@ -0,0 +1,122 @@
<template>
<div>
<!-- Barre search + tri + colonnes -->
<div class="flex items-center gap-2 mb-4">
<div class="relative flex-1 max-w-xs">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
<input
:value="search"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
type="text" placeholder="Rechercher..."
class="settings-input pl-9 w-full py-1.5 text-sm"
/>
</div>
<!-- Tri -->
<div class="relative shrink-0" ref="sortRef">
<button @click="sortOpen = !sortOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="activeSort !== 'none' ? 'border-accent text-accent' : ''">
<ArrowUpDown :size="14" />
<span class="hidden md:inline text-xs">Trier</span>
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
</button>
<div v-if="sortOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl">
<button v-for="s in sortOptions" :key="s.value" @click="handleSort(s.value)"
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'">
{{ s.label }}
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
</button>
</div>
</div>
<!-- Colonnes -->
<div class="relative shrink-0" ref="colRef">
<button @click="colOpen = !colOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="colOpen ? 'border-accent text-accent' : ''">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="hidden md:inline text-xs">Colonnes</span>
</button>
<div v-if="colOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-52 flex flex-col gap-0.5 shadow-xl">
<p class="text-[10px] text-muted px-3 py-1">Colonnes affichées</p>
<label v-for="col in columnOptions" :key="col.key" class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs cursor-pointer hover:bg-hover transition-colors">
<input type="checkbox" :checked="columns[col.key]" @change="emit('toggle-col', col.key)" class="accent-accent" />
{{ col.label }}
</label>
</div>
</div>
</div>
<!-- Onglets + filtres -->
<div class="flex items-center justify-between mb-4">
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
<button @click="emit('update:activeTab', 'active')" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'active' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
En cours
<span v-if="activeCount > 0" class="ml-1.5 text-[10px] text-accent">{{ activeCount }}</span>
</button>
<button @click="emit('update:activeTab', 'done')" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'done' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
Terminés
<span v-if="doneCount > 0" class="ml-1.5 text-[10px] text-accent">{{ doneCount }}</span>
</button>
</div>
<div v-if="activeTab === 'done'" class="flex items-center gap-3">
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-muted">Seeds uniquement</span>
<button @click="emit('update:seedingOnly', !seedingOnly)" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="seedingOnly ? 'bg-green-500' : 'bg-border'">
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="seedingOnly ? 'translate-x-[18px]' : 'translate-x-0'" />
</button>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-muted">Masquer importés</span>
<button @click="emit('update:hideImported', !hideImported)" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="hideImported ? 'bg-accent' : 'bg-border'">
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'" />
</button>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Search, ArrowUpDown } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
const props = defineProps<{
search : string
activeSort : string
sortDir : 'asc' | 'desc'
activeTab : 'active' | 'done'
seedingOnly : boolean
hideImported: boolean
columns : Record<string, boolean>
activeCount : number
doneCount : number
columnOptions: { key: string; label: string }[]
sortOptions : { label: string; value: string }[]
}>()
const emit = defineEmits<{
'update:search' : [value: string]
'update:activeTab' : [value: 'active' | 'done']
'update:seedingOnly': [value: boolean]
'update:hideImported': [value: boolean]
'sort' : [value: string]
'toggle-col' : [key: string]
}>()
const sortOpen = ref(false)
const colOpen = ref(false)
const sortRef = ref<HTMLElement | null>(null)
const colRef = ref<HTMLElement | null>(null)
onClickOutside(sortRef, () => { sortOpen.value = false })
onClickOutside(colRef, () => { colOpen.value = false })
function handleSort(val: string) {
emit('sort', val)
sortOpen.value = false
}
</script>
+201
View File
@@ -0,0 +1,201 @@
<template>
<div class="settings-card flex flex-col gap-3">
<!-- Nom + état -->
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<!-- 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>
</div>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-semibold text-primary">{{ clampedProgress }}%</p>
<p v-if="columns.size" class="text-xs text-muted mt-0.5">{{ formatSize(torrent.downloaded) }} / {{ formatSize(torrent.size) }}</p>
</div>
</div>
<!-- Barre de progression -->
<div class="h-1 bg-border rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500"
:class="torrent.state === 'seeding' || torrent.state === 'unknown' ? 'bg-green-500' : torrent.state === 'error' ? 'bg-red-500' : torrent.state === 'paused' ? 'bg-muted' : 'bg-accent'"
:style="{ width: `${clampedProgress}%` }" />
</div>
<!-- Infos + actions -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 text-xs text-muted">
<span v-if="torrent.state === 'downloading'">↓ {{ formatSpeed(torrent.speed) }}</span>
<span v-if="torrent.state === 'downloading' && torrent.eta > 0">ETA {{ formatEta(torrent.eta) }}</span>
<template v-if="torrent.state === 'seeding'">
<span class="text-green-500">Complété</span>
<span v-if="columns.ratio" class="font-mono" :class="torrent.ratio >= 1 ? 'text-green-400' : torrent.ratio >= 0.5 ? 'text-yellow-500' : 'text-muted'">R {{ torrent.ratio?.toFixed(2) ?? '0.00' }}</span>
<span v-if="columns.uploaded">↑ {{ formatSize(torrent.uploaded ?? 0) }}</span>
<span v-if="columns.upspeed && torrent.upspeed > 0">{{ formatSpeed(torrent.upspeed) }}</span>
</template>
<span v-if="torrent.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
</div>
<div class="flex items-center gap-2">
<!-- Actions terminés -->
<template v-if="torrent.state === 'seeding' || torrent.state === 'unknown'">
<!-- Erreurs -->
<div v-if="torrent.errorFiles?.length > 0" class="relative group/err">
<span class="text-xs px-2 py-0.5 rounded border border-red-500/40 text-red-400 cursor-default">{{ torrent.errorFiles.length }} erreur{{ torrent.errorFiles.length > 1 ? 's' : '' }}</span>
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
<div class="bg-card border border-red-500/30 rounded-lg p-3 text-xs shadow-xl">
<p class="text-red-400 mb-2 font-medium">Fichiers en erreur</p>
<div v-for="e in torrent.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
<p class="text-primary truncate">{{ e.file }}</p>
<p class="text-muted">{{ e.error }}</p>
</div>
</div>
</div>
</div>
<!-- Badge import -->
<span class="text-xs px-2 py-0.5 rounded border"
:class="torrent.organizeState === 'done' ? 'border-green-500/40 text-green-500' : torrent.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' : 'border-border text-muted'">
{{ torrent.organizeState === 'done'
? `Importé (${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total})`
: torrent.organizeState === 'partial'
? `En cours (${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total})`
: 'Non importé' }}
</span>
<button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing"
class="btn-secondary text-xs py-1 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>
<!-- Bouton supprimer -->
<div class="relative">
<button
@click="emit('toggle-confirm', torrent.hash)"
:disabled="deleting"
class="w-7 h-7 flex items-center justify-center rounded-lg border border-border text-muted hover:border-red-500/40 hover:text-red-400 transition-colors"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
</svg>
</button>
<div v-if="showConfirmDelete" class="absolute bottom-full right-0 mb-2 bg-card border border-border rounded-xl p-3 z-10 w-52 shadow-xl">
<p class="text-xs text-primary font-medium mb-2">
{{ tab === 'active' ? 'Annuler ce téléchargement ?' : 'Supprimer ce torrent ?' }}
</p>
<label class="flex items-center gap-2 text-xs text-muted mb-3 cursor-pointer">
<input type="checkbox" v-model="withFiles" class="accent-red-500" />
Supprimer aussi les fichiers
</label>
<div class="flex gap-1.5">
<button @click="emit('delete', torrent, withFiles)" :disabled="deleting" class="flex-1 text-xs py-1 px-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-colors">
{{ deleting ? '...' : 'Confirmer' }}
</button>
<button @click="emit('toggle-confirm', null)" class="flex-1 text-xs py-1 px-2 rounded-lg border border-border text-muted hover:text-primary transition-colors">
Annuler
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
torrent : any
tab : 'active' | 'done'
columns : Record<string, boolean>
importing : boolean
deleting : boolean
showConfirmDelete: boolean
}>()
const emit = defineEmits<{
import : [torrent: any]
delete : [torrent: any, withFiles: boolean]
'toggle-confirm': [hash: string | null]
}>()
const withFiles = ref(false)
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.torrent.progress ?? 0)))
// É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' },
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
paused : { label: 'En pause', class: 'border-border text-muted' },
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
} as Record<string, any>)[state] ?? { label: 'Inconnu', class: 'border-border text-muted' }
}
function formatSize(bytes: number): string {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
function formatSpeed(bps: number) { return `${formatSize(bps)}/s` }
function formatEta(seconds: number): string {
if (seconds < 0 || seconds > 86400 * 7) return '∞'
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`
if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`
return `${s}s`
}
</script>
+3 -1
View File
@@ -73,7 +73,9 @@ defineProps<{ items: NavItem[] }>()
const router = useRouter()
const auth = useAuthStore()
const currentVersion = ref('dev')
const currentVersion = ref(
import.meta.env.DEV ? 'dev' : ''
)
const latestVersion = ref('')
const latestReleaseUrl = ref('https://github.com/masutayunikon/FanKarr/releases/latest')
const updateAvailable = ref(false)
+74
View File
@@ -0,0 +1,74 @@
<template>
<div class="relative">
<div v-if="serie.fanart_image" class="absolute inset-0 h-64 overflow-hidden pointer-events-none">
<img
:src="serie.fanart_image"
class="w-full h-full object-cover opacity-15"
@error="(e) => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'"
/>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-main" />
</div>
<div class="relative px-4 md:px-8 pt-6 pb-10 flex items-start gap-6 min-h-[200px]">
<!-- Poster -->
<div class="shrink-0 w-32 aspect-[2/3] rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
<img v-if="serie.poster_image" :src="serie.poster_image" class="w-full h-full object-cover" />
<div v-else class="w-full aspect-[2/3] bg-card flex items-center justify-center text-muted">
<Tv :size="28" />
</div>
</div>
<!-- Infos -->
<div class="flex flex-col gap-3 justify-end min-w-0">
<RouterLink to="/series" class="inline-flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-fit">
<ArrowLeft :size="13" />
Séries
</RouterLink>
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ serie.title }}</h1>
<!-- Métadonnées -->
<div class="flex flex-wrap items-center gap-2">
<span v-if="serie.year" class="text-sm text-muted">{{ serie.year }}</span>
<span
v-if="serie.status"
class="text-xs px-2 py-0.5 rounded-full border"
:class="serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/15 text-green-400 border-green-500/20'
: 'bg-hover text-muted border-border'"
>{{ serie.status }}</span>
</div>
<p
v-if="serie.plot"
@click="expanded = !expanded"
class="text-sm text-secondary leading-relaxed max-w-2xl cursor-pointer select-none transition-all"
:class="expanded ? '' : 'line-clamp-3'"
>
{{ serie.plot }}
</p>
<!-- Lien wiki -->
<a
v-if="serie.wiki"
:href="serie.wiki"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors w-fit"
>
<ExternalLink :size="12" />
Wiki
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { ArrowLeft, Tv, ExternalLink } from 'lucide-vue-next'
defineProps<{ serie: any }>()
const expanded = ref(false)
</script>
+755
View File
@@ -0,0 +1,755 @@
<template>
<div class="bg-card border border-border rounded-xl">
<!-- Header saison -->
<div class="flex items-center justify-between px-5 py-3.5 border-b border-border">
<div class="flex items-center gap-3">
<button class="text-muted hover:text-primary transition-colors" @click="emit('toggle', season.id)">
<ChevronUp :size="15" class="transition-transform duration-200" :class="collapsed ? 'rotate-180' : ''" />
</button>
<div>
<h2 class="text-sm font-semibold text-primary">
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted font-normal ml-1">
— {{ season.title }}
</span>
</h2>
<p class="text-xs text-muted mt-0.5">
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
<template v-if="availableCount > 0"> · {{ availableCount }}/{{ season.episodes.length }} dispo</template>
<span
v-if="season.organized_state !== 'none'"
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px]"
:class="season.organized_state === 'complete' ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-500'"
>
{{ season.organized_state === 'complete'
? `✓ ${season.organized_count} importé${season.organized_count > 1 ? 's' : ''}`
: `${season.organized_count}/${availableCount || season.episodes.length} importés` }}
</span>
</p>
</div>
</div>
<!-- Boutons saison -->
<div class="flex items-center gap-2">
<!-- Corbeille saison (admin uniquement) -->
<button
v-if="!requestMode && season.organized_count > 0"
@click.stop="openUnimportSeasonModal"
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20"
title="Désimporter la saison"
>
<Trash2 :size="14" />
</button>
<!-- ── Mode demande ────────────────────────────────────── -->
<template v-if="requestMode">
<button
@click.stop="emit('requestSeason', season.season_number)"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
:class="isSeasonRequested
? 'bg-blue-500/10 text-blue-400 border-blue-500/20 cursor-default'
: 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'"
>
<component :is="isSeasonRequested ? checkIcon : requestIcon" />
{{ isSeasonRequested ? 'Demandé' : 'Demander' }}
</button>
</template>
<!-- ── Mode téléchargement (admin) ─────────────────────── -->
<template v-else>
<!-- Pas de pack saison : bouton "Saison" (dropdown si plusieurs packs via intégrales) -->
<template v-if="season.torrents.length === 0 && hasDownloadable">
<div v-if="uniquePackOptions.length > 1" class="relative" @click.stop>
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
@click="seasonMenuOpen = !seasonMenuOpen"
>
<component :is="downloadIcon" />
Saison ▾
</button>
<div v-if="seasonMenuOpen" class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-56 shadow-xl flex flex-col gap-0.5">
<button
v-for="opt in uniquePackOptions"
:key="opt.infohash"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
@click="emit('downloadSeason', season, opt.infohash); seasonMenuOpen = false"
>
<component :is="downloadIcon" />
{{ opt.label }}
</button>
</div>
</div>
<button
v-else
@click="emit('downloadSeason', season)"
:disabled="downloadingSeason"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border bg-accent-muted text-accent border-accent/20 hover:bg-accent/20 transition"
>
<component :is="downloadingSeason ? loaderIcon : downloadIcon" />
{{ downloadingSeason ? 'Envoi…' : 'Saison' }}
</button>
</template>
<!-- Pack saison unique -->
<button
v-if="season.torrents.length === 1"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
:class="canDownloadSeason
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
: 'text-muted border-border cursor-not-allowed opacity-50'"
:disabled="!canDownloadSeason"
@click="canDownloadSeason && emit('download', `season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
>
<component :is="seasonBtnIcon" />
{{ seasonBtnLabel }}
</button>
<!-- Dropdown packs saison multiples -->
<div v-if="season.torrents.length > 1" class="relative" @click.stop>
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
@click="seasonMenuOpen = !seasonMenuOpen"
>
<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"
: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="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
{{ groupLabels(season.torrents)[i] }}
</button>
</div>
</div>
</template>
</div>
</div>
<!-- Épisodes -->
<div v-if="!collapsed" class="divide-y divide-border/50">
<div
v-for="ep in season.episodes"
:key="ep.id"
class="px-5 py-3 hover:bg-hover/50 transition-colors last:rounded-b-xl"
:class="{ 'opacity-40': !ep.available && !ep.organized }"
>
<div class="flex items-center gap-3">
<!-- Thumbnail -->
<div class="shrink-0 w-20 aspect-video rounded-md overflow-hidden bg-shell hidden sm:flex items-center justify-center text-muted text-xs font-mono">
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
</div>
<!-- Infos épisode -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-xs text-muted shrink-0 font-mono">
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2, '0')}` }}
</span>
<span class="text-sm text-primary truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
<span
v-if="ep.fankai === false || ep.torrent?.fankai === false"
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-purple-500/10 text-purple-400 border-purple-500/20"
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
>Hors Fankai</span>
</div>
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
<span v-if="ep.aired" class="text-xs text-muted">{{ formatDate(ep.aired) }}</span>
<span v-if="ep.duration" class="text-xs text-muted">{{ formatDuration(ep.duration) }}</span>
<!-- Badge langue -->
<span
v-if="epLang(ep) === 'MULTI'"
title="Audio japonais + français"
class="shrink-0 inline-flex items-center gap-1 text-[10px] font-semibold leading-none px-1.5 py-0.5 rounded border bg-blue-500/10 border-blue-500/20 text-blue-300 select-none tracking-wide"
>
<!-- JP flag -->
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#fff"/><circle cx="7" cy="5" r="3" fill="#BC002D"/></svg>
<!-- FR flag -->
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#ED2939"/><rect width="9.33" height="10" fill="#fff"/><rect width="4.67" height="10" fill="#002395"/></svg>
MULTI
</span>
<span
v-else-if="epLang(ep) === 'VOSTFR'"
title="Audio japonais · Sous-titres français"
class="shrink-0 inline-flex items-center gap-1 text-[10px] font-semibold leading-none px-1.5 py-0.5 rounded border bg-amber-500/10 border-amber-500/20 text-amber-300 select-none tracking-wide"
>
<!-- JP flag -->
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#fff"/><circle cx="7" cy="5" r="3" fill="#BC002D"/></svg>
<!-- FR flag -->
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#ED2939"/><rect width="9.33" height="10" fill="#fff"/><rect width="4.67" height="10" fill="#002395"/></svg>
VOSTFR
</span>
<!-- Badge épisodes -->
<span
v-if="parseEpChap(ep.plot).episodes"
class="shrink-0 text-[10px] font-medium leading-none px-1.5 py-0.5 rounded border bg-violet-500/10 border-violet-500/20 text-violet-300 select-none"
:title="`Plage d'épisodes couverts`"
>{{ parseEpChap(ep.plot).episodes }}</span>
<!-- Badge chapitres -->
<span
v-if="parseEpChap(ep.plot).chapters"
class="shrink-0 text-[10px] font-medium leading-none px-1.5 py-0.5 rounded border bg-teal-500/10 border-teal-500/20 text-teal-300 select-none"
:title="`Plage de chapitres manga couverts`"
>{{ parseEpChap(ep.plot).chapters }}</span>
</div>
</div>
<!-- Bouton é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'"
@click="handleEpBtnClick(ep)"
>
<component :is="epStateIcon(ep)" />
</button>
<!-- Dropdown sélection torrent — mode demande -->
<div v-if="requestMode && epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1"
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-72 shadow-xl flex flex-col gap-0.5">
<p class="px-3 pt-1.5 pb-0.5 text-[10px] text-muted font-medium uppercase tracking-wide">Choisir le torrent</p>
<button
v-for="(t, i) in ep.torrents"
:key="i"
:title="t.raw ?? t.torrent_name ?? ''"
class="flex items-start gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
@click="emit('requestEpisode', season.season_number, ep.id, t); epOptionsOpen = null"
>
<component :is="requestIcon" class="shrink-0 mt-0.5" />
<span class="whitespace-normal break-words leading-relaxed">{{ t.raw ?? t.torrent_name ?? `Option ${i + 1}` }}</span>
</button>
</div>
<!-- Dropdown sélection torrent — mode téléchargement -->
<div v-if="!requestMode && epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1"
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
<button
v-for="(t, i) in ep.torrents"
:key="i"
:title="t.raw ?? t.torrent_name ?? ''"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
:class="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
:disabled="isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)"
@click="!isDownloaded(`ep-${ep.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `ep-${ep.id}-${i}`, t.torrent_url, t.magnet, t.file_index ?? null, t.file_path ?? null, t.infohash ?? null), epOptionsOpen = null)"
>
<component :is="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
{{ groupLabels(ep.torrents)[i] }}
</button>
</div>
</div>
<!-- Actions épisode importé (admin uniquement) -->
<template v-if="!requestMode && ep.organized && organizedByEpisode[String(ep.id)]">
<!-- Badge rename cliquable -->
<button
v-if="epNeedsRename(ep)"
@click.stop="emit('renameEpisode', ep, season)"
:disabled="epActionLoading[ep.id]"
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-yellow-500/10 text-yellow-400 border-yellow-500/20 hover:bg-yellow-500/20 transition-colors"
:title="`Actuel : ${organizedByEpisode[String(ep.id)]?.dest_filename}\nAttendu : ${epExpectedName(ep)}`"
>Rename</button>
<!-- Corbeille épisode -->
<button
@click.stop="openUnimportModal(ep, season)"
:disabled="epActionLoading[ep.id]"
class="w-6 h-6 flex items-center justify-center rounded text-red-400 hover:bg-red-500/10 transition-colors"
title="Désimporter l'épisode"
>
<Trash2 :size="12" />
</button>
</template>
<!-- Chevron synopsis (à droite, visible si l'épisode a un plot) -->
<button
v-if="ep.plot"
@click.stop="togglePlot(ep.id)"
class="w-6 h-6 flex items-center justify-center rounded transition-colors shrink-0"
:class="plotOpen === ep.id ? 'text-accent' : 'text-muted hover:text-primary'"
title="Voir le synopsis"
>
<ChevronUp :size="14" class="transition-transform duration-200" :class="plotOpen === ep.id ? '' : 'rotate-180'" />
</button>
</div>
<!-- Barre progression épisode -->
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="mt-1.5 sm:pl-[92px]">
<div class="h-0.5 bg-border rounded-full overflow-hidden">
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${epProgress(ep)!.progress}%` }" />
</div>
</div>
<!-- Synopsis -->
<div v-if="plotOpen === ep.id && ep.plot" class="mt-2 sm:pl-[92px]">
<p class="text-xs text-muted leading-relaxed" style="white-space: pre-line">{{ parseEpChap(ep.plot).cleanPlot }}</p>
</div>
</div>
</div>
</div>
<!-- Modal désimport épisode -->
<Teleport to="body">
<div v-if="unimportModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportModal = false">
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter l'épisode</h3>
<p class="text-xs text-muted mb-4">L'épisode sera retiré de la bibliothèque.</p>
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
<input type="checkbox" v-model="deleteFileOnUnimport" class="accent-red-500" />
<span class="text-xs text-muted">Supprimer le fichier du disque</span>
</label>
<div class="flex gap-2 justify-end">
<button @click="unimportModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
<button @click="confirmUnimport" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
{{ deleteFileOnUnimport ? 'Supprimer' : 'Désimporter' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Modal désimport saison -->
<Teleport to="body">
<div v-if="unimportSeasonModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportSeasonModal = false">
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter la saison</h3>
<p class="text-xs text-muted mb-4">Tous les épisodes importés de cette saison seront retirés de la bibliothèque.</p>
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
<input type="checkbox" v-model="deleteSeasonFiles" class="accent-red-500" />
<span class="text-xs text-muted">Supprimer les fichiers du disque</span>
</label>
<div class="flex gap-2 justify-end">
<button @click="unimportSeasonModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
<button @click="confirmUnimportSeason" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
{{ deleteSeasonFiles ? 'Supprimer' : 'Désimporter' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
import { ChevronUp, Download, Loader, Check, X, Trash2 } from 'lucide-vue-next'
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number; priority?: number }[] }
const props = defineProps<{
season : any
collapsed : boolean
activeTorrents : ActiveTorrent[]
downloading : string[]
downloaded : string[]
organizedByEpisode: Record<string, any>
epActionLoading : Record<number, boolean>
downloadingSeason ?: boolean
nfoSupport ?: boolean
requestMode ?: boolean // true = boutons demande au lieu de téléchargement
requestedSeasons ?: number[] // saisons déjà demandées par cet user pour cette série
requestedEpisodes ?: number[] // IDs d'épisodes déjà demandés par cet user pour cette série
}>()
const emit = defineEmits<{
toggle : [id: number]
download : [key: string, url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null]
downloadSeason : [season: any, packHash?: string]
renameEpisode : [ep: any, season: any]
unimportEpisode: [ep: any, season: any, deleteFile: boolean]
unimportSeason : [season: any, deleteFiles: boolean]
// Mode demande
requestSeason : [seasonNumber: number]
requestEpisode : [seasonNumber: number, episodeId: number, torrent?: any]
}>()
const requestIcon = h('svg', { viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': 2, fill: 'none', width: 12, height: 12 },
[h('circle', { cx: 12, cy: 12, r: 10 }), h('path', { d: 'M12 8v4l2 2' })]
)
const isSeasonRequested = computed(() =>
props.requestedSeasons?.includes(props.season.season_number) ?? false
)
/** Un épisode est "demandé" si sa saison l'est, ou si son ID est dans requestedEpisodes */
function isEpisodeRequested(episodeId: number): boolean {
return isSeasonRequested.value || (props.requestedEpisodes?.includes(episodeId) ?? false)
}
const epOptionsOpen = ref<number | null>(null)
const plotOpen = ref<number | null>(null)
const seasonMenuOpen = ref(false)
const unimportModal = ref(false)
const deleteFileOnUnimport = ref(false)
const unimportEpTarget = ref<{ ep: any; season: any } | null>(null)
const unimportSeasonModal = ref(false)
const deleteSeasonFiles = ref(false)
const downloadIcon = h(Download, { size: 12 })
const loaderIcon = h(Loader, { size: 12, class: 'animate-spin' })
const checkIcon = h(Check, { size: 12 })
// ── Helpers hash / progress ────────────────────────────────────
function extractHash(torrent: any): string | null {
// Préférer l'infohash direct (présent même si le magnet est absent)
if (torrent?.infohash) return torrent.infohash.toLowerCase()
if (!torrent?.magnet) return null
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
return m ? m[1].toLowerCase() : null
}
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
if (!hash) return null
return props.activeTorrents.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
}
function isDownloading(key: string) { return props.downloading.includes(key) }
function isDownloaded(key: string) { return props.downloaded.includes(key) }
function isAlreadyQueued(torrent: any): boolean {
// Pour un fichier dans un pack (file_index défini), le hash seul ne suffit pas :
// le pack peut être actif sans que CE fichier spécifique soit en téléchargement.
// On s'appuie uniquement sur downloading/downloaded pour ces cas.
if (torrent?.file_index != null) return false
const hash = extractHash(torrent)
if (!hash) return false
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase())
}
// ── Computed saison ────────────────────────────────────────────
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
const seasonProgress = computed(() => {
if (props.season.torrents && props.season.torrents.length > 1) {
for (const t of props.season.torrents) {
const prog = torrentProgress(extractHash(t))
if (prog) return prog
}
return null
}
return torrentProgress(extractHash(props.season.torrent))
})
const hasDownloadable = computed(() =>
props.season.episodes.some((ep: any) =>
ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)
)
)
const canDownloadSeason = computed(() =>
!!props.season.torrent && !isDownloading(`season-${props.season.id}`) && !isDownloaded(`season-${props.season.id}`) && !isAlreadyQueued(props.season.torrent) && props.season.organized_state !== 'complete'
)
const seasonBtnLabel = computed(() => {
if (props.season.organized_state === 'complete') return 'Importé'
if (isAlreadyQueued(props.season.torrent)) return 'Déjà ajouté'
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyé'
return 'Saison entière'
})
const seasonBtnIcon = computed(() => {
if (props.season.organized_state === 'complete' || isDownloaded(`season-${props.season.id}`) || isAlreadyQueued(props.season.torrent)) return h(Check, { size: 12 })
if (isDownloading(`season-${props.season.id}`)) return h(Loader, { size: 12, class: 'animate-spin' })
if (!props.season.torrent) return h(X, { size: 12 })
return h(Download, { size: 12 })
})
// ── État épisode ───────────────────────────────────────────────
// Vérifie si n'importe quelle clé de téléchargement pour cet épisode est marquée done/downloading
// (couvre ep-{id} pour torrent unique ET ep-{id}-{i} pour multi-torrent)
function epAnyDownloaded(ep: any): boolean {
if (isDownloaded(`ep-${ep.id}`)) return true
const count = ep.torrents?.length ?? 0
for (let i = 0; i < count; i++) { if (isDownloaded(`ep-${ep.id}-${i}`)) return true }
return false
}
function epAnyDownloading(ep: any): boolean {
if (isDownloading(`ep-${ep.id}`)) return true
const count = ep.torrents?.length ?? 0
for (let i = 0; i < count; i++) { if (isDownloading(`ep-${ep.id}-${i}`)) return true }
return false
}
function epProgress(ep: any): ActiveTorrent | null {
if (ep.organized) return null
// Chercher parmi toutes les options de torrent, pas seulement ep.torrent (= torrents[0])
const candidates: Array<{ torrent: any; key0: string; keyI: string }> = []
if (ep.torrents?.length > 0) {
ep.torrents.forEach((t: any, i: number) => candidates.push({ torrent: t, key0: `ep-${ep.id}`, keyI: `ep-${ep.id}-${i}` }))
} else if (ep.torrent) {
candidates.push({ torrent: ep.torrent, key0: `ep-${ep.id}`, keyI: `ep-${ep.id}` })
}
for (const { torrent: t, key0, keyI } of candidates) {
const active = torrentProgress(extractHash(t))
if (!active) continue
if (t.file_index != null) {
const sessionDl = isDownloaded(key0) || isDownloaded(keyI)
if (active.files && active.files.length > 0) {
const file = active.files.find(f => f.index === t.file_index)
// Avec données par fichier on se fie à la progression réelle du fichier précis.
// Si le fichier est à 100% sans session active → déjà téléchargé hors session
// (ex : désimporté puis re-consulté) → ne pas afficher le spinner.
if (file != null && file.priority !== 0 && (sessionDl || file.progress < 1)) {
return { ...active, progress: Math.round(file.progress * 100) }
}
if (!sessionDl) continue
} else {
// Pas de données par fichier : ignorer si pas de session active
if (!sessionDl) continue
return { ...active, progress: active.state === 'seeding' ? 100 : active.progress }
}
}
return active
}
return null
}
function handleEpBtnClick(ep: any) {
if (ep.organized) return
if (ep.torrents && ep.torrents.length > 1) {
// Plusieurs options → toggle dropdown
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
return
}
// Option unique → téléchargement direct
if (ep.torrent && !isAlreadyQueued(ep.torrent)) {
emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet, ep.torrent.file_index ?? null, ep.torrent.file_path ?? null, ep.torrent.infohash ?? null)
}
}
function handleEpRequestClick(ep: any) {
if (isEpisodeRequested(ep.id)) return
if (ep.torrents && ep.torrents.length > 1) {
// 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'
// Spinner si : envoyé au client, déjà en cours dans le client, ou fichier en cours/terminé
if (epAnyDownloaded(ep) || epAnyDownloading(ep) || isAlreadyQueued(ep.torrent)) return 'loading'
const prog = epProgress(ep)
if (prog) return 'loading'
if (!ep.torrent || !ep.available) return 'unavailable'
return 'idle'
}
function epStateIcon(ep: any) {
const state = epState(ep)
if (state === 'done') return h(Check, { size: 14 })
if (state === 'loading') return h(Loader, { size: 14, class: 'animate-spin' })
if (state === 'unavailable') return h(X, { size: 14 })
return h(Download, { size: 14 })
}
function epBtnClass(ep: any): string {
const state = epState(ep)
if (state === 'done') return 'border-green-500/30 text-green-500 bg-green-500/10 cursor-default'
if (state === 'loading') return 'border-accent/30 text-accent bg-accent-muted cursor-default'
if (state === 'unavailable') return 'border-border text-muted opacity-40 cursor-not-allowed'
return 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'
}
// ── Rename helpers ─────────────────────────────────────────────
function epExpectedName(ep: any): string {
const entry = props.organizedByEpisode[String(ep.id)]
if (!entry) return ''
const srcExt = entry.dest_filename ? '.' + entry.dest_filename.split('.').pop() : '.mkv'
if (props.nfoSupport) {
return ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : entry.dest_filename
} else {
// Préférer le formatted_name du torrent qui correspond au fichier importé.
// On essaie de matcher par hash (si l'entrée organized l'expose), sinon on
// cherche le premier torrent qui a un formatted_name.
const usedHash = entry.hash?.toLowerCase() ?? null
const matchedName = usedHash
? (ep.torrents ?? []).find((t: any) => t.infohash === usedHash)?.formatted_name
: null
const name = matchedName
?? (ep.torrents ?? []).find((t: any) => t.formatted_name)?.formatted_name
?? ep.formatted_name
return name?.trim() ? name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : entry.dest_filename
}
}
function epNeedsRename(ep: any): boolean {
const entry = props.organizedByEpisode[String(ep.id)]
if (!entry) return false
return entry.needs_rename === true
}
// ── Extraction épisodes / chapitres depuis le synopsis ─────────
// Cache léger pour éviter de recalculer pour chaque accès template (badges + synopsis)
const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parseEpChap>>()
/**
* Extrait les plages d'épisodes et chapitres depuis un synopsis.
* Ex: "...épisodes 628 à 634 soit les chapitres 700 à 706."
* → { episodes: "628–634", chapters: "700–706", cleanPlot: "..." }
* La phrase (ou ligne) contenant ces infos est retirée du synopsis affiché.
*/
function _parseEpChap(plot: string | null | undefined): {
episodes: string | null
chapters: string | null
cleanPlot: string
} {
const text = plot ?? ''
const epMatch = text.match(/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
const chapMatch = text.match(/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
const episodes = epMatch
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`)
: null
const chapters = chapMatch
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
: null
// Retirer les lignes contenant les références d'épisodes ou chapitres
let cleanPlot = text
if (episodes || chapters) {
cleanPlot = text
.split(/\r?\n/)
.filter(line => !/[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+/i.test(line))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
return { episodes, chapters, cleanPlot }
}
function parseEpChap(plot: string | null | undefined) {
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
const result = _parseEpChap(plot)
_epChapCache.set(plot, result)
return result
}
// ── Détection langue ───────────────────────────────────────────
function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
const sources: string[] = [
ep.formatted_name ?? '',
ep.torrent?.torrent_name ?? '',
ep.torrent?.raw ?? '',
...((ep.torrents ?? []) as any[]).map((t: any) => `${t.torrent_name ?? ''} ${t.raw ?? ''}`),
]
for (const s of sources) {
if (!s) continue
if (/\bMULTI\b/i.test(s)) return 'MULTI'
if (/\bVOSTFR\b/i.test(s)) return 'VOSTFR'
}
return null
}
// ── Labels de groupe ───────────────────────────────────────────
// Si tous les torrent_name sont présents ET distincts → on les utilise.
// Sinon → titre Nyaa brut (raw), plus informatif quand les noms se ressemblent.
function labelFromRaw(raw: string, index: number): string {
if (!raw) return `Option ${index + 1}`
return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
}
function groupLabels(torrents: any[]): string[] {
const names = torrents.map((t: any) => t.torrent_name ?? null)
const allPresent = names.every(n => n !== null && String(n).trim() !== '')
const allUnique = allPresent && new Set(names).size === names.length
if (allUnique) return names as string[]
// Fallback : titre Nyaa brut
return torrents.map((t: any, i: number) => labelFromRaw(t.raw ?? '', i))
}
// Packs parents distincts couvrant cette saison (quand pas de pack saison explicite)
// Permet le dropdown "Saison ▾" quand une saison est couverte par plusieurs intégrales.
// 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 []
// 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) continue
hashCount.set(hash, (hashCount.get(hash) ?? 0) + 1)
if (!hashTorrent.has(hash)) hashTorrent.set(hash, t)
}
}
// 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 {
if (!d) return ''
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
}
function formatDuration(seconds: number): string {
if (!seconds || seconds <= 0) return ''
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) return m > 0 ? `${h}h ${m}min` : `${h}h`
return `${m} min`
}
function togglePlot(epId: number) {
plotOpen.value = plotOpen.value === epId ? null : epId
}
function openUnimportModal(ep: any, season: any) {
unimportEpTarget.value = { ep, season }
deleteFileOnUnimport.value = false
unimportModal.value = true
}
function confirmUnimport() {
if (!unimportEpTarget.value) return
emit('unimportEpisode', unimportEpTarget.value.ep, unimportEpTarget.value.season, deleteFileOnUnimport.value)
unimportModal.value = false
unimportEpTarget.value = null
}
function openUnimportSeasonModal() {
deleteSeasonFiles.value = false
unimportSeasonModal.value = true
}
function confirmUnimportSeason() {
emit('unimportSeason', props.season, deleteSeasonFiles.value)
unimportSeasonModal.value = false
}
function closeAllMenus() {
epOptionsOpen.value = null
seasonMenuOpen.value = false
}
onMounted(() => document.addEventListener('click', closeAllMenus))
onUnmounted(() => document.removeEventListener('click', closeAllMenus))
defineExpose({ closeEpMenu: closeAllMenus })
</script>
+152
View File
@@ -0,0 +1,152 @@
<template>
<div>
<!-- Toolbar -->
<div class="shrink-0 px-4 md:px-6 py-3 border-b border-border flex flex-col gap-2">
<!-- Ligne 1 : recherche + filtres -->
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
<input
:value="search"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
type="text"
placeholder="Rechercher…"
class="settings-input pl-9 w-full py-1.5 text-sm"
/>
</div>
<!-- Filtres -->
<div class="relative shrink-0" ref="filterRef">
<button
@click="filterOpen = !filterOpen"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 relative"
:class="hasActiveFilter ? 'border-accent text-accent' : ''"
title="Filtres"
>
<SlidersHorizontal :size="15" />
<span class="hidden md:inline text-xs">Filtres</span>
<ChevronDown class="hidden md:inline transition-transform" :size="12" :class="filterOpen ? 'rotate-180' : ''" />
<span v-if="hasActiveFilter" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
</button>
<div
v-if="filterOpen"
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-3 z-20 w-52 flex flex-col gap-3 shadow-xl"
>
<div>
<p class="text-xs text-muted mb-1.5">Disponibilité</p>
<div class="flex flex-col gap-1">
<button
v-for="f in filtersDisponibilite" :key="f.value"
@click="emit('update:activeFilter', f.value)"
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
{{ f.label }}
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
</button>
</div>
</div>
<div class="border-t border-border pt-3">
<p class="text-xs text-muted mb-1.5">État import</p>
<div class="flex flex-col gap-1">
<button
v-for="f in filtersImport" :key="f.value"
@click="emit('update:activeFilter', f.value)"
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full shrink-0" :style="{ background: f.color }" />
{{ f.label }}
</div>
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
</button>
</div>
</div>
<button
v-if="hasActiveFilter"
@click="emit('update:activeFilter', 'all')"
class="text-xs text-muted hover:text-primary transition-colors text-left pt-1 border-t border-border"
>
Effacer les filtres
</button>
</div>
</div>
</div>
<!-- Ligne 2 : tri + taille + count -->
<div class="flex items-center gap-2 flex-wrap">
<div class="flex items-center gap-1">
<button
v-for="s in sortOptions" :key="s.value"
@click="emit('update:activeSort', s.value)"
class="px-2.5 py-1 text-xs rounded-lg border transition-colors"
:class="activeSort === s.value
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ s.label }}
</button>
</div>
<div class="flex items-center gap-1 border-l border-border pl-2 ml-auto">
<button
v-for="(_, size) in posterSizes" :key="size"
@click="emit('update:posterSize', size)"
class="w-7 h-7 text-[10px] rounded-md border transition-colors flex items-center justify-center"
:class="posterSize === size
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ size }}
</button>
</div>
<span class="text-xs text-muted">{{ count }} série{{ count > 1 ? 's' : '' }}</span>
</div>
</div>
<!-- Légende barres -->
<div class="shrink-0 px-4 md:px-6 py-2 flex items-center gap-4 border-b border-border">
<div v-for="l in legend" :key="l.label" class="flex items-center gap-1.5">
<span class="w-3 h-1.5 rounded-full" :style="{ background: l.color }" />
<span class="text-[11px] text-muted">{{ l.label }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Search, SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
defineProps<{
search : string
activeFilter : string
activeSort : string
posterSize : string
posterSizes : Record<string, string>
count : number
hasActiveFilter : boolean
filtersDisponibilite: { label: string; value: string }[]
filtersImport : { label: string; value: string; color: string }[]
sortOptions : { label: string; value: string }[]
legend : { label: string; color: string }[]
}>()
const emit = defineEmits<{
'update:search' : [value: string]
'update:activeFilter': [value: string]
'update:activeSort' : [value: string]
'update:posterSize' : [value: string]
}>()
const filterOpen = ref(false)
const filterRef = ref<HTMLElement | null>(null)
onClickOutside(filterRef, () => { filterOpen.value = false })
</script>
+304
View File
@@ -0,0 +1,304 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="emit('close')">
<div class="bg-card border border-border rounded-xl w-full max-w-lg flex flex-col" style="max-height: 85vh">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div>
<h3 class="text-sm font-semibold text-primary">Configuration Plex</h3>
<p class="text-xs text-muted mt-0.5">
{{ plexStep === 'auth' ? 'Connectez votre compte Plex' :
plexStep === 'server' ? 'Choisissez votre serveur Plex' :
plexStep === 'library' ? 'Créez la bibliothèque Fankai' :
plexStep === 'done' ? 'Configuration terminée' :
'Erreur' }}
</p>
</div>
<button @click="emit('close')" class="text-muted hover:text-primary text-lg leading-none">✕</button>
</div>
<!-- Étape 1 : Auth -->
<div v-if="plexStep === 'auth'" class="flex flex-col gap-4 px-6 py-5">
<div v-if="!plexAuthMethod" class="flex flex-col gap-3">
<button
@click="startOAuth" :disabled="plexLoading"
class="flex items-center gap-3 p-4 rounded-lg border border-border hover:border-accent hover:bg-accent-muted transition-colors text-left"
>
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#E5A50A" opacity=".2"/>
<path d="M12 6v6l4 2" stroke="#E5A50A" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-primary">{{ plexLoading ? 'Ouverture…' : 'Connexion via Plex' }}</p>
<p class="text-xs text-muted">Google, Apple, ou compte Plex</p>
</div>
</button>
<button
@click="plexAuthMethod = 'credentials'"
class="flex items-center gap-3 p-4 rounded-lg border border-border hover:border-secondary transition-colors text-left"
>
<div class="w-8 h-8 rounded-lg bg-hover flex items-center justify-center shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-primary">Email / Mot de passe</p>
<p class="text-xs text-muted">Connexion directe avec vos identifiants</p>
</div>
</button>
</div>
<!-- OAuth en attente -->
<div v-else-if="plexAuthMethod === 'oauth'" class="flex flex-col items-center gap-4 py-4 text-center">
<div class="w-10 h-10 border-2 border-border border-t-accent rounded-full animate-spin" />
<div>
<p class="text-sm text-primary font-medium">En attente de la connexion Plex…</p>
<p class="text-xs text-muted mt-1">Connectez-vous sur la page Plex qui s'est ouverte dans votre navigateur</p>
</div>
<button @click="cancelOAuth" class="text-xs text-muted hover:text-primary transition-colors">Annuler</button>
</div>
<!-- Formulaire credentials -->
<template v-else-if="plexAuthMethod === 'credentials'">
<div>
<label class="settings-label mb-1 block">Adresse e-mail Plex</label>
<input v-model="plexForm.username" type="email" class="settings-input w-full" placeholder="email@example.com" />
</div>
<div>
<label class="settings-label mb-1 block">Mot de passe</label>
<input v-model="plexForm.password" type="password" class="settings-input w-full" placeholder="••••••••" @keyup.enter="plexConnect" />
</div>
<div v-if="plexNeeds2FA">
<label class="settings-label mb-1 block">Code 2FA</label>
<input v-model="plexForm.code" type="text" class="settings-input w-full" placeholder="123456" maxlength="6" @keyup.enter="plexConnect" />
</div>
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
<div class="flex gap-2">
<button @click="plexConnect" :disabled="plexLoading || !plexForm.username || !plexForm.password" class="btn-primary">
{{ plexLoading ? 'Connexion…' : 'Se connecter' }}
</button>
<button @click="plexAuthMethod = null" class="btn-secondary">Retour</button>
</div>
</template>
<p v-if="plexError && !plexAuthMethod" class="text-xs text-red-400">{{ plexError }}</p>
<button v-if="!plexAuthMethod" @click="emit('close')" class="btn-secondary self-start">Annuler</button>
</div>
<!-- Étape 2 : Choix serveur -->
<div v-else-if="plexStep === 'server'" class="flex flex-col gap-3 px-6 py-5">
<p class="text-xs text-muted">{{ plexServers.length }} serveur{{ plexServers.length > 1 ? 's' : '' }} trouvé{{ plexServers.length > 1 ? 's' : '' }}</p>
<div
v-for="(server, i) in plexServers" :key="i"
@click="selectServer(server)"
class="flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors"
:class="plexSelectedServer === server ? 'border-accent bg-accent-muted' : 'border-border hover:border-secondary'"
>
<div>
<p class="text-sm text-primary font-medium">{{ server.name }}</p>
<p class="text-xs text-muted">{{ server.owned ? 'Propriétaire' : 'Partagé' }} · {{ server.connections.length }} connexion{{ server.connections.length > 1 ? 's' : '' }}</p>
</div>
<svg v-if="plexSelectedServer === server" width="14" height="14" viewBox="0 0 24 24" stroke="#e5a50a" stroke-width="2.5" fill="none"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
<div class="flex gap-2 mt-1">
<button @click="plexConnectServer" :disabled="!plexSelectedServer || plexLoading" class="btn-primary">
{{ plexLoading ? 'Connexion…' : 'Continuer' }}
</button>
<button @click="plexStep = 'auth'" class="btn-secondary">Retour</button>
</div>
</div>
<!-- Étape 3 : Bibliothèque -->
<div v-else-if="plexStep === 'library'" class="flex flex-col gap-4 px-6 py-5">
<div>
<label class="settings-label mb-1 block">Nom de la bibliothèque</label>
<input v-model="plexForm.libraryName" type="text" class="settings-input w-full" placeholder="Fankai" />
</div>
<div>
<label class="settings-label mb-1 block">Chemin de la médiathèque</label>
<p class="text-xs text-muted mb-1.5">Chemin tel que vu par votre serveur Plex</p>
<input v-model="plexForm.libraryPath" type="text" class="settings-input w-full font-mono" placeholder="/data/media/fankai" />
</div>
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
<div class="flex gap-2">
<button @click="plexSetup" :disabled="plexLoading || !plexForm.libraryName || !plexForm.libraryPath" class="btn-primary">
{{ plexLoading ? 'Création…' : 'Créer la bibliothèque' }}
</button>
<button @click="plexStep = 'server'" class="btn-secondary">Retour</button>
</div>
</div>
<!-- Étape 4 : Résultat -->
<div v-else-if="plexStep === 'done'" class="flex flex-col gap-4 px-6 py-5">
<div class="flex flex-col gap-2">
<div v-for="s in plexSteps" :key="s.step" class="flex items-center gap-2.5">
<div class="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
:class="s.ok ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'">
<svg v-if="s.ok" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2" fill="none"><line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/></svg>
</div>
<p class="text-xs" :class="s.ok ? 'text-primary' : 'text-red-400'">{{ s.message }}</p>
</div>
</div>
<div v-if="plexManualSetup" class="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-3">
<p class="text-xs text-yellow-400 font-medium mb-2">⚠ Configuration manuelle de l'agent requise (Plex &lt; 1.43)</p>
<ol class="text-xs text-muted space-y-1 list-decimal list-inside">
<li>Rendez-vous sur la page <span class="text-primary">Metadata Agent</span> de Plex</li>
<li>Dans <span class="text-primary">Metadata Provider</span>, ajoutez : <code class="bg-hover px-1 rounded">https://metadata.fankai.fr/plex</code></li>
<li>Dans <span class="text-primary">Metadata Agent</span>, créez un agent avec ce provider</li>
<li>Dans votre bibliothèque → ⋯ → <span class="text-primary">Manage Library → Edit → Advanced → Agent</span>, sélectionnez l'agent Fankai</li>
<li>Rafraîchissez les métadonnées</li>
</ol>
</div>
<button @click="emit('close')" class="btn-primary">Fermer</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
const props = defineProps<{ mediaPath: string }>()
const emit = defineEmits<{ close: [] }>()
const plexStep = ref<'auth' | 'server' | 'library' | 'done'>('auth')
const plexLoading = ref(false)
const plexError = ref('')
const plexNeeds2FA = ref(false)
const plexToken = ref('')
const plexServers = ref<any[]>([])
const plexSelectedServer = ref<any>(null)
const plexServerUrl = ref('')
const plexManualSetup = ref(false)
const plexSteps = ref<{ step: string; ok: boolean; message: string }[]>([])
const plexAuthMethod = ref<'oauth' | 'credentials' | null>(null)
const plexOAuthPinId = ref<string | null>(null)
let plexOAuthTimer: ReturnType<typeof setInterval> | null = null
const plexForm = ref({
username : '',
password : '',
code : '',
libraryName: 'Fankai',
libraryPath: props.mediaPath,
})
// ── OAuth ─────────────────────────────────────────────────────
async function startOAuth() {
plexError.value = ''
plexLoading.value = true
try {
const res = await fetch('/api/plex/oauth/start', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { plexError.value = data.error ?? 'Erreur OAuth'; return }
plexOAuthPinId.value = String(data.pinId)
plexAuthMethod.value = 'oauth'
window.open(data.authUrl, '_blank')
let attempts = 0
plexOAuthTimer = setInterval(async () => {
attempts++
if (attempts > 150) { cancelOAuth(); plexError.value = 'Délai expiré — réessayez'; return }
try {
const r = await fetch(`/api/plex/oauth/poll/${plexOAuthPinId.value}`, { credentials: 'include' })
const d = await r.json()
if (d.ok && !d.pending) {
cancelOAuth()
plexToken.value = d.token
plexServers.value = d.servers
if (d.servers.length === 1) { plexSelectedServer.value = d.servers[0]; await plexConnectServer() }
else plexStep.value = 'server'
}
} catch {}
}, 2000)
} catch {
plexError.value = "Impossible de démarrer l'authentification"
} finally {
plexLoading.value = false
}
}
function cancelOAuth() {
if (plexOAuthTimer) { clearInterval(plexOAuthTimer); plexOAuthTimer = null }
plexOAuthPinId.value = null
if (plexAuthMethod.value === 'oauth') plexAuthMethod.value = null
}
// ── Credentials ───────────────────────────────────────────────
async function plexConnect() {
plexError.value = ''
plexLoading.value = true
try {
const res = await fetch('/api/plex/connect', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ username: plexForm.value.username, password: plexForm.value.password, code: plexForm.value.code || undefined }),
})
const data = await res.json()
if (!res.ok) {
if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez votre code 2FA' }
else plexError.value = data.error ?? 'Erreur de connexion'
return
}
plexToken.value = data.token
plexServers.value = data.servers
if (data.servers.length === 1) { plexSelectedServer.value = data.servers[0]; await plexConnectServer() }
else plexStep.value = 'server'
} catch {
plexError.value = 'Impossible de contacter le serveur'
} finally {
plexLoading.value = false
}
}
function selectServer(server: any) { plexSelectedServer.value = server }
async function plexConnectServer() {
if (!plexSelectedServer.value) return
plexError.value = ''
plexLoading.value = true
try {
const conns = plexSelectedServer.value.connections ?? []
const conn = conns.find((c: any) => c.local && !c.relay) ?? conns.find((c: any) => !c.local && !c.relay) ?? conns.find((c: any) => c.relay)
plexServerUrl.value = conn?.uri ?? ''
if (!plexServerUrl.value) throw new Error('Aucune URL de connexion disponible')
plexForm.value.libraryPath = props.mediaPath
plexStep.value = 'library'
} catch (err) {
plexError.value = err instanceof Error ? err.message : 'Erreur'
} finally {
plexLoading.value = false
}
}
async function plexSetup() {
plexError.value = ''
plexLoading.value = true
try {
const res = await fetch('/api/plex/setup', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ token: plexToken.value, serverUrl: plexServerUrl.value, libraryName: plexForm.value.libraryName, libraryPath: plexForm.value.libraryPath }),
})
const data = await res.json()
plexSteps.value = data.steps ?? []
plexManualSetup.value = data.manualSetup ?? false
plexStep.value = 'done'
if (!data.ok) plexError.value = data.error ?? 'Erreur lors du setup'
} catch {
plexError.value = 'Impossible de contacter le serveur'
} finally {
plexLoading.value = false
}
}
onUnmounted(() => cancelOAuth())
</script>
+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)
}
+56 -30
View File
@@ -16,7 +16,7 @@
</Transition>
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-sidebar shrink-0">
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-sidebar shrink-0 relative z-10">
<button @click="mobileOpen = true" class="text-muted hover:text-primary transition-colors">
<Menu :size="20" />
</button>
@@ -39,45 +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: '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>
+52 -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',
@@ -63,6 +80,11 @@ const router = createRouter({
name: 'settings-media',
component: () => import('@/views/settings/MediaManagementView.vue'),
},
{
path: 'import-management',
name: 'setting-import',
component: () => import('@/views/settings/ImportManagementView.vue'),
},
{
path: 'catalogue',
name: 'settings-catalogue',
@@ -78,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'),
},
],
},
],
@@ -92,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 }
})
+2 -2
View File
@@ -100,12 +100,12 @@ export const useSeriesStore = defineStore('series', () => {
}
}
async function download(torrent_url: string | null, magnet: string | null): Promise<{ success: boolean; error?: string }> {
async function download(torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null): Promise<{ success: boolean; error?: string }> {
try {
const res = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ torrent_url, magnet })
body: JSON.stringify({ torrent_url, magnet, file_index: file_index ?? null, file_path: file_path ?? null, infohash: infohash ?? null })
})
const data = await res.json()
if (res.ok) return { success: true }
+79 -11
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()
+63 -415
View File
@@ -7,27 +7,19 @@
<div class="flex items-center gap-3">
<h1 class="text-lg font-semibold text-primary">Activité</h1>
<div class="flex items-center gap-1.5">
<span
class="w-1.5 h-1.5 rounded-full transition-colors"
:class="polling ? 'bg-green-500 animate-pulse' : 'bg-border'"
/>
<span class="w-1.5 h-1.5 rounded-full transition-colors" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-border'" />
<span class="text-xs text-muted">{{ polling ? 'En direct' : 'Pause' }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<button @click="importAll" :disabled="importingAll" class="btn-primary">
{{ importingAll ? '...' : 'Importer tout' }}
</button>
<button @click="importAll" :disabled="importingAll" class="btn-primary">{{ importingAll ? '...' : 'Importer tout' }}</button>
<button @click="fetchTorrents" :disabled="loading" class="btn-secondary">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
:class="loading ? 'animate-spin' : ''">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" :class="loading ? 'animate-spin' : ''">
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
<polyline points="16 5 21 5 21 10"/>
</svg>
</button>
<button @click="togglePolling" class="btn-secondary">
{{ polling ? 'Pause' : 'Reprendre' }}
</button>
<button @click="togglePolling" class="btn-secondary">{{ polling ? 'Pause' : 'Reprendre' }}</button>
</div>
</div>
@@ -39,138 +31,30 @@
</div>
</div>
<!-- Toolbar : recherche + tri + colonnes -->
<div class="flex items-center gap-2 mb-4">
<div class="relative flex-1 max-w-xs">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
<input
v-model="search"
type="text"
placeholder="Rechercher..."
class="settings-input pl-9 w-full py-1.5 text-sm"
/>
</div>
<!-- Tri -->
<div class="relative shrink-0" ref="sortRef">
<button
@click="sortOpen = !sortOpen"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
:class="activeSort !== 'none' ? 'border-accent text-accent' : ''"
>
<ArrowUpDown :size="14" />
<span class="hidden md:inline text-xs">Trier</span>
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
</button>
<div
v-if="sortOpen"
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl"
>
<button
v-for="s in sortOptions" :key="s.value"
@click="setSort(s.value)"
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
{{ s.label }}
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
</button>
</div>
</div>
<!-- Colonnes -->
<div class="relative shrink-0" ref="colRef">
<button
@click="colOpen = !colOpen"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
:class="colOpen ? 'border-accent text-accent' : ''"
>
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="hidden md:inline text-xs">Colonnes</span>
</button>
<div
v-if="colOpen"
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-52 flex flex-col gap-0.5 shadow-xl"
>
<p class="text-[10px] text-muted px-3 py-1">Colonnes affichées</p>
<label
v-for="col in columnOptions" :key="col.key"
class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs cursor-pointer hover:bg-hover transition-colors"
>
<input type="checkbox" v-model="columns[col.key]" class="accent-accent" />
{{ col.label }}
</label>
</div>
</div>
</div>
<!-- Onglets + filtres -->
<div class="flex items-center justify-between mb-4">
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
<button
@click="activeTab = 'active'"
class="px-4 py-1.5 text-xs rounded-md transition-colors"
:class="activeTab === 'active' ? 'bg-active text-primary' : 'text-muted hover:text-primary'"
>
En cours
<span v-if="activeTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">
{{ activeTorrents.length }}
</span>
</button>
<button
@click="activeTab = 'done'"
class="px-4 py-1.5 text-xs rounded-md transition-colors"
:class="activeTab === 'done' ? 'bg-active text-primary' : 'text-muted hover:text-primary'"
>
Terminés
<span v-if="doneTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">
{{ doneTorrents.length }}
</span>
</button>
</div>
<div v-if="activeTab === 'done'" class="flex items-center gap-3">
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-muted">Seeds uniquement</span>
<button
@click="seedingOnly = !seedingOnly"
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200"
:class="seedingOnly ? 'bg-green-500' : 'bg-border'"
>
<span
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
:class="seedingOnly ? 'translate-x-[18px]' : 'translate-x-0'"
/>
</button>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-muted">Masquer importés</span>
<button
@click="hideImported = !hideImported"
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200"
:class="hideImported ? 'bg-accent' : 'bg-border'"
>
<span
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
:class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'"
/>
</button>
</label>
</div>
</div>
<!-- Toolbar + Tabs -->
<DownloadsToolbar
v-model:search="search"
v-model:active-tab="activeTab"
v-model:seeding-only="seedingOnly"
v-model:hide-imported="hideImported"
:active-sort="activeSort"
:sort-dir="sortDir"
:columns="columns"
:active-count="activeTorrents.length"
:done-count="doneTorrents.length"
:column-options="columnOptions"
:sort-options="sortOptions"
@sort="setSort"
@toggle-col="(key) => (columns as any)[key] = !(columns as any)[key]"
/>
<!-- Pas de clients -->
<div v-if="noClients" class="settings-card flex flex-col items-center gap-3 py-16 text-center">
<p class="text-sm text-muted">Aucun client torrent configuré</p>
<RouterLink to="/settings/download-client" class="text-xs text-accent hover:underline">
Configurer un client →
</RouterLink>
<RouterLink to="/settings/download-client" class="text-xs text-accent hover:underline">Configurer un client →</RouterLink>
</div>
<!-- Loading initial -->
<!-- Loading -->
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
@@ -178,152 +62,25 @@
<!-- Vide -->
<div v-else-if="!loading && visibleTorrents.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
<p class="text-sm text-muted">
{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}
</p>
<p class="text-xs text-muted">
{{ activeTab === 'active'
? 'Les torrents avec la catégorie « fankai » apparaîtront ici'
: hideImported ? 'Tous les torrents terminés sont importés' : '' }}
</p>
<p class="text-sm text-muted">{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}</p>
<p class="text-xs text-muted">{{ activeTab === 'active' ? 'Les torrents avec la catégorie « fankai » apparaîtront ici' : hideImported ? 'Tous les torrents terminés sont importés' : '' }}</p>
</div>
<!-- Liste torrents -->
<div v-else class="flex flex-col gap-2">
<div v-for="t in visibleTorrents" :key="t.hash" class="settings-card flex flex-col gap-3">
<!-- Nom + état + progression -->
<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">{{ t.name }}</p>
<div class="flex items-center gap-2 mt-1">
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(t.state).class">
{{ stateBadge(t.state).label }}
</span>
<span v-if="columns.client" class="text-xs text-muted">{{ t.client_name }}</span>
</div>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-semibold text-primary">{{ clampedProgress(t) }}%</p>
<p v-if="columns.size" class="text-xs text-muted mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
</div>
</div>
<!-- Barre de progression -->
<div class="h-1 bg-border rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="t.state === 'seeding' || t.state === 'unknown' ? 'bg-green-500' :
t.state === 'error' ? 'bg-red-500' :
t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
:style="{ width: `${clampedProgress(t)}%` }"
/>
</div>
<!-- Infos bas + actions -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 text-xs text-muted">
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
<template v-if="t.state === 'seeding'">
<span class="text-green-500">Complété</span>
<span v-if="columns.ratio" class="font-mono"
:class="t.ratio >= 1 ? 'text-green-400' : t.ratio >= 0.5 ? 'text-yellow-500' : 'text-muted'">
R {{ t.ratio?.toFixed(2) ?? '0.00' }}
</span>
<span v-if="columns.uploaded">↑ {{ formatSize(t.uploaded ?? 0) }}</span>
<span v-if="columns.upspeed && t.upspeed > 0">{{ formatSpeed(t.upspeed) }}</span>
</template>
<span v-if="t.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
</div>
<div v-if="t.state === 'seeding' || t.state === 'unknown'" class="flex items-center gap-2">
<!-- Erreurs fichiers -->
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
<span class="text-xs px-2 py-0.5 rounded border border-red-500/40 text-red-400 cursor-default">
{{ t.errorFiles.length }} erreur{{ t.errorFiles.length > 1 ? 's' : '' }}
</span>
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
<div class="bg-card border border-red-500/30 rounded-lg p-3 text-xs shadow-xl">
<p class="text-red-400 mb-2 font-medium">Fichiers en erreur</p>
<div v-for="e in t.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
<p class="text-primary truncate">{{ e.file }}</p>
<p class="text-muted">{{ e.error }}</p>
</div>
</div>
</div>
</div>
<!-- Badge import -->
<span
class="text-xs px-2 py-0.5 rounded border"
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
'border-border text-muted'"
>
{{ t.organizeState === 'done' ? `Importé (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
t.organizeState === 'partial' ? `En cours (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
'Non importé' }}
</span>
<button
v-if="t.organizeState !== 'done'"
@click="importTorrent(t)"
:disabled="importing[t.hash]"
class="btn-secondary text-xs py-1"
>
{{ importing[t.hash] ? '...' : 'Importer' }}
</button>
<!-- Bouton supprimer -->
<div class="relative">
<button
@click="confirmDelete = confirmDelete === t.hash ? null : t.hash"
:disabled="deleting[t.hash]"
class="w-7 h-7 flex items-center justify-center rounded-lg border border-border text-muted hover:border-red-500/40 hover:text-red-400 transition-colors"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
</svg>
</button>
<!-- Popup confirmation -->
<div
v-if="confirmDelete === t.hash"
class="absolute bottom-full right-0 mb-2 bg-card border border-border rounded-xl p-3 z-10 w-52 shadow-xl"
>
<p class="text-xs text-primary font-medium mb-2">Supprimer ce torrent ?</p>
<label class="flex items-center gap-2 text-xs text-muted mb-3 cursor-pointer">
<input type="checkbox" v-model="deleteWithFiles[t.hash]" class="accent-red-500" />
Supprimer aussi les fichiers
</label>
<div class="flex gap-1.5">
<button
@click="deleteTorrent(t)"
:disabled="deleting[t.hash]"
class="flex-1 text-xs py-1 px-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-colors"
>
{{ deleting[t.hash] ? '...' : 'Confirmer' }}
</button>
<button
@click="confirmDelete = null"
class="flex-1 text-xs py-1 px-2 rounded-lg border border-border text-muted hover:text-primary transition-colors"
>
Annuler
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<TorrentCard
v-for="t in visibleTorrents"
:key="t.hash"
:torrent="t"
:tab="activeTab"
:columns="columns"
:importing="!!importing[t.hash]"
:deleting="!!deleting[t.hash]"
:show-confirm-delete="confirmDelete === t.hash"
@import="importTorrent"
@delete="(torrent, withFiles) => deleteTorrent(torrent, withFiles)"
@toggle-confirm="(hash) => { confirmDelete = hash }"
/>
</div>
</div>
@@ -333,13 +90,12 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { Search, ArrowUpDown } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
import { useToast } from '@/composables/useToast'
import TorrentCard from '@/components/downloads/TorrentCard.vue'
import DownloadsToolbar from '@/components/downloads/DownloadsToolbar.vue'
const { add: toast } = useToast()
// ─── State ────────────────────────────────────────────────────
const torrents = ref<any[]>([])
const loading = ref(true)
const polling = ref(true)
@@ -348,26 +104,14 @@ const importing = ref<Record<string, boolean>>({})
const importingAll = ref(false)
const confirmDelete = ref<string | null>(null)
const deleting = ref<Record<string, boolean>>({})
const deleteWithFiles = ref<Record<string, boolean>>({})
const activeTab = ref<'active' | 'done'>('active')
const hideImported = ref(false)
const seedingOnly = ref(false)
const search = ref('')
const sortOpen = ref(false)
const colOpen = ref(false)
const activeSort = ref<'none' | 'name' | 'size' | 'progress' | 'state' | 'ratio'>('none')
const sortDir = ref<'asc' | 'desc'>('asc')
const sortRef = ref<HTMLElement | null>(null)
const colRef = ref<HTMLElement | null>(null)
const columns = ref({
client : true,
size : true,
ratio : true,
uploaded: true,
upspeed : true,
})
const columns = ref({ client: true, size: true, ratio: true, uploaded: true, upspeed: true })
const columnOptions = [
{ key: 'client', label: 'Client' },
{ key: 'size', label: 'Taille / Téléchargé' },
@@ -375,7 +119,6 @@ const columnOptions = [
{ key: 'uploaded', label: 'Uploadé (total)' },
{ key: 'upspeed', label: 'Vitesse upload' },
]
const sortOptions = [
{ label: 'Nom', value: 'name' },
{ label: 'Taille', value: 'size' },
@@ -384,33 +127,24 @@ const sortOptions = [
{ label: 'Ratio', value: 'ratio' },
]
onClickOutside(sortRef, () => { sortOpen.value = false })
onClickOutside(colRef, () => { colOpen.value = false })
let pollInterval: ReturnType<typeof setInterval> | null = null
// ─── Computed ─────────────────────────────────────────────────
const activeTorrents = computed(() =>
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state))
)
const doneTorrents = computed(() =>
torrents.value.filter(t => t.state === 'seeding' || t.state === 'unknown')
)
const visibleTorrents = computed(() => {
let list = activeTab.value === 'active' ? activeTorrents.value : doneTorrents.value
if (activeTab.value === 'done') {
if (seedingOnly.value) list = list.filter(t => t.state === 'seeding')
if (hideImported.value) list = list.filter(t => t.organizeState !== 'done')
}
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(t => t.name.toLowerCase().includes(q))
}
if (activeSort.value !== 'none') {
const dir = sortDir.value === 'asc' ? 1 : -1
list = [...list].sort((a, b) => {
@@ -424,10 +158,8 @@ const visibleTorrents = computed(() => {
}
})
}
return list
})
const stats = computed(() => [
{ label: 'En cours', value: torrents.value.filter(t => t.state === 'downloading').length },
{ label: 'Complétés', value: torrents.value.filter(t => t.state === 'seeding').length },
@@ -435,53 +167,11 @@ const stats = computed(() => [
{ label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
])
// ─── Helpers ──────────────────────────────────────────────────
function setSort(val: typeof activeSort.value) {
if (activeSort.value === val) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
activeSort.value = val
sortDir.value = 'asc'
}
sortOpen.value = false
function setSort(val: string) {
if (activeSort.value === val) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
else { activeSort.value = val as typeof activeSort.value; sortDir.value = 'asc' }
}
function clampedProgress(t: any): number {
return Math.min(100, Math.max(0, t.progress ?? 0))
}
function stateBadge(state: string) {
const map: Record<string, { label: string; class: string }> = {
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
paused : { label: 'En pause', class: 'border-border text-muted' },
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
}
return map[state] ?? map.unknown
}
function formatSize(bytes: number): string {
if (!bytes || bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
function formatSpeed(bps: number): string { return `${formatSize(bps)}/s` }
function formatEta(seconds: number): string {
if (seconds < 0 || seconds > 86400 * 7) return '∞'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`
if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`
return `${s}s`
}
// ─── Actions ──────────────────────────────────────────────────
async function fetchTorrents() {
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
@@ -489,97 +179,55 @@ async function fetchTorrents() {
if (!res.ok) return
torrents.value = await res.json()
noClients.value = false
} catch {
} finally {
loading.value = false
}
} catch {} finally { loading.value = false }
}
function startPolling() {
fetchTorrents()
pollInterval = setInterval(fetchTorrents, 60_000)
}
function stopPolling() {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
}
function togglePolling() {
polling.value = !polling.value
polling.value ? startPolling() : stopPolling()
}
function startPolling() { fetchTorrents(); pollInterval = setInterval(fetchTorrents, 60_000) }
function stopPolling() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null } }
function togglePolling() { polling.value = !polling.value; polling.value ? startPolling() : stopPolling() }
async function importTorrent(torrent: any) {
importing.value[torrent.hash] = true
try {
const res = await fetch('/api/organize', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }),
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }),
})
if (res.ok) {
toast(`${torrent.name} importé ✓`, 'success')
await fetchTorrents()
} else {
const { error } = await res.json()
toast(error ?? "Erreur lors de l'import", 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
importing.value[torrent.hash] = false
}
if (res.ok) { toast(`${torrent.name} importé ✓`, 'success'); await fetchTorrents() }
else { const { error } = await res.json(); toast(error ?? "Erreur lors de l'import", 'error') }
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { importing.value[torrent.hash] = false }
}
async function importAll() {
const toImport = doneTorrents.value.filter(t => t.organizeState !== 'done')
if (toImport.length === 0) { toast('Tous les torrents sont déjà importés', 'success'); return }
importingAll.value = true
let done = 0, errors = 0
for (const t of toImport) {
try {
const res = await fetch('/api/organize', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name }),
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name }),
})
res.ok ? done++ : errors++
} catch { errors++ }
}
importingAll.value = false
await fetchTorrents()
errors === 0
? toast(`${done} torrent(s) importé(s) ✓`, 'success')
: toast(`${done} OK, ${errors} erreur(s)`, 'error')
errors === 0 ? toast(`${done} torrent(s) importé(s) ✓`, 'success') : toast(`${done} OK, ${errors} erreur(s)`, 'error')
}
async function deleteTorrent(torrent: any) {
async function deleteTorrent(torrent: any, withFiles: boolean) {
deleting.value[torrent.hash] = true
try {
const withFiles = deleteWithFiles.value[torrent.hash] ?? false
const res = await fetch(`/api/torrent/${torrent.hash}?deleteFiles=${withFiles}`, {
method : 'DELETE',
credentials: 'include',
})
if (res.ok) {
toast(`"${torrent.name}" supprimé ✓`, 'success')
confirmDelete.value = null
await fetchTorrents()
} else {
toast('Erreur lors de la suppression', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
deleting.value[torrent.hash] = false
}
const res = await fetch(`/api/torrent/${torrent.hash}?deleteFiles=${withFiles}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) { toast(`"${torrent.name}" supprimé ✓`, 'success'); confirmDelete.value = null; await fetchTorrents() }
else toast('Erreur lors de la suppression', 'error')
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { deleting.value[torrent.hash] = false }
}
onMounted(startPolling)
onUnmounted(stopPolling)
</script>
</script>
+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>
+624 -339
View File
File diff suppressed because it is too large. Load diff
+26 -128
View File
@@ -1,121 +1,19 @@
<template>
<div class="flex flex-col">
<!-- Toolbar -->
<div class="shrink-0 px-4 md:px-6 py-3 border-b border-border flex flex-col gap-2">
<!-- Ligne 1 : recherche + filtres -->
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
<input
v-model="search"
type="text"
placeholder="Rechercher…"
class="settings-input pl-9 w-full py-1.5 text-sm"
/>
</div>
<!-- Filtres : icône only sur mobile, texte sur desktop -->
<div class="relative shrink-0" ref="filterRef">
<button
@click="filterOpen = !filterOpen"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 relative"
:class="hasActiveFilter ? 'border-accent text-accent' : ''"
title="Filtres"
>
<SlidersHorizontal :size="15" />
<span class="hidden md:inline text-xs">Filtres</span>
<ChevronDown class="hidden md:inline transition-transform" :size="12" :class="filterOpen ? 'rotate-180' : ''" />
<span v-if="hasActiveFilter" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
</button>
<div
v-if="filterOpen"
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-3 z-20 w-52 flex flex-col gap-3 shadow-xl"
>
<div>
<p class="text-xs text-muted mb-1.5">Disponibilité</p>
<div class="flex flex-col gap-1">
<button
v-for="f in filtersDisponibilite" :key="f.value"
@click="activeFilter = f.value"
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
{{ f.label }}
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
</button>
</div>
</div>
<div class="border-t border-border pt-3">
<p class="text-xs text-muted mb-1.5">État import</p>
<div class="flex flex-col gap-1">
<button
v-for="f in filtersImport" :key="f.value"
@click="activeFilter = f.value"
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full shrink-0" :style="{ background: f.color }" />
{{ f.label }}
</div>
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
</button>
</div>
</div>
<button
v-if="hasActiveFilter"
@click="activeFilter = 'all'"
class="text-xs text-muted hover:text-primary transition-colors text-left pt-1 border-t border-border"
>
Effacer les filtres
</button>
</div>
</div>
</div>
<!-- Ligne 2 : tri + taille + count -->
<div class="flex items-center gap-2 flex-wrap">
<div class="flex items-center gap-1">
<button
v-for="s in sortOptions" :key="s.value"
@click="activeSort = s.value"
class="px-2.5 py-1 text-xs rounded-lg border transition-colors"
:class="activeSort === s.value
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ s.label }}
</button>
</div>
<div class="flex items-center gap-1 border-l border-border pl-2 ml-auto">
<button
v-for="(_, size) in posterSizes" :key="size"
@click="posterSize = size"
class="w-7 h-7 text-[10px] rounded-md border transition-colors flex items-center justify-center"
:class="posterSize === size
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ size }}
</button>
</div>
<span class="text-xs text-muted">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
</div>
</div>
<!-- Légende barres -->
<div class="shrink-0 px-4 md:px-6 py-2 flex items-center gap-4 border-b border-border">
<div v-for="l in legend" :key="l.label" class="flex items-center gap-1.5">
<span class="w-3 h-1.5 rounded-full" :style="{ background: l.color }" />
<span class="text-[11px] text-muted">{{ l.label }}</span>
</div>
</div>
<SeriesToolbar
v-model:search="search"
v-model:active-filter="activeFilter"
v-model:active-sort="activeSort"
v-model:poster-size="posterSize"
:poster-sizes="posterSizes"
:count="filtered.length"
:has-active-filter="hasActiveFilter"
:filters-disponibilite="filtersDisponibilite"
:filters-import="filtersImport"
:sort-options="sortOptions"
:legend="legend"
/>
<!-- Grille -->
<div class="px-4 md:px-6 py-6">
@@ -189,27 +87,25 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
import { RouterLink } from 'vue-router'
import { Search, SlidersHorizontal, ChevronDown, Tv } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
import { useSeriesStore } from '@/stores/series'
import { Tv } from 'lucide-vue-next'
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'
defineOptions({ name: 'SeriesView' })
const store = useSeriesStore()
const dlStore = useDownloadsStore()
const auth = useAuthStore()
const { add: toast } = useToast()
const { current: posterSize, sizes: posterSizes } = usePosterSize()
const search = ref('')
const activeFilter = ref('all')
const activeSort = ref('alpha')
const filterOpen = ref(false)
const filterRef = ref<HTMLElement | null>(null)
onClickOutside(filterRef, () => { filterOpen.value = false })
const filtersDisponibilite = [
{ label: 'Toutes', value: 'all' },
@@ -304,20 +200,22 @@ 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(() => {
if (dlInterval) clearInterval(dlInterval)
})
</script>
</script>
+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' },
+39 -28
View File
@@ -8,38 +8,47 @@
</p>
</div>
<!-- Bannière données manquantes -->
<div
v-if="status.empty"
class="border border-accent/30 bg-accent-muted rounded-lg p-4 flex items-center justify-between gap-4"
>
<div>
<p class="text-xs text-accent font-medium mb-1">Données manquantes</p>
<p class="text-sm text-secondary">
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
Téléchargez les données pour utiliser FanKarr.
<!-- Loading -->
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<template v-else>
<!-- Bannière données manquantes -->
<div
v-if="status.empty"
class="border border-accent/30 bg-accent-muted rounded-lg p-4 flex items-center justify-between gap-4"
>
<div>
<p class="text-xs text-accent font-medium mb-1">Données manquantes</p>
<p class="text-sm text-secondary">
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
Téléchargez les données pour utiliser FanKarr.
</p>
</div>
<button @click="update" :disabled="updating" class="btn-primary whitespace-nowrap">
{{ updating ? 'Téléchargement...' : 'Télécharger' }}
</button>
</div>
<!-- Statut -->
<div class="settings-card">
<p class="text-xs text-muted mb-1">Statut</p>
<p class="text-sm text-primary font-medium">
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}
</p>
</div>
<button @click="update" :disabled="updating" class="btn-primary whitespace-nowrap">
{{ updating ? 'Téléchargement...' : 'Télécharger' }}
</button>
</div>
<!-- Statut -->
<div class="settings-card">
<p class="text-xs text-muted mb-1">Statut</p>
<p class="text-sm text-primary font-medium">
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}
</p>
</div>
<!-- Action -->
<div>
<button @click="update" :disabled="updating" class="btn-primary">
{{ updating ? 'Mise à jour...' : 'Mettre à jour maintenant' }}
</button>
<p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
</div>
<!-- Action -->
<div>
<button @click="update" :disabled="updating" class="btn-primary">
{{ updating ? 'Mise à jour...' : 'Mettre à jour maintenant' }}
</button>
<p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
</div>
</template>
</div>
</template>
@@ -51,11 +60,13 @@ import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const updating = ref(false)
const loaded = ref(false)
const status = ref({ exists: false, count: 0, empty: true })
onMounted(async () => {
const res = await fetch('/api/torrents/status', { credentials: 'include' })
if (res.ok) status.value = await res.json()
loaded.value = true
})
async function update() {
+179 -12
View File
@@ -40,6 +40,74 @@
</div>
</div>
<!-- Tableau comparatif -->
<div>
<h3 class="text-sm font-semibold text-primary mb-1">Comparatif des clients</h3>
<p class="text-xs text-muted mb-3">Fonctionnalités supportées par chaque client.</p>
<div class="overflow-x-auto rounded-xl border border-border">
<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 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'" />
<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-yellow-400' : 'text-muted'">
{{ row.fileSelect ? 'Async ✓' : '✗' }}
</span>
</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>
<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 -->
<Teleport to="body">
<div
@@ -66,18 +134,61 @@
<option v-for="def in availableClients" :key="def.id" :value="def.id">{{ def.label }}</option>
</select>
</div>
<div v-if="currentDefinition" v-for="field in currentDefinition.fields" :key="field.key">
<label class="settings-label">
{{ field.label }}
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="settings-input"
/>
</div>
<!-- Champs principaux -->
<template v-if="currentDefinition">
<div v-for="field in basicFields" :key="field.key">
<label class="settings-label">
{{ field.label }}
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="settings-input"
/>
</div>
<!-- Paramètres avancés -->
<div v-if="advancedFields.length > 0">
<button
type="button"
@click="advancedOpen = !advancedOpen"
class="flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-full py-1"
>
<ChevronDown
:size="13"
class="transition-transform duration-200 shrink-0"
:class="advancedOpen ? 'rotate-180' : ''"
/>
Paramètres avancés
</button>
<div v-if="advancedOpen" class="flex flex-col gap-3 mt-2 pl-3 border-l border-border">
<div v-for="field in advancedFields" :key="field.key">
<label class="settings-label flex items-center gap-1.5">
{{ field.label }}
<span
class="inline-flex items-center justify-center w-4 h-4 rounded-full border border-border text-[10px] text-muted cursor-default leading-none shrink-0 relative group"
tabindex="-1"
>
i
<span class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-56 text-[11px] text-secondary bg-card border border-border rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-10 leading-relaxed">
{{ fieldTooltip(field.key) }}
</span>
</span>
</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
placeholder=""
class="settings-input"
/>
</div>
</div>
</div>
</template>
</div>
<p v-if="!modal.tested && modal.type" class="text-xs text-muted">
@@ -110,6 +221,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { ChevronDown } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
@@ -118,6 +230,37 @@ const clients = ref<any[]>([])
const availableClients = ref<any[]>([])
const healthStatus = ref<Record<string, boolean | null>>({})
const clientMatrix = [
{
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({
open : false,
uuid : null as string | null,
@@ -129,10 +272,32 @@ const modal = ref({
tested : false,
})
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath']
const FIELD_TOOLTIPS: Record<string, string> = {
savePath : 'Optionnel. Permet de placer les téléchargements dans un sous-dossier spécifique à l\'intérieur du dossier déjà configuré dans la gestion des médias.',
remotePath : 'Si le client tourne sur une autre machine, indiquez ici le chemin qu\'il utilise (ex: /downloads). À associer avec le chemin local ci-dessous.',
localPath : 'Chemin équivalent au chemin distant, mais vu par FanKarr sur sa machine (ex: /mnt/nas/downloads). Les deux champs fonctionnent en binôme pour faire la correspondance.',
}
function fieldTooltip(key: string): string {
return FIELD_TOOLTIPS[key] ?? ''
}
const advancedOpen = ref(false)
const currentDefinition = computed(() =>
availableClients.value.find(d => d.id === modal.value.type) ?? null
)
const basicFields = computed(() =>
currentDefinition.value?.fields.filter((f: any) => !ADVANCED_KEYS.includes(f.key)) ?? []
)
const advancedFields = computed(() =>
currentDefinition.value?.fields.filter((f: any) => ADVANCED_KEYS.includes(f.key)) ?? []
)
function clientLabel(type: string) {
return availableClients.value.find(d => d.id === type)?.label ?? type
}
@@ -177,6 +342,7 @@ async function deleteClient(uuid: string) {
function openAddModal() {
modal.value = { open: true, uuid: null, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
advancedOpen.value = false
}
function editClient(client: any) {
@@ -197,6 +363,7 @@ function closeModal() { modal.value.open = false }
function onTypeChange() {
modal.value.config = {}
modal.value.tested = false
advancedOpen.value = false
const def = currentDefinition.value
if (def) {
for (const field of def.fields) {
+311
View File
@@ -0,0 +1,311 @@
<template>
<div class="flex flex-col gap-6 px-4 md:px-8 py-6 max-w-4xl">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-primary">Gestion des imports</h2>
<p class="text-xs text-muted mt-0.5">
Visualisez et renommez vos fichiers importés selon le mode actuel
<span class="ml-1 px-1.5 py-0.5 rounded text-[10px]"
:class="nfoSupport ? 'bg-accent/10 text-accent' : 'bg-hover text-muted'">
{{ nfoSupport ? 'Mode NFO' : 'Mode Formaté' }}
</span>
</p>
</div>
<div class="flex items-center gap-2">
<button @click="load" :disabled="loading" class="btn-secondary">
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
:class="loading ? 'animate-spin' : ''">
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
<polyline points="16 5 21 5 21 10"/>
</svg>
Actualiser
</button>
<button
@click="purgeNfo"
:disabled="purgingNfo"
class="btn-secondary text-red-400 hover:text-red-300"
title="Supprime tous les .nfo, .png, .jpg, .tbn dans les dossiers séries"
>
{{ purgingNfo ? 'Suppression…' : 'Purger NFO / images' }}
</button>
<button
v-if="totalNeedsRename > 0"
@click="renameAll()"
:disabled="renamingAll"
class="btn-primary"
>
{{ renamingAll ? 'Renommage…' : `Tout renommer (${totalNeedsRename})` }}
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<!-- Vide -->
<div v-else-if="series.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
<p class="text-sm text-muted">Aucune série importée</p>
<p class="text-xs text-muted">Importez des séries depuis la page Activité ou via l'import manuel.</p>
</div>
<!-- Résultat rename all -->
<div v-if="renameResult" class="settings-card flex items-center justify-between gap-3">
<p class="text-xs" :class="renameResult.errors.length > 0 ? 'text-yellow-500' : 'text-green-400'">
{{ renameResult.done }} fichier{{ renameResult.done > 1 ? 's' : '' }} renommé{{ renameResult.done > 1 ? 's' : '' }}
<span v-if="renameResult.errors.length > 0"> · {{ renameResult.errors.length }} erreur{{ renameResult.errors.length > 1 ? 's' : '' }}</span>
</p>
<button @click="renameResult = null" class="text-xs text-muted hover:text-primary">✕</button>
</div>
<!-- Liste séries -->
<div v-else class="flex flex-col gap-3">
<div
v-for="serie in filteredSeries"
:key="serie.serie_id"
class="settings-card flex flex-col gap-0"
>
<!-- Header série -->
<div class="flex items-center justify-between py-1">
<div class="flex items-center gap-2.5">
<button @click="toggleSerie(serie.serie_id)" class="text-muted hover:text-primary transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
class="transition-transform" :class="collapsed.has(serie.serie_id) ? '' : 'rotate-180'">
<polyline points="18 15 12 9 6 15"/>
</svg>
</button>
<p class="text-sm font-medium text-primary">{{ serie.serie_title }}</p>
<span class="text-xs text-muted">{{ serie.total }} épisode{{ serie.total > 1 ? 's' : '' }}</span>
<span v-if="serie.needs_rename > 0"
class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 border border-yellow-500/20">
{{ serie.needs_rename }} à renommer
</span>
<span v-if="serie.episodes.filter((e: any) => !e.file_exists).length > 0"
class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">
{{ serie.episodes.filter((e: any) => !e.file_exists).length }} introuvable{{ serie.episodes.filter((e: any) => !e.file_exists).length > 1 ? 's' : '' }}
</span>
<span v-else-if="serie.needs_rename === 0" class="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400 border border-green-500/20">
✓ À jour
</span>
</div>
<div class="flex items-center gap-2">
<button v-if="serie.needs_rename > 0" @click="renameAll(serie.serie_id)" :disabled="renamingAll" class="text-xs text-accent hover:underline">
Tout renommer
</button>
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="text-xs text-muted hover:text-primary transition-colors">
Désimporter
</button>
<button @click="unimportSerie(serie, true)" :disabled="renamingAll" class="text-xs text-red-400 hover:text-red-300 transition-colors">
Supprimer fichiers
</button>
</div>
</div>
<!-- Liste épisodes -->
<div v-if="!collapsed.has(serie.serie_id)" class="flex flex-col divide-y divide-border/50 mt-2 -mx-4 px-4">
<div
v-for="ep in serie.episodes"
:key="ep.episode_id"
class="flex items-center gap-3 py-2.5"
>
<!-- Numéro -->
<span class="text-xs text-muted font-mono shrink-0 w-14">
S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}
</span>
<!-- Noms -->
<div class="flex-1 min-w-0">
<p class="text-xs text-primary truncate">{{ ep.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5">
<span class="text-[11px] font-mono text-muted truncate max-w-xs">{{ ep.current_name }}</span>
<template v-if="ep.needs_rename">
<svg width="10" height="10" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0 text-yellow-400">
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
</svg>
<span class="text-[11px] font-mono text-yellow-400 truncate max-w-xs">{{ ep.expected_name }}</span>
</template>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 shrink-0">
<span v-if="!ep.file_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">Introuvable</span>
<span v-else-if="!ep.needs_rename" class="text-[10px] text-green-400">✓</span>
<button
v-else
@click="renameSingle(ep, serie)"
:disabled="renamingEp[ep.episode_id]"
class="text-xs px-2 py-1 rounded-lg border border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 transition-colors"
>
{{ renamingEp[ep.episode_id] ? '…' : 'Renommer' }}
</button>
<button
@click="unimportEp(ep, false)"
:disabled="renamingEp[ep.episode_id]"
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors"
>
Désimporter
</button>
<button
@click="unimportEp(ep, true)"
:disabled="renamingEp[ep.episode_id]"
class="text-xs px-2 py-1 rounded-lg border border-red-500/20 text-red-400 hover:bg-red-500/10 transition-colors"
>
Supprimer
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
// ─── State ────────────────────────────────────────────────────
const loading = ref(true)
const renamingAll = ref(false)
const purgingNfo = ref(false)
const series = ref<any[]>([])
const nfoSupport = ref(false)
const collapsed = ref<Set<number>>(new Set())
const renamingEp = ref<Record<number, boolean>>({})
const renameResult = ref<{ done: number; errors: any[] } | null>(null)
const showOnlyNeedsRename = ref(false)
// ─── Computed ──────────────────────────────────────────────────
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
const filteredSeries = computed(() =>
showOnlyNeedsRename.value ? series.value.filter(s => s.needs_rename > 0) : series.value
)
// ─── Actions ──────────────────────────────────────────────────
async function load() {
loading.value = true
try {
const res = await fetch('/api/organized-summary', { credentials: 'include' })
if (!res.ok) { toast('Erreur chargement', 'error'); return }
const data = await res.json()
series.value = data.series ?? []
nfoSupport.value = data.nfo_support ?? false
// Replier les séries sans rename par défaut
collapsed.value = new Set(series.value.filter(s => s.needs_rename === 0).map((s: any) => s.serie_id))
} finally {
loading.value = false
}
}
function toggleSerie(id: number) {
if (collapsed.value.has(id)) collapsed.value.delete(id)
else collapsed.value.add(id)
// Forcer la réactivité
collapsed.value = new Set(collapsed.value)
}
async function renameAll(serieId?: number) {
renamingAll.value = true
renameResult.value = null
try {
const res = await fetch('/api/rename-all', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify(serieId ? { serie_id: serieId } : {}),
})
const data = await res.json()
renameResult.value = { done: data.done, errors: data.errors ?? [] }
if (data.done > 0) await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
renamingAll.value = false
}
}
async function renameSingle(ep: any, serie: any) {
renamingEp.value[ep.episode_id] = true
try {
const res = await fetch('/api/rename-episode', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ serie_id: serie.serie_id, episode_id: ep.episode_id, torrent_hash: ep.torrent_hash }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
toast(`Renommé : ${data.new_name}`, 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
renamingEp.value[ep.episode_id] = false
}
}
async function purgeNfo() {
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
purgingNfo.value = true
try {
const res = await fetch('/api/purge-nfo', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
toast(`${data.deleted} fichier${data.deleted > 1 ? 's' : ''} supprimé${data.deleted > 1 ? 's' : ''}${data.errors.length > 0 ? ` (${data.errors.length} erreur${data.errors.length > 1 ? 's' : ''})` : ''}`, data.errors.length > 0 ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
purgingNfo.value = false
}
}
async function unimportSerie(serie: any, deleteFile: boolean) {
if (!confirm(`Désimporter "${serie.serie_title}"${deleteFile ? ' et supprimer les fichiers' : ''} ?`)) return
renamingAll.value = true
try {
const res = await fetch(`/api/organized/${serie.serie_id}?deleteFile=${deleteFile}`, {
method: 'DELETE', credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
const data = await res.json()
toast(`${data.removed} épisode(s) désimporté(s) ✓`, 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
renamingAll.value = false
}
}
async function unimportEp(ep: any, deleteFile: boolean) {
renamingEp.value[ep.episode_id] = true
try {
// On cherche le serie_id depuis la liste
const serie = series.value.find(s => s.episodes.some((e: any) => e.episode_id === ep.episode_id))
if (!serie) return
const res = await fetch(`/api/organized/${serie.serie_id}/${ep.episode_id}?deleteFile=${deleteFile}`, {
method : 'DELETE',
credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
renamingEp.value[ep.episode_id] = false
}
}
onMounted(load)
</script>
+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>
+195 -151
View File
@@ -6,131 +6,131 @@
<p class="text-sm text-muted mt-1">Configuration des dossiers et du comportement d'import.</p>
</div>
<!-- Dossiers -->
<div class="settings-card flex flex-col gap-5">
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="settings-label">Dossier téléchargements</label>
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
{{ form.completePath || '/' }}
</button>
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
</div>
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="settings-label">Médiathèque Fankai</label>
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
{{ form.mediaPath || '/' }}
</button>
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
</div>
<!-- Loading -->
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<!-- Alerte globale si un des deux paths est sur / -->
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
<div class="flex flex-col gap-0.5">
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
<template v-else>
<!-- Chemins -->
<div class="settings-card flex flex-col gap-5">
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="settings-label">Dossier téléchargements</label>
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
{{ form.completePath || '/' }}
</button>
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
</div>
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="settings-label">Médiathèque Fankai</label>
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
{{ form.mediaPath || '/' }}
</button>
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
</div>
</div>
<!-- Alerte chemins non configurés -->
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
<div class="flex flex-col gap-0.5">
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
<p class="text-xs text-muted">
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
Configurez-les ci-dessus avant de sauvegarder.
</p>
</div>
</div>
<!-- Mode import -->
<div class="settings-card flex flex-col gap-2">
<label class="settings-label">Mode d'import</label>
<div class="flex gap-2">
<button
v-for="mode in (['hardlink', 'copy', 'move'] as const)"
:key="mode"
@click="form.organizeMode = mode"
class="px-4 py-2 text-xs rounded-lg border transition-colors capitalize"
:class="form.organizeMode === mode
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ mode === 'hardlink' ? 'Hardlink' : mode === 'copy' ? 'Copier' : 'Déplacer' }}
</button>
</div>
<p class="text-xs text-muted">
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
Configurez-les ci-dessus avant de sauvegarder.
<span class="text-primary">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
</p>
</div>
</div>
<!-- Mode import -->
<div class="settings-card flex flex-col gap-2">
<label class="settings-label">Mode d'import</label>
<div class="flex gap-2">
<button
@click="form.organizeMode = 'hardlink'"
class="px-4 py-2 text-xs rounded-lg border transition-colors"
:class="form.organizeMode === 'hardlink'
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
Hardlink
</button>
<button
@click="form.organizeMode = 'copy'"
class="px-4 py-2 text-xs rounded-lg border transition-colors"
:class="form.organizeMode === 'copy'
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
Copier
</button>
<button
@click="form.organizeMode = 'move'"
class="px-4 py-2 text-xs rounded-lg border transition-colors"
:class="form.organizeMode === 'move'
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
Déplacer
</button>
<!-- Toggles -->
<div class="settings-card flex flex-col gap-5">
<SettingsToggle
v-model="form.autoImport"
label="Import automatique"
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
/>
<SettingsToggle
:model-value="form.nfoSupport"
@update:model-value="onNfoToggle"
label="NFO / Métadonnées"
description="Télécharge les NFO et images depuis GitLab lors de l'import (Kodi, Infuse, etc.)."
/>
<SettingsToggle
v-if="form.organizeMode === 'move'"
v-model="form.deleteTorrentOnMove"
label="Supprimer le torrent après déplacement"
description="Supprime automatiquement le torrent du client après un import en mode Déplacer."
/>
<SettingsToggle
v-model="form.autoUnimportMissing"
label="Désimporter si fichier manquant"
description="Si un fichier importé n'est plus trouvé sur le disque lors du scan, il est automatiquement retiré de la bibliothèque."
/>
</div>
<p class="text-xs text-muted">
<span class="text-primary">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
</p>
</div>
<!-- Toggles -->
<div class="settings-card flex flex-col gap-5">
<!-- Plex -->
<div class="settings-card flex items-center justify-between gap-4">
<div>
<p class="settings-label">Plex</p>
<p class="text-xs text-muted mt-0.5">
Créez une bibliothèque Plex connectée à l'agent de métadonnées Fankai.
Requiert Plex Media Server 1.43+ pour la configuration automatique de l'agent.
</p>
</div>
<button @click="plexOpen = true" class="btn-secondary shrink-0">Configurer Plex</button>
</div>
<SettingsToggle
v-model="form.autoImport"
label="Import automatique"
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
/>
<SettingsToggle
v-model="form.nfoSupport"
label="NFO / Métadonnées"
description="Télécharge les NFO et images depuis GitLab lors de l'import (Kodi, Infuse, etc.)."
/>
<SettingsToggle
v-if="form.organizeMode === 'move'"
v-model="form.deleteTorrentOnMove"
label="Supprimer le torrent après déplacement"
description="Supprime automatiquement le torrent du client après un import en mode Déplacer."
/>
</div>
<!-- Actions -->
<div class="flex items-center gap-3">
<button @click="save" :disabled="saving" class="btn-primary">
{{ saving ? '...' : 'Sauvegarder' }}
</button>
<button @click="scan" :disabled="scanning" class="btn-secondary">
{{ scanning ? 'Scan...' : 'Analyser médiathèque' }}
</button>
<span
v-if="scanResult"
class="text-xs"
:class="scanResult.added > 0 ? 'text-green-400' : 'text-muted'"
>
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
</span>
</div>
<!-- Actions -->
<div class="flex items-center gap-3">
<button @click="save" :disabled="saving" class="btn-primary">
{{ saving ? '...' : 'Sauvegarder' }}
</button>
<button @click="scan" :disabled="scanning" class="btn-secondary">
{{ scanning ? 'Scan...' : 'Analyser médiathèque' }}
</button>
<span v-if="scanResult" class="text-xs" :class="scanResult.added > 0 ? 'text-green-400' : 'text-muted'">
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
</span>
</div>
</template>
</div>
<!-- Folder Picker -->
@@ -140,6 +140,41 @@
@select="onPickerSelect"
@cancel="picker.open = false"
/>
<!-- Plex Wizard -->
<PlexWizard
v-if="plexOpen"
:media-path="form.mediaPath"
@close="plexOpen = false"
/>
<!-- Confirmation NFO -->
<Teleport to="body">
<div
v-if="nfoConfirmOpen"
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"
@click.self="nfoConfirmOpen = false"
>
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<h3 class="text-sm font-semibold text-primary">Activer les NFO / Métadonnées ?</h3>
<p class="text-xs text-muted leading-relaxed">
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex ou Emby</span>,
les métadonnées sont déjà gérées directement par l'agent — activer les NFO est inutile
et peut créer des conflits.
</p>
<p class="text-xs text-muted leading-relaxed">
Activez cette option uniquement si vous utilisez <span class="text-primary">Kodi, Infuse</span>
ou un lecteur qui lit les fichiers NFO locaux.
</p>
</div>
<div class="flex gap-2 pt-1">
<button @click="confirmNfo" class="btn-primary text-xs">Oui, activer quand même</button>
<button @click="nfoConfirmOpen = false" class="btn-secondary text-xs">Annuler</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
@@ -147,33 +182,46 @@ import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import FolderPicker from '@/components/FolderPicker.vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import PlexWizard from '@/components/settings/PlexWizard.vue'
const { add: toast } = useToast()
const saving = ref(false)
const scanning = ref(false)
const isDocker = ref(false)
const scanResult = ref<{ found: number; added: number } | null>(null)
const saving = ref(false)
const scanning = ref(false)
const isDocker = ref(false)
const loaded = ref(false)
const plexOpen = ref(false)
const nfoConfirmOpen = ref(false)
const scanResult = ref<{ found: number; added: number } | null>(null)
function onNfoToggle(val: boolean) {
if (val && !form.value.nfoSupport) {
nfoConfirmOpen.value = true
} else {
form.value.nfoSupport = val
}
}
function confirmNfo() {
form.value.nfoSupport = true
nfoConfirmOpen.value = false
}
const form = ref({
mediaPath : '',
completePath : '',
organizeMode : 'hardlink' as 'hardlink' | 'copy' | 'move',
nfoSupport : false,
autoImport : true,
deleteTorrentOnMove: false,
mediaPath : '',
completePath : '',
organizeMode : 'hardlink' as 'hardlink' | 'copy' | 'move',
nfoSupport : false,
autoImport : true,
deleteTorrentOnMove : false,
autoUnimportMissing : false,
})
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
open: false,
field: 'completePath',
currentPath: '/',
open: false, field: 'completePath', currentPath: '/',
})
// FIX : détection path non configuré (vide ou racine /)
function isRootPath(p: string): boolean {
return !p || p === '/'
}
function isRootPath(p: string): boolean { return !p || p === '/' }
const hasUnconfiguredPaths = computed(() =>
isRootPath(form.value.completePath) || isRootPath(form.value.mediaPath)
@@ -188,17 +236,6 @@ function onPickerSelect(path: string) {
picker.value.open = false
}
onMounted(async () => {
const [settingsRes, systemRes, infoRes] = await Promise.all([
fetch('/api/settings', { credentials: 'include' }),
fetch('/api/system', { credentials: 'include' }),
fetch('/api/system/info', { credentials: 'include' }),
])
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
})
async function save() {
saving.value = true
try {
@@ -214,19 +251,14 @@ async function save() {
}
async function scan() {
scanning.value = true
scanning.value = true
scanResult.value = null
try {
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
if (res.ok) {
const data = await res.json()
scanResult.value = { found: data.found, added: data.added }
toast(
data.added > 0
? `${data.found} fichiers analysés — ${data.added} ajoutés`
: `${data.found} fichiers analysés — rien de nouveau`,
'success'
)
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
} else {
toast("Erreur lors de l'analyse", 'error')
}
@@ -236,4 +268,16 @@ async function scan() {
scanning.value = false
}
}
</script>
onMounted(async () => {
const [settingsRes, systemRes, infoRes] = await Promise.all([
fetch('/api/settings', { credentials: 'include' }),
fetch('/api/system', { credentials: 'include' }),
fetch('/api/system/info', { credentials: 'include' }),
])
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
loaded.value = true
})
</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.8.0
3.18.1